feat(desktop): let convert-a-branch reach remote branches too
This commit is contained in:
parent
b818c427c8
commit
cb7f594be8
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1607,6 +1607,7 @@ export const ar = defineLocale({
|
|||
branchOpenExisting: 'فتح',
|
||||
branchSwitchHome: 'تبديل الموطن',
|
||||
branchCreateWorktree: 'شجرة عمل جديدة',
|
||||
branchTrackRemote: 'تتبع البعيد',
|
||||
branchesLoading: 'جار تحميل الفروع...',
|
||||
noBranches: 'لم يتم العثور على فروع',
|
||||
removeWorktree: 'إزالة شجرة العمل',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1750,6 +1750,7 @@ export const ja = defineLocale({
|
|||
branchOpenExisting: '開く',
|
||||
branchSwitchHome: 'ホームを切替',
|
||||
branchCreateWorktree: '新しいワークツリー',
|
||||
branchTrackRemote: 'リモートを追跡',
|
||||
branchesLoading: 'ブランチを読み込み中…',
|
||||
noBranches: 'ブランチが見つかりません',
|
||||
removeWorktree: 'ワークツリーを削除',
|
||||
|
|
|
|||
|
|
@ -1609,6 +1609,7 @@ export interface Translations {
|
|||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
branchCreateWorktree: string
|
||||
branchTrackRemote: string
|
||||
branchesLoading: string
|
||||
noBranches: string
|
||||
removeWorktree: string
|
||||
|
|
|
|||
|
|
@ -1693,6 +1693,7 @@ export const zhHant = defineLocale({
|
|||
branchOpenExisting: '開啟',
|
||||
branchSwitchHome: '切回主簽出',
|
||||
branchCreateWorktree: '新增工作樹',
|
||||
branchTrackRemote: '追蹤遠端',
|
||||
branchesLoading: '正在載入分支…',
|
||||
noBranches: '找不到分支',
|
||||
removeWorktree: '移除工作樹',
|
||||
|
|
|
|||
|
|
@ -2106,6 +2106,7 @@ export const zh: Translations = {
|
|||
branchOpenExisting: '打开',
|
||||
branchSwitchHome: '切回主检出',
|
||||
branchCreateWorktree: '新工作树',
|
||||
branchTrackRemote: '跟踪远程',
|
||||
branchesLoading: '正在加载分支…',
|
||||
noBranches: '未找到分支',
|
||||
removeWorktree: '移除工作树',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue