diff --git a/apps/desktop/electron/git-worktree-ops.test.ts b/apps/desktop/electron/git-worktree-ops.test.ts index e63e756ba6f1b..0149088ff8ecf 100644 --- a/apps/desktop/electron/git-worktree-ops.test.ts +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -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 }) + } +}) diff --git a/apps/desktop/electron/git-worktree-ops.ts b/apps/desktop/electron/git-worktree-ops.ts index a2a875765d0be..2ff43862c32f7 100644 --- a/apps/desktop/electron/git-worktree-ops.ts +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -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 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 [] } diff --git a/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx index 9b41847015672..d4491c5b5bb0b 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx @@ -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} > - + {branch.name} {branchActionLabel(branch, p)} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 471b5c0dc6a7e..6a80736bf0604 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 // 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 } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 26f72e7f4d92e..cf7302e1da8db 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1607,6 +1607,7 @@ export const ar = defineLocale({ branchOpenExisting: 'فتح', branchSwitchHome: 'تبديل الموطن', branchCreateWorktree: 'شجرة عمل جديدة', + branchTrackRemote: 'تتبع البعيد', branchesLoading: 'جار تحميل الفروع...', noBranches: 'لم يتم العثور على فروع', removeWorktree: 'إزالة شجرة العمل', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0914021c0d248..47727de87e941 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a8ae05042b7e6..1409940479a28 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1750,6 +1750,7 @@ export const ja = defineLocale({ branchOpenExisting: '開く', branchSwitchHome: 'ホームを切替', branchCreateWorktree: '新しいワークツリー', + branchTrackRemote: 'リモートを追跡', branchesLoading: 'ブランチを読み込み中…', noBranches: 'ブランチが見つかりません', removeWorktree: 'ワークツリーを削除', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index f3ef8501205ad..d73175787e106 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1609,6 +1609,7 @@ export interface Translations { branchOpenExisting: string branchSwitchHome: string branchCreateWorktree: string + branchTrackRemote: string branchesLoading: string noBranches: string removeWorktree: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index fc7feb8f39574..7996bd74fd93a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1693,6 +1693,7 @@ export const zhHant = defineLocale({ branchOpenExisting: '開啟', branchSwitchHome: '切回主簽出', branchCreateWorktree: '新增工作樹', + branchTrackRemote: '追蹤遠端', branchesLoading: '正在載入分支…', noBranches: '找不到分支', removeWorktree: '移除工作樹', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 927d5c1b25fef..6a7231aa0d592 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2106,6 +2106,7 @@ export const zh: Translations = { branchOpenExisting: '打开', branchSwitchHome: '切回主检出', branchCreateWorktree: '新工作树', + branchTrackRemote: '跟踪远程', branchesLoading: '正在加载分支…', noBranches: '未找到分支', removeWorktree: '移除工作树', diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index c44365a9cb4ed..5808abcad8050 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -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 { const git = desktopGit()