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()