Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions src/main/services/worktree.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
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 {
const repoName = basename(repoPath)
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<void> {
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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -146,6 +161,7 @@ export async function createWorktreeFromBranch(

export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
const g = simpleGit(repoPath)
await pruneWorktrees(repoPath)
const result = await g.raw(['worktree', 'list', '--porcelain'])

const worktrees: WorktreeInfo[] = []
Expand All @@ -169,5 +185,84 @@ export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {

export async function removeWorktree(repoPath: string, worktreePath: string): Promise<void> {
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<string> => {
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
}
}
}
105 changes: 105 additions & 0 deletions tests/unit/main/worktree.service.test.ts
Original file line number Diff line number Diff line change
@@ -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/<name> 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')
})
})
Loading