From 242a2c2f2baf044c375c53b7ced71bec9c358b88 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 17 May 2026 22:12:56 -0700 Subject: [PATCH] fix(cli): stop worktree init --force from wiping repo worktrees/ (#6240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each working tree gets an isolated Paperclip instance; the CLI's `paperclip worktree init` is what bootstraps that instance and writes `/.paperclip/config.json` + `.env` > - When `--force` was passed, the init path tried to "start clean" by recursively removing the entire `/.paperclip/` directory before rewriting those two files > - But `/.paperclip/` also holds `worktrees/`, which contains every repo-managed worktree checkout (70+ on this machine). The recursive rm silently nuked all of them. > - This PR narrows the `--force` reset so it only deletes the two files it's about to rewrite (`config.json`, `.env`), instead of wiping the whole `repoConfigDir` > - It also adds a regression test that drops a sentinel file into `/worktrees/` and asserts it survives a `--force` init > - The benefit is that `worktree init --force` becomes safe to run from inside the main repo without destroying every sibling worktree checkout ## What Changed - `cli/src/commands/worktree.ts`: in the `--force` branch of `runWorktreeInit`, replace `rmSync(paths.repoConfigDir, { recursive: true, force: true })` with targeted removals of `paths.configPath` and `paths.envPath`. `paths.instanceRoot` removal is unchanged — that path is per-instance and safe to wipe. - `cli/src/__tests__/worktree.test.ts`: new regression test that seeds a fake `worktrees//` checkout inside the repo's `.paperclip/` and verifies `runWorktreeInit({ force: true, ... })` does not delete it. ## Verification - `pnpm --filter @paperclip/cli test -- worktree` — the new regression test fails on the old code and passes on the fix - Manual: from a repo checkout, `npx paperclipai worktree init --force …` no longer removes `/.paperclip/worktrees/`; only `config.json` and `.env` are rewritten ## Risks - Low. The change strictly narrows what `--force` removes. Any caller that depended on `--force` also wiping unrelated files under `/.paperclip/` (there shouldn't be any — it's documented as just config + env) would see those files persist. `instanceRoot` cleanup is unchanged. ## Model Used - Claude (Anthropic), model `claude-opus-4-7`, ~200K context, extended-thinking + tool-use enabled, run via the Paperclip `claude_local` adapter. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — CLI-only fix) - [x] I have updated relevant documentation to reflect my changes (no doc surface affected) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- cli/src/__tests__/worktree.test.ts | 39 ++++++++++++++++++++++++++++++ cli/src/commands/worktree.ts | 7 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index afb83f73..49c445b1 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -512,6 +512,45 @@ describe("worktree helpers", () => { } }); + it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + const repoConfigDir = path.join(repoRoot, ".paperclip"); + fs.mkdirSync(repoConfigDir, { recursive: true }); + fs.writeFileSync(path.join(repoConfigDir, "config.json"), "stale", "utf8"); + fs.writeFileSync(path.join(repoConfigDir, ".env"), "STALE=1", "utf8"); + + // Simulate the repo-managed worktrees subfolder that holds every + // worktree checkout (the directory PAPA-358 reported as nuked). + const worktreesDir = path.join(repoConfigDir, "worktrees"); + const checkoutDir = path.join(worktreesDir, "PAP-100-feature"); + fs.mkdirSync(checkoutDir, { recursive: true }); + const sentinelPath = path.join(checkoutDir, "sentinel.txt"); + fs.writeFileSync(sentinelPath, "do-not-delete", "utf8"); + + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + force: true, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(sentinelPath)).toBe(true); + expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do-not-delete"); + expect(fs.existsSync(path.join(repoConfigDir, "config.json"))).toBe(true); + expect(fs.readFileSync(path.join(repoConfigDir, "config.json"), "utf8")).not.toBe("stale"); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + itEmbeddedPostgres( "seeds authenticated users into minimally cloned worktree instances", async () => { diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index faa8e490..fea6f2c3 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1385,7 +1385,12 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { } if (opts.force) { - rmSync(paths.repoConfigDir, { recursive: true, force: true }); + // Only remove the specific files we're about to rewrite, not the whole + // repoConfigDir — that directory can contain sibling state such as + // /.paperclip/worktrees/ holding every repo-managed worktree + // checkout, and a recursive rmSync here would nuke them all. + rmSync(paths.configPath, { force: true }); + rmSync(paths.envPath, { force: true }); rmSync(paths.instanceRoot, { recursive: true, force: true }); }