diff --git a/.opencode/skills/work-with-pr/SKILL.md b/.opencode/skills/work-with-pr/SKILL.md index 67282c54f..4858b8de6 100644 --- a/.opencode/skills/work-with-pr/SKILL.md +++ b/.opencode/skills/work-with-pr/SKILL.md @@ -282,6 +282,18 @@ Once all three gates pass: gh pr merge "$PR_NUMBER" --squash --delete-branch ``` +### Sync .sisyphus state back to main repo + +Before removing the worktree, copy `.sisyphus/` state back. When `.sisyphus/` is gitignored, files written there during worktree execution are not committed or merged — they would be lost on worktree removal. + +```bash +# Sync .sisyphus state from worktree to main repo (preserves task state, plans, notepads) +if [ -d "$WORKTREE_PATH/.sisyphus" ]; then + mkdir -p "$ORIGINAL_DIR/.sisyphus" + cp -r "$WORKTREE_PATH/.sisyphus/"* "$ORIGINAL_DIR/.sisyphus/" 2>/dev/null || true +fi +``` + ### Clean up the worktree The worktree served its purpose — remove it to avoid disk bloat: diff --git a/src/features/boulder-state/index.ts b/src/features/boulder-state/index.ts index 17618996b..a174e1a57 100644 --- a/src/features/boulder-state/index.ts +++ b/src/features/boulder-state/index.ts @@ -2,3 +2,4 @@ export * from "./types" export * from "./constants" export * from "./storage" export * from "./top-level-task" +export * from "./worktree-sync" diff --git a/src/features/boulder-state/worktree-sync.test.ts b/src/features/boulder-state/worktree-sync.test.ts new file mode 100644 index 000000000..60f3e240d --- /dev/null +++ b/src/features/boulder-state/worktree-sync.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { syncSisyphusStateFromWorktree } from "./worktree-sync" + +describe("syncSisyphusStateFromWorktree", () => { + const BASE = join(tmpdir(), "worktree-sync-test-" + Date.now()) + const WORKTREE = join(BASE, "worktree") + const MAIN_REPO = join(BASE, "main") + + beforeEach(() => { + mkdirSync(WORKTREE, { recursive: true }) + mkdirSync(MAIN_REPO, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(BASE)) { + rmSync(BASE, { recursive: true, force: true }) + } + }) + + test("#given no .sisyphus in worktree #when syncing #then returns true without error", () => { + const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) + + expect(result).toBe(true) + expect(existsSync(join(MAIN_REPO, ".sisyphus"))).toBe(false) + }) + + test("#given .sisyphus with boulder.json in worktree #when syncing #then copies to main repo", () => { + const worktreeSisyphus = join(WORKTREE, ".sisyphus") + mkdirSync(worktreeSisyphus, { recursive: true }) + writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"active_plan":"/plan.md","plan_name":"test"}') + + const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) + + expect(result).toBe(true) + const copied = readFileSync(join(MAIN_REPO, ".sisyphus", "boulder.json"), "utf-8") + expect(JSON.parse(copied).plan_name).toBe("test") + }) + + test("#given nested .sisyphus dirs in worktree #when syncing #then copies full tree recursively", () => { + const worktreePlans = join(WORKTREE, ".sisyphus", "plans") + const worktreeNotepads = join(WORKTREE, ".sisyphus", "notepads", "my-plan") + mkdirSync(worktreePlans, { recursive: true }) + mkdirSync(worktreeNotepads, { recursive: true }) + writeFileSync(join(worktreePlans, "my-plan.md"), "- [x] Task 1\n- [ ] Task 2") + writeFileSync(join(worktreeNotepads, "learnings.md"), "learned something") + + const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) + + expect(result).toBe(true) + expect(readFileSync(join(MAIN_REPO, ".sisyphus", "plans", "my-plan.md"), "utf-8")).toContain("Task 1") + expect(readFileSync(join(MAIN_REPO, ".sisyphus", "notepads", "my-plan", "learnings.md"), "utf-8")).toBe("learned something") + }) + + test("#given existing .sisyphus in main repo #when syncing #then worktree state overwrites stale state", () => { + const mainSisyphus = join(MAIN_REPO, ".sisyphus") + mkdirSync(mainSisyphus, { recursive: true }) + writeFileSync(join(mainSisyphus, "boulder.json"), '{"plan_name":"old"}') + + const worktreeSisyphus = join(WORKTREE, ".sisyphus") + mkdirSync(worktreeSisyphus, { recursive: true }) + writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"plan_name":"updated"}') + + const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) + + expect(result).toBe(true) + const content = readFileSync(join(mainSisyphus, "boulder.json"), "utf-8") + expect(JSON.parse(content).plan_name).toBe("updated") + }) + + test("#given pre-existing files in main .sisyphus #when syncing #then preserves files not in worktree", () => { + const mainSisyphus = join(MAIN_REPO, ".sisyphus", "rules") + mkdirSync(mainSisyphus, { recursive: true }) + writeFileSync(join(mainSisyphus, "my-rule.md"), "existing rule") + + const worktreeSisyphus = join(WORKTREE, ".sisyphus") + mkdirSync(worktreeSisyphus, { recursive: true }) + writeFileSync(join(worktreeSisyphus, "boulder.json"), '{"plan_name":"new"}') + + const result = syncSisyphusStateFromWorktree(WORKTREE, MAIN_REPO) + + expect(result).toBe(true) + expect(readFileSync(join(MAIN_REPO, ".sisyphus", "rules", "my-rule.md"), "utf-8")).toBe("existing rule") + expect(existsSync(join(MAIN_REPO, ".sisyphus", "boulder.json"))).toBe(true) + }) +}) diff --git a/src/features/boulder-state/worktree-sync.ts b/src/features/boulder-state/worktree-sync.ts new file mode 100644 index 000000000..98a7bdb9f --- /dev/null +++ b/src/features/boulder-state/worktree-sync.ts @@ -0,0 +1,34 @@ +import { existsSync, cpSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { BOULDER_DIR } from "./constants" +import { log } from "../../shared/logger" + +export function syncSisyphusStateFromWorktree(worktreePath: string, mainRepoPath: string): boolean { + const srcDir = join(worktreePath, BOULDER_DIR) + const destDir = join(mainRepoPath, BOULDER_DIR) + + if (!existsSync(srcDir)) { + log("[worktree-sync] No .sisyphus directory in worktree, nothing to sync", { worktreePath }) + return true + } + + try { + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }) + } + + cpSync(srcDir, destDir, { recursive: true, force: true }) + log("[worktree-sync] Synced .sisyphus state from worktree to main repo", { + worktreePath, + mainRepoPath, + }) + return true + } catch (err) { + log("[worktree-sync] Failed to sync .sisyphus state", { + worktreePath, + mainRepoPath, + error: String(err), + }) + return false + } +} diff --git a/src/features/builtin-commands/templates/start-work.ts b/src/features/builtin-commands/templates/start-work.ts index 4de4d9a45..d8cad2a96 100644 --- a/src/features/builtin-commands/templates/start-work.ts +++ b/src/features/builtin-commands/templates/start-work.ts @@ -115,9 +115,14 @@ Register these as task/todo items so progress is tracked and visible throughout When working in a worktree (\`worktree_path\` is set in boulder.json) and ALL plan tasks are complete: 1. Commit all remaining changes in the worktree -2. Switch to the main working directory (the original repo, NOT the worktree) -3. Merge the worktree branch into the current branch: \`git merge \` -4. If merge succeeds, clean up: \`git worktree remove \` -5. Remove the boulder.json state +2. **Sync .sisyphus state back**: Copy \`.sisyphus/\` from the worktree to the main repo before removal. + This is CRITICAL when \`.sisyphus/\` is gitignored — state written during worktree execution would otherwise be lost. + \`\`\`bash + cp -r /.sisyphus/* /.sisyphus/ 2>/dev/null || true + \`\`\` +3. Switch to the main working directory (the original repo, NOT the worktree) +4. Merge the worktree branch into the current branch: \`git merge \` +5. If merge succeeds, clean up: \`git worktree remove \` +6. Remove the boulder.json state This is the DEFAULT behavior when \`--worktree\` was used. Skip merge only if the user explicitly instructs otherwise (e.g., asks to create a PR instead).`