diff --git a/src/main/services/worktree.service.ts b/src/main/services/worktree.service.ts index 27c9c68..db76c02 100644 --- a/src/main/services/worktree.service.ts +++ b/src/main/services/worktree.service.ts @@ -1,6 +1,6 @@ import simpleGit from 'simple-git' -import { join, dirname, basename } from 'path' -import { mkdir, access } from 'fs/promises' +import { join, dirname, basename, resolve as resolvePath } from 'path' +import { mkdir, access, realpath } from 'fs/promises' import type { WorktreeInfo } from '../../shared/types' function worktreeDir(repoPath: string): string { @@ -8,6 +8,19 @@ function worktreeDir(repoPath: string): string { return join(dirname(repoPath), '.codecrucible-worktrees', repoName) } +// `git worktree prune` clears stale administrative entries left behind when a +// worktree's directory is deleted or moved outside of git (e.g. after the +// parent repo itself is moved). Stale entries keep their branch "in use", +// which causes tools like GitHub Desktop to fail with +// `cannot delete branch '...' used by worktree at '...'`. +async function pruneWorktrees(repoPath: string): Promise { + try { + await simpleGit(repoPath).raw(['worktree', 'prune']) + } catch { + // Best-effort cleanup + } +} + // If `branch` is currently checked out in some worktree, detach that worktree // (HEAD becomes detached at the same commit) so we can add a new worktree // claiming the branch. Returns the path that was detached, or null. @@ -49,6 +62,7 @@ export async function createWorktree( const wtPath = join(wtBase, sessionName) await mkdir(wtBase, { recursive: true }) + await pruneWorktrees(repoPath) // Check if repo has any commits let hasCommits = true @@ -104,6 +118,7 @@ export async function createWorktreeFromBranch( const wtPath = join(wtBase, sessionName) await mkdir(wtBase, { recursive: true }) + await pruneWorktrees(repoPath) // Fetch the branch from origin await g.raw(['fetch', 'origin', remoteBranch]) @@ -146,6 +161,7 @@ export async function createWorktreeFromBranch( export async function listWorktrees(repoPath: string): Promise { const g = simpleGit(repoPath) + await pruneWorktrees(repoPath) const result = await g.raw(['worktree', 'list', '--porcelain']) const worktrees: WorktreeInfo[] = [] @@ -169,5 +185,84 @@ export async function listWorktrees(repoPath: string): Promise { export async function removeWorktree(repoPath: string, worktreePath: string): Promise { const g = simpleGit(repoPath) - await g.raw(['worktree', 'remove', worktreePath, '--force']) + + // Look up the branch attached to this worktree before we remove it, so we + // can delete an orphaned `session/*` branch and avoid leaving dangling refs + // that block branch deletion in other tools (e.g. GitHub Desktop). + // Normalize both sides via realpath so symlinked paths (e.g. macOS /var vs + // /private/var) and trailing-slash differences don't cause a miss. + const normalize = async (p: string): Promise => { + try { + return await realpath(p) + } catch { + // The path may not exist (e.g. the worktree directory was already + // deleted). Resolve the parent — usually still present — and re-append + // the basename so symlinked ancestors (e.g. macOS /var → /private/var) + // are still followed. + try { + const parent = await realpath(dirname(p)) + return join(parent, basename(p)) + } catch { + return resolvePath(p) + } + } + } + const targetPath = await normalize(worktreePath) + let attachedBranch: string | null = null + try { + const wtOutput = await g.raw(['worktree', 'list', '--porcelain']) + let currentPath = '' + for (const line of wtOutput.split('\n')) { + if (line.startsWith('worktree ')) { + currentPath = await normalize(line.slice('worktree '.length)) + } else if (line.startsWith('branch refs/heads/') && currentPath === targetPath) { + attachedBranch = line.slice('branch refs/heads/'.length) + break + } + } + } catch { + // Best-effort lookup + } + + let removeError: unknown = null + try { + await g.raw(['worktree', 'remove', worktreePath, '--force']) + } catch (err) { + removeError = err + } + await pruneWorktrees(repoPath) + + let removed = removeError === null + if (removeError) { + // The remove may have failed simply because the directory was already gone + // and git's admin entry was stale. After prune, re-check the worktree list: + // if our target is no longer registered, treat the removal as successful; + // otherwise surface the original error. + let stillPresent = true + try { + const wtOutput = await g.raw(['worktree', 'list', '--porcelain']) + stillPresent = false + for (const line of wtOutput.split('\n')) { + if (line.startsWith('worktree ')) { + const p = await normalize(line.slice('worktree '.length)) + if (p === targetPath) { + stillPresent = true + break + } + } + } + } catch { + // If we can't list, treat the worktree as still present and rethrow. + } + if (stillPresent) throw removeError + removed = true + } + + if (removed && attachedBranch && attachedBranch.startsWith('session/')) { + try { + await g.raw(['branch', '-D', attachedBranch]) + } catch { + // Branch may not exist or may be checked out elsewhere + } + } } diff --git a/tests/unit/main/worktree.service.test.ts b/tests/unit/main/worktree.service.test.ts new file mode 100644 index 0000000..acb03f8 --- /dev/null +++ b/tests/unit/main/worktree.service.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import simpleGit from 'simple-git' +import { + createWorktree, + listWorktrees, + removeWorktree, +} from '../../../src/main/services/worktree.service' + +let tmpRoot: string +let repoPath: string + +async function initRepo(path: string) { + const g = simpleGit(path) + await g.init() + await g.addConfig('user.email', 'test@example.com') + await g.addConfig('user.name', 'Test') + await g.addConfig('commit.gpgsign', 'false') + // Some hosts default init.defaultBranch elsewhere; force `main` so tests are + // deterministic regardless of the user's git config. + await g.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']) + await writeFile(join(path, 'README.md'), 'hello\n') + await g.add('README.md') + await g.commit('initial') +} + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'cc-wt-test-')) + repoPath = join(tmpRoot, 'repo') + await mkdir(repoPath, { recursive: true }) + await initRepo(repoPath) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('worktree.service', () => { + it('createWorktree creates a session/ branch and worktree dir', async () => { + const info = await createWorktree(repoPath, 'feat/x', 'main') + expect(info.branch).toBe('session/feat/x') + expect(info.path.endsWith('feat/x')).toBe(true) + + const list = await listWorktrees(repoPath) + const found = list.find((w) => w.branch === 'session/feat/x') + expect(found).toBeDefined() + // macOS resolves /var to /private/var, so compare via realpath. + expect(await realpath(found!.path)).toBe(await realpath(info.path)) + }) + + it('removeWorktree removes the worktree and deletes its session/* branch', async () => { + const info = await createWorktree(repoPath, 'feat/y', 'main') + await removeWorktree(repoPath, info.path) + + const list = await listWorktrees(repoPath) + expect(list.find((w) => w.branch === 'session/feat/y')).toBeUndefined() + + const branches = await simpleGit(repoPath).branch() + expect(Object.keys(branches.branches)).not.toContain('session/feat/y') + }) + + it('listWorktrees prunes stale entries from a deleted worktree directory', async () => { + const info = await createWorktree(repoPath, 'feat/stale', 'main') + + // Simulate the user deleting the worktree dir outside of git (or moving + // the parent repo) — git keeps a stale admin entry under .git/worktrees/ + // that keeps the branch "in use" until pruned. + await rm(info.path, { recursive: true, force: true }) + + const list = await listWorktrees(repoPath) + // After prune, the stale entry is gone from `worktree list`. + expect(list.find((w) => w.path === info.path)).toBeUndefined() + + // And the branch can now be deleted by other tools (e.g. GitHub Desktop) + // because the worktree no longer claims it. + await expect( + simpleGit(repoPath).raw(['branch', '-D', 'session/feat/stale']) + ).resolves.toBeDefined() + }) + + it('removeWorktree succeeds even when the worktree directory is already gone', async () => { + const info = await createWorktree(repoPath, 'feat/gone', 'main') + await rm(info.path, { recursive: true, force: true }) + + await expect(removeWorktree(repoPath, info.path)).resolves.toBeUndefined() + + const branches = await simpleGit(repoPath).branch() + expect(Object.keys(branches.branches)).not.toContain('session/feat/gone') + }) + + it('removeWorktree leaves non-session branches alone', async () => { + // Create a regular branch and attach a worktree to it manually. + const g = simpleGit(repoPath) + await g.raw(['branch', 'keepme', 'main']) + const wtPath = join(tmpRoot, 'extwt') + await g.raw(['worktree', 'add', wtPath, 'keepme']) + + await removeWorktree(repoPath, wtPath) + + const branches = await g.branch() + expect(Object.keys(branches.branches)).toContain('keepme') + }) +})