From 99abdba60de0cff7a9801bc46db7fc5f2d8c2ec3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Sep 2026 20:48:59 +1000 Subject: [PATCH] feat(worktree): delete branches after merged cleanup --- plugins/engineering/skills/worktree/SKILL.md | 12 +- .../skills/worktree/scripts/worktree.sh | 32 ++++- tests/unit/scripts/worktree.test.ts | 124 ++++++++++++++++++ 3 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 tests/unit/scripts/worktree.test.ts diff --git a/plugins/engineering/skills/worktree/SKILL.md b/plugins/engineering/skills/worktree/SKILL.md index 0c813a4..9e8a7d9 100644 --- a/plugins/engineering/skills/worktree/SKILL.md +++ b/plugins/engineering/skills/worktree/SKILL.md @@ -36,6 +36,14 @@ Worktrees default to `__worktrees/`. Set `W /scripts/worktree.sh -C remove --force ``` -Normal removal preserves Git's dirty-worktree protection. Use `--force` only when discarding the worktree's uncommitted files is intended. Removal unregisters and deletes the worktree directory; it leaves the branch intact. +Normal removal preserves Git's dirty-worktree protection and leaves the local branch intact. Use `--force` only when discarding the worktree's uncommitted files is intended. -An add is complete when the script prints the new path and `git -C worktree list` contains it. A removal is complete when that path is absent. +After a branch's merge is confirmed, remove both the worktree and its local branch: + +```bash +/scripts/worktree.sh -C remove --merged +``` + +`--merged` records the caller's merge confirmation and force-deletes the branch, including after a squash merge. Combine it with `--force` only when the merged worktree is dirty. + +An add is complete when the script prints the new path and `git -C worktree list` contains it. Normal removal is complete when that path is absent. Merged cleanup is complete when the path is absent and `git -C branch --list ` prints nothing. diff --git a/plugins/engineering/skills/worktree/scripts/worktree.sh b/plugins/engineering/skills/worktree/scripts/worktree.sh index 08679e1..4934b3d 100755 --- a/plugins/engineering/skills/worktree/scripts/worktree.sh +++ b/plugins/engineering/skills/worktree/scripts/worktree.sh @@ -5,11 +5,13 @@ usage() { cat <<'EOF' Usage: worktree.sh [-C ] add [start-point] - worktree.sh [-C ] remove [--force] + worktree.sh [-C ] remove [--force] [--merged] Worktrees are created under __worktrees by default. Override the location with WORKTREE_ROOT and the default remote with WORKTREE_REMOTE. Branch slashes become dashes in directory names. +Pass --merged only after the branch's merge is confirmed. It removes the +worktree and deletes its checked-out local branch. EOF } @@ -83,10 +85,18 @@ case "$command" in remove) shift force=() - if [[ "${1:-}" == "--force" ]]; then - force=(--force) + merged=false + while [[ "${1:-}" == --* ]]; do + case "$1" in + --force) force=(--force) ;; + --merged) merged=true ;; + *) + usage >&2 + exit 2 + ;; + esac shift - fi + done [[ $# -eq 1 ]] || { usage >&2 exit 2 @@ -114,7 +124,21 @@ case "$command" in esac [[ -d "$path" ]] || fail "worktree does not exist: $path" + + branch="" + if [[ "$merged" == true ]]; then + branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null)" || + fail "cannot delete the branch for a detached worktree: $path" + git_common_dir="$(git -C "$repo_root" rev-parse --git-common-dir)" + if [[ "$git_common_dir" != /* ]]; then + git_common_dir="$repo_root/$git_common_dir" + fi + git_common_dir="$(cd "$git_common_dir" && pwd -P)" + fi git -C "$repo_root" worktree remove "${force[@]}" "$path" + if [[ "$merged" == true ]]; then + git --git-dir="$git_common_dir" branch -D "$branch" + fi ;; -h|--help|help) diff --git a/tests/unit/scripts/worktree.test.ts b/tests/unit/scripts/worktree.test.ts new file mode 100644 index 0000000..e84f343 --- /dev/null +++ b/tests/unit/scripts/worktree.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const WORKTREE_SCRIPT = resolve( + import.meta.dir, + '../../../plugins/engineering/skills/worktree/scripts/worktree.sh', +); + +const tempDirs: string[] = []; + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface WorktreeRepository { + root: string; + repo: string; + worktreeRoot: string; +} + +function run( + cwd: string, + command: string[], + extraEnv: Record = {}, +): CommandResult { + const env = { ...process.env, ...extraEnv }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + env.GIT_CONFIG_GLOBAL = '/dev/null'; + env.GIT_CONFIG_NOSYSTEM = '1'; + + const result = Bun.spawnSync(command, { + cwd, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +function git(cwd: string, ...args: string[]): string { + const result = run(cwd, ['git', ...args]); + if (result.exitCode !== 0) { + throw new Error(`git ${args.join(' ')} failed:\n${result.stdout}${result.stderr}`); + } + return result.stdout.trim(); +} + +async function createRepository(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-worktree-test-')); + tempDirs.push(root); + const repo = join(root, 'repo'); + const worktreeRoot = join(root, 'worktrees'); + + git(root, 'init', '--initial-branch=main', repo); + git(repo, 'config', '--local', 'user.name', 'Worktree Test'); + git(repo, 'config', '--local', 'user.email', 'worktree-test@example.com'); + await writeFile(join(repo, 'base.txt'), 'base\n'); + git(repo, 'add', 'base.txt'); + git(repo, 'commit', '-m', 'base'); + + return { root, repo, worktreeRoot }; +} + +function worktree( + fixture: WorktreeRepository, + ...args: string[] +): CommandResult { + return run( + fixture.repo, + [WORKTREE_SCRIPT, '-C', fixture.repo, ...args], + { WORKTREE_ROOT: fixture.worktreeRoot }, + ); +} + +function addedPath(result: CommandResult): string { + expect(result.exitCode).toBe(0); + return result.stdout.trim().split('\n').at(-1) ?? ''; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('worktree remove', () => { + test('preserves the branch during normal removal', async () => { + const fixture = await createRepository(); + const path = addedPath(worktree(fixture, 'add', 'feat/unfinished', 'main')); + + const result = worktree(fixture, 'remove', 'feat/unfinished'); + + expect(result.exitCode).toBe(0); + expect(existsSync(path)).toBe(false); + expect(git(fixture.repo, 'branch', '--list', 'feat/unfinished')).toBe('feat/unfinished'); + }); + + test('deletes the branch after a confirmed squash merge', async () => { + const fixture = await createRepository(); + const path = addedPath(worktree(fixture, 'add', 'feat/merged', 'main')); + await writeFile(join(path, 'feature.txt'), 'feature\n'); + git(path, 'add', 'feature.txt'); + git(path, 'commit', '-m', 'feature'); + git(fixture.repo, 'merge', '--squash', 'feat/merged'); + git(fixture.repo, 'commit', '-m', 'squash feature'); + + expect(run(fixture.repo, ['git', 'merge-base', '--is-ancestor', 'feat/merged', 'main']).exitCode).not.toBe(0); + + const result = worktree(fixture, 'remove', '--merged', 'feat/merged'); + + expect(result.exitCode).toBe(0); + expect(existsSync(path)).toBe(false); + expect(git(fixture.repo, 'branch', '--list', 'feat/merged')).toBe(''); + }); +});