Skip to content
Open
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
12 changes: 10 additions & 2 deletions plugins/engineering/skills/worktree/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ Worktrees default to `<repo>__worktrees/<branch-with-slashes-as-dashes>`. Set `W
<skill-directory>/scripts/worktree.sh -C <repo> remove --force <branch-or-path>
```

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 <repo> 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
<skill-directory>/scripts/worktree.sh -C <repo> remove --merged <branch-or-path>
```

`--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 <repo> 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 <repo> branch --list <branch>` prints nothing.
32 changes: 28 additions & 4 deletions plugins/engineering/skills/worktree/scripts/worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ usage() {
cat <<'EOF'
Usage:
worktree.sh [-C <repo>] add <branch> [start-point]
worktree.sh [-C <repo>] remove [--force] <branch-or-path>
worktree.sh [-C <repo>] remove [--force] [--merged] <branch-or-path>

Worktrees are created under <repo>__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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
124 changes: 124 additions & 0 deletions tests/unit/scripts/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
): 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<WorktreeRepository> {
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('');
});
});
Loading