Merge pull request #782 from paperclipai/feat/worktree-cleanup-and-env
feat(worktree): cleanup command, env var defaults, auto-prefix
This commit is contained in:
@@ -127,6 +127,8 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
|
||||
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
||||
}
|
||||
|
||||
const WORKTREE_NAME_PREFIX = "paperclip-";
|
||||
|
||||
function resolveWorktreeMakeName(name: string): string {
|
||||
const value = nonEmpty(name);
|
||||
if (!value) {
|
||||
@@ -137,7 +139,15 @@ function resolveWorktreeMakeName(name: string): string {
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`;
|
||||
}
|
||||
|
||||
function resolveWorktreeHome(explicit?: string): string {
|
||||
return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME;
|
||||
}
|
||||
|
||||
function resolveWorktreeStartPoint(explicit?: string): string | undefined {
|
||||
return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||
@@ -623,7 +633,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
|
||||
homeDir: resolveWorktreeHome(opts.home),
|
||||
instanceId,
|
||||
});
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
@@ -732,6 +742,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||
|
||||
const name = resolveWorktreeMakeName(nameArg);
|
||||
const startPoint = resolveWorktreeStartPoint(opts.startPoint);
|
||||
const sourceCwd = process.cwd();
|
||||
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||
if (existsSync(targetPath)) {
|
||||
@@ -739,8 +750,8 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
if (opts.startPoint) {
|
||||
const [remote] = opts.startPoint.split("/", 1);
|
||||
if (startPoint) {
|
||||
const [remote] = startPoint.split("/", 1);
|
||||
try {
|
||||
execFileSync("git", ["fetch", remote], {
|
||||
cwd: sourceCwd,
|
||||
@@ -756,8 +767,8 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
const worktreeArgs = resolveGitWorktreeAddArgs({
|
||||
branchName: name,
|
||||
targetPath,
|
||||
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
|
||||
startPoint: opts.startPoint,
|
||||
branchExists: !startPoint && localBranchExists(sourceCwd, name),
|
||||
startPoint,
|
||||
});
|
||||
|
||||
const spinner = p.spinner();
|
||||
@@ -800,6 +811,232 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeCleanupOptions = {
|
||||
instance?: string;
|
||||
home?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type GitWorktreeListEntry = {
|
||||
worktree: string;
|
||||
branch: string | null;
|
||||
bare: boolean;
|
||||
detached: boolean;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const entries: GitWorktreeListEntry[] = [];
|
||||
let current: Partial<GitWorktreeListEntry> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
current = { worktree: line.slice("worktree ".length) };
|
||||
} else if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length);
|
||||
} else if (line === "bare") {
|
||||
current.bare = true;
|
||||
} else if (line === "detached") {
|
||||
current.detached = true;
|
||||
} else if (line === "" && current.worktree) {
|
||||
entries.push({
|
||||
worktree: current.worktree,
|
||||
branch: current.branch ?? null,
|
||||
bare: current.bare ?? false,
|
||||
detached: current.detached ?? false,
|
||||
});
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
if (current.worktree) {
|
||||
entries.push({
|
||||
worktree: current.worktree,
|
||||
branch: current.branch ?? null,
|
||||
bare: current.bare ?? false,
|
||||
detached: current.detached ?? false,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function branchHasUniqueCommits(cwd: string, branchName: string): boolean {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"],
|
||||
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
).trim();
|
||||
return output.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["branch", "-r", "--list", `*/${branchName}`],
|
||||
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
).trim();
|
||||
return output.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function worktreePathHasUncommittedChanges(worktreePath: string): boolean {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["status", "--porcelain"],
|
||||
{ cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
).trim();
|
||||
return output.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
|
||||
|
||||
const name = resolveWorktreeMakeName(nameArg);
|
||||
const sourceCwd = process.cwd();
|
||||
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home)));
|
||||
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
|
||||
|
||||
// ── 1. Assess current state ──────────────────────────────────────────
|
||||
|
||||
const hasBranch = localBranchExists(sourceCwd, name);
|
||||
const hasTargetDir = existsSync(targetPath);
|
||||
const hasInstanceData = existsSync(instanceRoot);
|
||||
|
||||
const worktrees = parseGitWorktreeList(sourceCwd);
|
||||
const linkedWorktree = worktrees.find(
|
||||
(wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath),
|
||||
);
|
||||
|
||||
if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) {
|
||||
p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found.");
|
||||
p.outro(pc.green("Already clean."));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Safety checks ────────────────────────────────────────────────
|
||||
|
||||
const problems: string[] = [];
|
||||
|
||||
if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) {
|
||||
const onRemote = branchExistsOnAnyRemote(sourceCwd, name);
|
||||
if (onRemote) {
|
||||
p.log.info(
|
||||
`Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`,
|
||||
);
|
||||
} else {
|
||||
problems.push(
|
||||
`Branch "${name}" has commits not found on any other branch or remote. ` +
|
||||
`Deleting it will lose work. Push it first, or use --force.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) {
|
||||
problems.push(
|
||||
`Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0 && !opts.force) {
|
||||
for (const problem of problems) {
|
||||
p.log.error(problem);
|
||||
}
|
||||
throw new Error("Safety checks failed. Resolve the issues above or re-run with --force.");
|
||||
}
|
||||
if (problems.length > 0 && opts.force) {
|
||||
for (const problem of problems) {
|
||||
p.log.warning(`Overridden by --force: ${problem}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Clean up (idempotent steps) ──────────────────────────────────
|
||||
|
||||
// 3a. Remove the git worktree registration
|
||||
if (linkedWorktree) {
|
||||
const worktreeDirExists = existsSync(linkedWorktree.worktree);
|
||||
const spinner = p.spinner();
|
||||
if (worktreeDirExists) {
|
||||
spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`);
|
||||
try {
|
||||
const removeArgs = ["worktree", "remove", linkedWorktree.worktree];
|
||||
if (opts.force) removeArgs.push("--force");
|
||||
execFileSync("git", removeArgs, {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
} else {
|
||||
spinner.start("Pruning stale worktree entry...");
|
||||
execFileSync("git", ["worktree", "prune"], {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop("Pruned stale worktree entry.");
|
||||
}
|
||||
} else {
|
||||
// Even without a linked worktree, prune to clean up any orphaned entries
|
||||
execFileSync("git", ["worktree", "prune"], {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
// 3b. Remove the worktree directory if it still exists (e.g. partial creation)
|
||||
if (existsSync(targetPath)) {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Removing worktree directory ${targetPath}...`);
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
spinner.stop(`Removed worktree directory ${targetPath}.`);
|
||||
}
|
||||
|
||||
// 3c. Delete the local branch (now safe — worktree is gone)
|
||||
if (localBranchExists(sourceCwd, name)) {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Deleting local branch "${name}"...`);
|
||||
try {
|
||||
const deleteFlag = opts.force ? "-D" : "-d";
|
||||
execFileSync("git", ["branch", deleteFlag, name], {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Deleted local branch "${name}".`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.yellow(`Could not delete branch "${name}".`));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// 3d. Remove instance data
|
||||
if (existsSync(instanceRoot)) {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Removing instance data at ${instanceRoot}...`);
|
||||
rmSync(instanceRoot, { recursive: true, force: true });
|
||||
spinner.stop(`Removed instance data at ${instanceRoot}.`);
|
||||
}
|
||||
|
||||
p.outro(pc.green("Cleanup complete."));
|
||||
}
|
||||
|
||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = resolvePaperclipEnvFile(configPath);
|
||||
@@ -826,10 +1063,10 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
program
|
||||
.command("worktree:make")
|
||||
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
||||
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
||||
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
|
||||
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)")
|
||||
.option("--start-point <ref>", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
@@ -845,7 +1082,7 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
@@ -862,4 +1099,13 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
.argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed")
|
||||
.option("--instance <id>", "Explicit instance id (if different from the worktree name)")
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false)
|
||||
.action(worktreeCleanupCommand);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user