feat(desktop): let convert-a-branch reach remote branches too

This commit is contained in:
ethernet 2026-08-04 17:16:54 -04:00
parent b818c427c8
commit cb7f594be8
11 changed files with 235 additions and 24 deletions

View File

@ -321,3 +321,105 @@ test('addWorktree: base origin/main does not set up upstream tracking', async ()
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})
// A pair of repos: a bare "remote" with `main` and the extra branches in
// `branches`, plus a clone of it. Returns both paths. The caller must remove
// them.
function seedRemoteAndClone(label, branches) {
const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-${label}-remote-`))
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-${label}-clone-`))
const remoteGit = (...args) => execFileSync('git', ['-C', remoteDir, ...args]).toString().trim()
execFileSync('git', ['init', '-b', 'main', remoteDir])
remoteGit('-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root')
for (const branch of branches) {
remoteGit('branch', branch)
}
execFileSync('git', ['clone', remoteDir, cloneDir])
return { cloneDir, remoteDir }
}
test('listBranches: offers remote branches that have no local counterpart', async () => {
const { cloneDir, remoteDir } = seedRemoteAndClone('branches-remote', ['teammate-work'])
try {
const branches = await listBranches(cloneDir, 'git')
const byName = new Map(branches.map(b => [b.name, b]))
// The teammate's branch is only on the remote. The list therefore offers it
// by its remote-tracking name, with a flag that lets the UI say "track
// remote".
const remoteOnly = byName.get('origin/teammate-work')
assert.ok(remoteOnly)
assert.equal(remoteOnly.isRemote, true)
assert.equal(remoteOnly.checkedOut, false)
assert.equal(remoteOnly.isDefault, false)
assert.equal(remoteOnly.worktreePath, null)
// `main` is checked out locally, so it shows once as a local branch.
// "origin/main" is a duplicate of a branch that is already in the list.
assert.equal(byName.get('main').isRemote, false)
assert.equal(byName.has('origin/main'), false)
// "origin/HEAD" is an alias for the default branch of the remote. It is not
// a branch.
assert.equal(
branches.some(b => b.name.endsWith('/HEAD')),
false
)
} finally {
fs.rmSync(remoteDir, { recursive: true, force: true })
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})
test('addWorktree: a remote branch becomes a local branch tracking it', async () => {
const { cloneDir, remoteDir } = seedRemoteAndClone('convert-remote', ['teammate-work'])
try {
const result = await addWorktree(cloneDir, { existingBranch: 'origin/teammate-work' }, 'git')
const inTree = (...args) => execFileSync('git', ['-C', result.path, ...args]).toString().trim()
// The worktree is on a local branch that has the name of the remote one. It
// is not on a detached HEAD, which is the result of a checkout of
// "origin/teammate-work".
assert.equal(result.branch, 'teammate-work')
assert.equal(inTree('branch', '--show-current'), 'teammate-work')
assert.match(result.path, /[/\\]\.worktrees[/\\]teammate-work/)
// The branch tracks the remote branch, so push and pull work with no more
// setup.
assert.equal(inTree('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'), 'origin/teammate-work')
} finally {
fs.rmSync(remoteDir, { recursive: true, force: true })
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})
test('addWorktree: a remote default branch gets its own worktree, not a home switch', async () => {
const { cloneDir, remoteDir } = seedRemoteAndClone('convert-remote-default', [])
const git = (...args) => execFileSync('git', ['-C', cloneDir, ...args]).toString().trim()
try {
// Move the main checkout off `main`, which makes "origin/main" convertible.
// The local `main` is then free, but the request names the remote-tracking
// ref.
git('switch', '-c', 'rawr')
git('branch', '-D', 'main')
const result = await addWorktree(cloneDir, { existingBranch: 'origin/main' }, 'git')
// "switch home" applies to a local default branch. A remote ref always gets
// a new worktree, so the main checkout stays where the user put it.
assert.equal(result.branch, 'main')
assert.notEqual(fs.realpathSync(result.path), fs.realpathSync(cloneDir))
assert.equal(git('branch', '--show-current'), 'rawr')
} finally {
fs.rmSync(remoteDir, { recursive: true, force: true })
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})

View File

@ -122,6 +122,35 @@ async function gitLine(gitBin, args, cwd) {
}
}
// True when the command exits 0. Use this function and not `gitLine` for a
// `--quiet` probe. A `--quiet` probe prints nothing when it finds the ref, and
// that output is the same as the output of a failure.
async function gitOk(gitBin, args, cwd) {
try {
await runGit(gitBin, args, cwd)
return true
} catch {
return false
}
}
// The remote that a ref belongs to ("origin" for "origin/main"), or "" when the
// name is not a remote-tracking ref in this repo. This function asks git. It
// does not assume that the remote has the name "origin", because a repo can
// give its remotes any name.
async function remoteOfRef(gitBin, cwd, name) {
if (!name.includes('/')) {
return ''
}
if (!(await gitOk(gitBin, ['show-ref', '--verify', '--quiet', `refs/remotes/${name}`], cwd))) {
return ''
}
return name.slice(0, name.indexOf('/'))
}
async function defaultBranch(gitBin, cwd) {
const remote = (
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
@ -214,19 +243,43 @@ function uniqueDir(base) {
}
async function addExistingBranchWorktree(gitBin, root, name) {
const branch = sanitizeBranch(name)
const requested = sanitizeBranch(name)
if (!branch) {
if (!requested) {
throw new Error('Branch name is required.')
}
if (branch === (await defaultBranch(gitBin, root))) {
// "origin/feature" is a remote-tracking ref and not a branch that git can
// check out. `git worktree add <dir> origin/feature` detaches HEAD. Make a
// local branch with the same short name that tracks the remote ref. This is
// what `git switch feature` does for a branch on exactly one remote.
const remote = await remoteOfRef(gitBin, root, requested)
const branch = remote ? requested.slice(remote.length + 1) : requested
if (!remote && branch === (await defaultBranch(gitBin, root))) {
await runGit(gitBin, ['switch', branch], root)
return { path: root, branch, repoRoot: root }
}
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
if (remote) {
// The remote-tracking ref is stale if the user did not fetch recently. This
// fetch is best effort: after a failure, the last known ref is still there
// to branch from.
try {
await runGit(gitBin, ['fetch', remote, branch], root)
} catch {
// The user is offline, or the branch is gone from the remote. Use the ref
// that the repo already has.
}
await runGit(gitBin, ['worktree', 'add', '--track', '-b', branch, dir, requested], root)
return { path: dir, branch, repoRoot: root }
}
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
return { path: dir, branch, repoRoot: root }
@ -310,10 +363,14 @@ async function removeWorktree(repoPath, worktreePath, options, gitBin) {
return { removed: resolvedTree }
}
// List local branches for the "convert a branch into a worktree" picker, most
// recently committed first. Each carries whether it's already checked out in a
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
// remote backend where the probe can't run.
// List the branches for the "convert a branch into a worktree" picker, most
// recently committed first. The local heads come first. Then come the
// remote-tracking refs that have no local branch yet. This is the same set that
// the base-branch picker offers, so "convert" can reach a teammate's branch
// that the user did not check out.
// Each branch carries a flag for a checkout in a worktree, and the path of that
// worktree. Empty on a non-repo or a remote backend, where the probe cannot
// run.
async function listBranches(repoPath, gitBin) {
let resolved
@ -324,26 +381,55 @@ async function listBranches(repoPath, gitBin) {
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const [localOut, remoteOut] = await Promise.all([
runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved),
runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/remotes'], resolved)
])
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(name => ({
const names = (out: string) =>
out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
const locals = names(localOut)
const localSet = new Set(locals)
const remotes = names(remoteOut).filter(name => {
// "origin/HEAD" is a symbolic alias for the default branch of the remote.
// It is not a branch, and it shows in the list as a duplicate.
if (name.endsWith('/HEAD')) {
return false
}
// The user reaches a remote branch that they track locally through its
// local head. To list both is noise, and a checkout of the
// remote-tracking ref detaches HEAD.
return !localSet.has(name.slice(name.indexOf('/') + 1))
})
return [
...locals.map(name => ({
name,
checkedOut: pathByBranch.has(name),
isDefault: Boolean(trunk && name === trunk),
isRemote: false,
worktreePath: pathByBranch.get(name) || null
})),
...remotes.map(name => ({
// A remote branch has no local checkout, and it cannot be the local
// trunk. It is therefore never checked out and never the default.
name,
checkedOut: false,
isDefault: false,
isRemote: true,
worktreePath: null
}))
]
} catch {
return []
}

View File

@ -35,6 +35,7 @@ interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
branchTrackRemote: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
@ -42,6 +43,10 @@ const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
return copy.branchOpenExisting
}
if (branch.isRemote) {
return copy.branchTrackRemote
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
@ -274,7 +279,11 @@ export function WorktreeDialog() {
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={branch.isRemote ? 'repo' : 'git-branch'}
size="0.8rem"
/>
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}

View File

@ -199,7 +199,8 @@ declare global {
options?: { force?: boolean }
) => Promise<{ removed: string }>
branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }>
// Local branches for the "convert a branch into a worktree" picker.
// The local branches, plus the remote-tracking refs that have no local
// branch, for the "convert a branch into a worktree" picker.
branchList: (repoPath: string) => Promise<HermesGitBranch[]>
// Local + remote-tracking branches for the "base branch" picker in the
// new-worktree dialog. The remote default (origin/HEAD) is flagged so
@ -820,13 +821,17 @@ export interface HermesGitWorktree {
locked: boolean
}
// A local branch as offered by the "convert a branch into a worktree" picker.
// `checkedOut` means selecting opens that checkout; `isDefault` means selecting
// switches the main checkout instead of creating `.worktrees/main`.
// A branch that the "convert a branch into a worktree" picker offers: the local
// heads, plus the remote-tracking refs that have no local branch yet.
// `checkedOut` means that a selection opens that checkout. `isDefault` means
// that a selection switches the main checkout, and does not make
// `.worktrees/main`. `isRemote` means that a selection first makes a local
// branch that tracks the remote one.
export interface HermesGitBranch {
name: string
checkedOut: boolean
isDefault: boolean
isRemote: boolean
worktreePath: null | string
}

View File

@ -1607,6 +1607,7 @@ export const ar = defineLocale({
branchOpenExisting: 'فتح',
branchSwitchHome: 'تبديل الموطن',
branchCreateWorktree: 'شجرة عمل جديدة',
branchTrackRemote: 'تتبع البعيد',
branchesLoading: 'جار تحميل الفروع...',
noBranches: 'لم يتم العثور على فروع',
removeWorktree: 'إزالة شجرة العمل',

View File

@ -1913,6 +1913,7 @@ export const en: Translations = {
branchOpenExisting: 'open',
branchSwitchHome: 'switch home',
branchCreateWorktree: 'new worktree',
branchTrackRemote: 'track remote',
branchesLoading: 'Loading branches…',
noBranches: 'No branches found',
removeWorktree: 'Remove worktree',

View File

@ -1750,6 +1750,7 @@ export const ja = defineLocale({
branchOpenExisting: '開く',
branchSwitchHome: 'ホームを切替',
branchCreateWorktree: '新しいワークツリー',
branchTrackRemote: 'リモートを追跡',
branchesLoading: 'ブランチを読み込み中…',
noBranches: 'ブランチが見つかりません',
removeWorktree: 'ワークツリーを削除',

View File

@ -1609,6 +1609,7 @@ export interface Translations {
branchOpenExisting: string
branchSwitchHome: string
branchCreateWorktree: string
branchTrackRemote: string
branchesLoading: string
noBranches: string
removeWorktree: string

View File

@ -1693,6 +1693,7 @@ export const zhHant = defineLocale({
branchOpenExisting: '開啟',
branchSwitchHome: '切回主簽出',
branchCreateWorktree: '新增工作樹',
branchTrackRemote: '追蹤遠端',
branchesLoading: '正在載入分支…',
noBranches: '找不到分支',
removeWorktree: '移除工作樹',

View File

@ -2106,6 +2106,7 @@ export const zh: Translations = {
branchOpenExisting: '打开',
branchSwitchHome: '切回主检出',
branchCreateWorktree: '新工作树',
branchTrackRemote: '跟踪远程',
branchesLoading: '正在加载分支…',
noBranches: '未找到分支',
removeWorktree: '移除工作树',

View File

@ -1086,8 +1086,11 @@ export async function startWorkInRepo(
return { branch: result.branch, path: result.path }
}
// Local branches for the composer's "convert a branch into a worktree" picker.
// Empty on a remote backend / non-repo (the Electron probe can't run).
// Branches for the composer's "convert a branch into a worktree" picker: the
// local heads, plus the remote-tracking refs that have no local branch yet. A
// teammate's branch is therefore reachable, and the user does not check it out
// by hand first.
// Empty on a remote backend or a non-repo, where the Electron probe cannot run.
export async function listRepoBranches(repoPath: string): Promise<HermesGitBranch[]> {
const git = desktopGit()