feat: add removeWorktreeForJob helper

Removes worktree dir via `git worktree remove --force` and deletes
the local branch by default; keepBranch=true preserves the branch.
Returns { removed: false } when the worktree path doesn't exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 11:46:31 +02:00
parent e7bb3c82ba
commit b20e297851
2 changed files with 131 additions and 1 deletions

View file

@ -4,7 +4,7 @@ import { promisify } from 'node:util'
import * as os from 'node:os'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { createWorktreeForJob } from '../../src/git/worktree.js'
import { createWorktreeForJob, removeWorktreeForJob } from '../../src/git/worktree.js'
const exec = promisify(execFile)
@ -112,3 +112,93 @@ describe('createWorktreeForJob', () => {
).rejects.toThrow('Worktree path already exists')
})
})
describe('removeWorktreeForJob', () => {
const tmpDirs: string[] = []
const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
afterEach(async () => {
if (originalWorktreeDir === undefined) {
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
} else {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir
}
for (const dir of tmpDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true })
}
})
async function makeWorktreeParent(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-'))
tmpDirs.push(dir)
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir
return dir
}
it('removes worktree directory and deletes branch by default', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const { worktreePath, branchName } = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-01',
branchName: 'feat/job-rm-01',
baseRef: 'origin/main',
})
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-01' })
expect(result.removed).toBe(true)
await expect(fs.access(worktreePath)).rejects.toThrow()
await expect(fs.access(path.join(wtParent, 'job-rm-01'))).rejects.toThrow()
// Branch should be deleted
await expect(
exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
cwd: repoDir,
}),
).rejects.toThrow()
})
it('removes worktree directory but keeps branch when keepBranch=true', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const { worktreePath, branchName } = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-02',
branchName: 'feat/job-rm-02',
baseRef: 'origin/main',
})
const result = await removeWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-02',
keepBranch: true,
})
expect(result.removed).toBe(true)
await expect(fs.access(worktreePath)).rejects.toThrow()
await expect(fs.access(path.join(wtParent, 'job-rm-02'))).rejects.toThrow()
// Branch should still exist
const { stdout } = await exec(
'git',
['show-ref', '--verify', `refs/heads/${branchName}`],
{ cwd: repoDir },
)
expect(stdout).toContain(branchName)
})
it('returns { removed: false } when worktree does not exist', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-nonexistent' })
expect(result.removed).toBe(false)
})
})

View file

@ -55,3 +55,43 @@ export async function createWorktreeForJob(opts: {
return { worktreePath, branchName }
}
export async function removeWorktreeForJob(opts: {
repoRoot: string
jobId: string
keepBranch?: boolean
}): Promise<{ removed: boolean }> {
const { repoRoot, jobId, keepBranch = false } = opts
const parent =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
const worktreePath = path.join(parent, jobId)
try {
await fs.access(worktreePath)
} catch {
return { removed: false }
}
let branchName: string | undefined
if (!keepBranch) {
try {
const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: worktreePath,
})
branchName = stdout.trim()
} catch {
// worktree HEAD unreadable — skip branch deletion
}
}
await exec('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot })
if (!keepBranch && branchName && (await branchExists(repoRoot, branchName))) {
await exec('git', ['branch', '-D', branchName], { cwd: repoRoot })
}
return { removed: true }
}