fix(#2853): sync .sisyphus state from worktree to main repo before removal
When .sisyphus/ is gitignored, task state written during worktree execution is lost when the worktree is removed. Fix: - add worktree-sync.ts: syncSisyphusStateFromWorktree() copies .sisyphus/ contents from worktree to main repo directory - update start-work.ts template: documents the sync step as CRITICAL when worktree_path is set in boulder.json - update work-with-pr/SKILL.md: adds explicit sync step before worktree removal - export from boulder-state index - test: 5 scenarios covering no-.sisyphus, nested dirs, overwrite stale state
This commit is contained in:
@@ -282,6 +282,18 @@ Once all three gates pass:
|
|||||||
gh pr merge "$PR_NUMBER" --squash --delete-branch
|
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
|
### Clean up the worktree
|
||||||
|
|
||||||
The worktree served its purpose — remove it to avoid disk bloat:
|
The worktree served its purpose — remove it to avoid disk bloat:
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./types"
|
|||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
export * from "./storage"
|
export * from "./storage"
|
||||||
export * from "./top-level-task"
|
export * from "./top-level-task"
|
||||||
|
export * from "./worktree-sync"
|
||||||
|
|||||||
88
src/features/boulder-state/worktree-sync.test.ts
Normal file
88
src/features/boulder-state/worktree-sync.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
34
src/features/boulder-state/worktree-sync.ts
Normal file
34
src/features/boulder-state/worktree-sync.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
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
|
1. Commit all remaining changes in the worktree
|
||||||
2. Switch to the main working directory (the original repo, NOT the worktree)
|
2. **Sync .sisyphus state back**: Copy \`.sisyphus/\` from the worktree to the main repo before removal.
|
||||||
3. Merge the worktree branch into the current branch: \`git merge <worktree-branch>\`
|
This is CRITICAL when \`.sisyphus/\` is gitignored — state written during worktree execution would otherwise be lost.
|
||||||
4. If merge succeeds, clean up: \`git worktree remove <worktree-path>\`
|
\`\`\`bash
|
||||||
5. Remove the boulder.json state
|
cp -r <worktree-path>/.sisyphus/* <main-repo>/.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 <worktree-branch>\`
|
||||||
|
5. If merge succeeds, clean up: \`git worktree remove <worktree-path>\`
|
||||||
|
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).`
|
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).`
|
||||||
|
|||||||
Reference in New Issue
Block a user