From 34cd1056eabe5d2bbf6c2cf53c540e33c804ee86 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 23:34:28 -0600 Subject: [PATCH] feat(M003/S05): Self-healing git repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks: - chore(M003/S05): auto-commit after complete-slice - docs: tighten GSD system prompt tool-routing — prescriptive rules + anti-patterns - chore(M003/S05/T02): auto-commit after execute-task - chore(M003/S05/T01): auto-commit after execute-task - chore(M003/S05): auto-commit after plan-slice - docs(S05): add slice plan Branch: gsd/M003/S05 --- .gsd/DECISIONS.md | 1 + .gsd/milestones/M003/M003-ROADMAP.md | 2 +- .gsd/milestones/M003/slices/S05/S05-PLAN.md | 65 +++++ .../M003/slices/S05/S05-RESEARCH.md | 70 ++++++ .../milestones/M003/slices/S05/S05-SUMMARY.md | 112 +++++++++ .gsd/milestones/M003/slices/S05/S05-UAT.md | 96 +++++++ .../M003/slices/S05/tasks/T01-PLAN.md | 51 ++++ .../M003/slices/S05/tasks/T01-SUMMARY.md | 58 +++++ .../M003/slices/S05/tasks/T02-PLAN.md | 51 ++++ .../M003/slices/S05/tasks/T02-SUMMARY.md | 55 ++++ .../pi-coding-agent/src/core/system-prompt.ts | 9 + src/resources/extensions/gsd/auto-worktree.ts | 93 +++---- src/resources/extensions/gsd/auto.ts | 3 +- src/resources/extensions/gsd/git-self-heal.ts | 198 +++++++++++++++ .../extensions/gsd/prompts/system.md | 61 ++--- .../gsd/tests/git-self-heal.test.ts | 234 ++++++++++++++++++ 16 files changed, 1069 insertions(+), 90 deletions(-) create mode 100644 .gsd/milestones/M003/slices/S05/S05-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S05/S05-RESEARCH.md create mode 100644 .gsd/milestones/M003/slices/S05/S05-SUMMARY.md create mode 100644 .gsd/milestones/M003/slices/S05/S05-UAT.md create mode 100644 .gsd/milestones/M003/slices/S05/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S05/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M003/slices/S05/tasks/T02-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S05/tasks/T02-SUMMARY.md create mode 100644 src/resources/extensions/gsd/git-self-heal.ts create mode 100644 src/resources/extensions/gsd/tests/git-self-heal.test.ts diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index c1a9aaaaf..09bdc67d8 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -49,3 +49,4 @@ | D041 | M003/S03 | pattern | JSON.stringify for git commit message escaping | Use JSON.stringify to wrap commit message in git commit -m | Handles special characters (quotes, newlines) safely without shell escaping bugs. | No | | D042 | M003/S04 | pattern | shouldUseWorktreeIsolation override parameter | Accept optional overridePrefs for testability | loadEffectiveGSDPreferences computes PROJECT_PREFERENCES_PATH at module load time from process.cwd(). chdir-based test fixtures cannot influence it. Override parameter enables reliable testing. | Yes — if preference loading becomes dynamic | | D043 | M003/S04 | pattern | validatePreferences exported | Export from preferences.ts for direct test access | Was module-private. Tests need to call it directly without full file-loading pipeline. No downstream consumers affected. | No | +| D044 | M003/S05 | pattern | Self-heal strategy for merge failures | Detect real conflicts immediately (skip retry), retry only transient failures once | Real conflicts will fail identically on retry — wasting time. Transient failures (stale index, leftover merge state) recover after abort+reset. Fast escalation for conflicts, automatic recovery for everything else. | Yes — if retry proves useful for some conflict types | diff --git a/.gsd/milestones/M003/M003-ROADMAP.md b/.gsd/milestones/M003/M003-ROADMAP.md index 851f4cc5f..e3d12f3d8 100644 --- a/.gsd/milestones/M003/M003-ROADMAP.md +++ b/.gsd/milestones/M003/M003-ROADMAP.md @@ -65,7 +65,7 @@ This milestone is complete only when all are true: - [x] **S04: Preferences + backwards compatibility** `risk:medium` `depends:[S01]` > After this: `git.isolation: "worktree"` (default for new projects) / `"branch"` (existing projects) and `git.merge_to_main: "milestone"` / `"slice"` preferences are validated and respected. An existing project with `gsd/*` branches defaults to branch mode and works identically to today. Verified by running tests in both modes. -- [ ] **S05: Self-healing git repair** `risk:medium` `depends:[S01,S02,S03]` +- [x] **S05: Self-healing git repair** `risk:medium` `depends:[S01,S02,S03]` > After this: when a merge fails or checkout breaks during auto-mode, the system aborts the failed operation, resets working tree state, and retries. Only truly unresolvable conflicts (real code conflicts between human-edited files) pause auto-mode. Users see non-technical messages, not raw git errors. Verified by deliberately introducing failures and confirming auto-recovery. - [ ] **S06: Doctor + cleanup + code simplification** `risk:low` `depends:[S01,S02,S03,S05]` diff --git a/.gsd/milestones/M003/slices/S05/S05-PLAN.md b/.gsd/milestones/M003/slices/S05/S05-PLAN.md new file mode 100644 index 000000000..d9a11c041 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/S05-PLAN.md @@ -0,0 +1,65 @@ +# S05: Self-healing git repair + +**Goal:** When git operations fail during auto-mode, the system automatically attempts repair (abort, reset, retry) before escalating. Only truly unresolvable code conflicts trigger fix-merge or pause. Users see non-technical messages, not raw git errors. + +**Demo:** Deliberately introduce a merge failure (corrupt index, stale MERGE_HEAD) during auto-mode and observe automatic recovery without user intervention. Real code conflicts still escalate to fix-merge. + +## Must-Haves + +- `abortAndReset(cwd)` detects and clears leftover MERGE_HEAD/SQUASH_MSG/rebase state +- `withMergeHeal(cwd, mergeFn)` wraps merge operations: on failure, detect real conflicts (escalate immediately) vs transient failures (abort+reset+retry once) +- `recoverCheckout(cwd, targetBranch)` handles dirty index by resetting before checkout +- `formatGitError(error)` translates git errors to non-technical user-facing messages +- Self-heal wired into `mergeSliceToMilestone` and `mergeMilestoneToMain` in auto-worktree.ts +- Self-heal wired into auto.ts non-conflict error handling path +- Never runs `git clean` without excluding `.gsd/` +- Real code conflicts (UU files detected) skip retry and escalate immediately + +## Proof Level + +- This slice proves: integration +- Real runtime required: yes (real git repos with deliberate failures) +- Human/UAT required: no + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — all assertions pass +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` — existing 21 assertions still pass +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` — existing 23 assertions still pass +- `npx tsc --noEmit` — zero type errors + +## Observability / Diagnostics + +- Runtime signals: self-heal functions return structured results (action taken, retry count, success/failure) +- Inspection surfaces: `abortAndReset` reports what it cleaned (MERGE_HEAD, SQUASH_MSG, rebase) +- Failure visibility: `formatGitError` output includes suggested action (`/gsd doctor`) +- Redaction constraints: none + +## Integration Closure + +- Upstream surfaces consumed: `mergeSliceToMilestone`, `mergeMilestoneToMain` (auto-worktree.ts), merge error handling block (auto.ts ~L1670-1695) +- New wiring introduced: self-heal wraps existing merge calls; formatGitError replaces raw error messages +- What remains: S06 (doctor), S07 (full test suite) + +## Tasks + +- [x] **T01: Create git-self-heal.ts module with repair functions and tests** `est:30m` + - Why: The core self-heal utilities must exist and be independently tested before wiring into existing code. + - Files: `src/resources/extensions/gsd/git-self-heal.ts`, `src/resources/extensions/gsd/tests/git-self-heal.test.ts` + - Do: Create `git-self-heal.ts` with four exports: `abortAndReset(cwd)` (detects MERGE_HEAD/SQUASH_MSG/rebase-apply, aborts appropriately, resets to HEAD), `withMergeHeal(cwd, mergeFn)` (calls mergeFn, on failure checks `git diff --diff-filter=U` — if conflict files exist, throws MergeConflictError immediately without retry; otherwise aborts+resets+retries once), `recoverCheckout(cwd, targetBranch)` (resets dirty index then checkouts, stash not needed since worktree changes are expendable), `formatGitError(error)` (pattern-matches common git errors to user-friendly messages with `/gsd doctor` suggestion). All functions synchronous (execSync). Never use `git clean` — only `git reset --hard HEAD` and `git checkout -- .`. Test with real temp git repos: create merge conflicts, corrupt state, verify recovery. + - Verify: `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — all pass + - Done when: All four functions exported, tested with deliberate git failures, `npx tsc --noEmit` clean + +- [x] **T02: Wire self-heal into auto-worktree.ts and auto.ts** `est:25m` + - Why: The utilities must be integrated into the actual merge/checkout paths to provide self-healing in auto-mode. + - Files: `src/resources/extensions/gsd/auto-worktree.ts`, `src/resources/extensions/gsd/auto.ts` + - Do: In `mergeSliceToMilestone`: wrap the checkout + merge block with `withMergeHeal` (or use `recoverCheckout` for the checkout call and `withMergeHeal` for the merge). In `mergeMilestoneToMain`: same pattern — `recoverCheckout` for checkout main, `withMergeHeal` for squash merge. In auto.ts ~L1670-1695: replace the raw error message with `formatGitError`. Ensure `MergeConflictError` still propagates to auto.ts fix-merge dispatch (self-heal must re-throw it, not swallow it). Run existing merge tests to confirm no regressions. + - Verify: `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` (21 pass), `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` (23 pass), `npx tsc --noEmit` clean + - Done when: Self-heal wraps all merge/checkout paths in auto-worktree.ts, auto.ts uses formatGitError, all existing tests pass + +## Files Likely Touched + +- `src/resources/extensions/gsd/git-self-heal.ts` (new) +- `src/resources/extensions/gsd/tests/git-self-heal.test.ts` (new) +- `src/resources/extensions/gsd/auto-worktree.ts` +- `src/resources/extensions/gsd/auto.ts` diff --git a/.gsd/milestones/M003/slices/S05/S05-RESEARCH.md b/.gsd/milestones/M003/slices/S05/S05-RESEARCH.md new file mode 100644 index 000000000..ed96293fb --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/S05-RESEARCH.md @@ -0,0 +1,70 @@ +# S05: Self-healing git repair — Research + +**Date:** 2026-03-14 + +## Summary + +Self-healing needs to wrap three failure points that already exist in auto-worktree.ts and auto.ts: (1) `mergeSliceToMilestone` conflicts/failures, (2) `mergeMilestoneToMain` conflicts/failures, and (3) checkout failures during worktree operations. The good news: most of the error detection and recovery infrastructure already exists in auto.ts — the mid-merge safety check block (~L1524-1580) already does MERGE_HEAD/SQUASH_MSG detection, abort, reset, and finalization. The fix-merge dispatch pattern (~L1624-1695) already handles MergeConflictError by spawning a conflict resolution session. What's missing is: (a) a reusable `withGitSelfHeal` wrapper that tries abort+reset+retry before giving up, (b) checkout failure recovery (dirty index, detached HEAD), (c) user-facing error messages that hide git jargon, and (d) wiring self-heal into auto-worktree.ts functions which currently use raw `execSync` with no error handling. + +The approach should be a utility module (`git-self-heal.ts`) exporting focused repair functions, not a monolithic wrapper. The existing `buildFixMergePrompt` in auto.ts is the right pattern for truly unresolvable conflicts — self-heal handles the automatable cases, and only escalates to fix-merge or pause for real code conflicts. + +## Recommendation + +Create `src/resources/extensions/gsd/git-self-heal.ts` with: +1. `abortAndReset(cwd)` — detects MERGE_HEAD/SQUASH_MSG/rebase, aborts, resets to HEAD +2. `tryMergeWithHeal(cwd, mergeFn)` — wraps a merge operation: on failure, abort+reset, retry once, then throw +3. `recoverCheckout(cwd, targetBranch)` — stash dirty state, force checkout, pop stash +4. `formatUserError(gitError)` — translates git errors to non-technical messages + +Wire these into `mergeSliceToMilestone` and `mergeMilestoneToMain` in auto-worktree.ts, and into the auto.ts merge guard block. The existing fix-merge dispatch in auto.ts stays as the escalation path for real conflicts that survive self-heal. + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| Merge conflict detection | `git diff --name-only --diff-filter=U` pattern in auto-worktree.ts | Already proven in S02/S03 | +| Merge abort | `git merge --abort` pattern in auto.ts L1555 | Already proven in mid-merge safety check | +| Hard reset | `git reset --hard HEAD` pattern in auto.ts L1559, L1675 | Already proven | +| Fix-merge dispatch | `buildFixMergePrompt` + fix-merge unit in auto.ts L1624-1695 | Already proven escalation path | +| MergeConflictError class | git-service.ts L62 | Structured error with conflictedFiles, strategy, branches | + +## Existing Code and Patterns + +- `auto.ts:1524-1580` — Mid-merge safety check: detects leftover MERGE_HEAD/SQUASH_MSG, finalizes or aborts+resets. This is the template for self-heal detection logic. +- `auto.ts:1624-1695` — Fix-merge dispatch: on MergeConflictError, spawns an LLM session to resolve conflicts. This is the escalation path self-heal should defer to. +- `auto.ts:1670-1690` — Non-conflict error handling: detects UU/AA/UD in status, resets, stops. This should be replaced by self-heal retry. +- `auto-worktree.ts:250-350` — `mergeSliceToMilestone`: raw execSync for checkout and merge, throws MergeConflictError on conflict, no retry logic. +- `auto-worktree.ts:410-480` — `mergeMilestoneToMain`: raw execSync for checkout and squash-merge, throws MergeConflictError, no retry. +- `git-service.ts:829` — `reset --hard HEAD` used in ensureSliceBranch error path. +- `git-service.ts:574` — `git clean -fdx` used in branch setup, documents safety rationale. + +## Constraints + +- All git operations use `execSync` (not async) — self-heal functions must be synchronous +- `loadEffectiveGSDPreferences` captures cwd at module load time — cannot be used reliably in worktree context (D042) +- Worktree `.gsd/` is not tracked in git — self-heal must never `git clean` the `.gsd/` directory +- `mergeSliceToMilestone` requires caller to be on milestone branch — recovery must restore this invariant +- `mergeMilestoneToMain` does `process.chdir` — recovery must handle cwd being in either worktree or project root + +## Common Pitfalls + +- **Resetting in wrong cwd** — `mergeMilestoneToMain` chdir to originalBasePath before merge. If merge fails, reset must happen in originalBasePath, not worktree. The cwd after chdir is the critical context. +- **Stale SQUASH_MSG without MERGE_HEAD** — squash-merge leaves SQUASH_MSG but no MERGE_HEAD. `git merge --abort` won't clear it. Must manually unlink SQUASH_MSG (already handled in auto.ts L1560-1564). +- **Retry causing duplicate commits** — if a merge partially succeeded (committed but post-merge step failed), retrying would error with "already up to date." Must check current state before retrying. +- **git clean deleting .gsd/** — `git clean -fdx` would wipe `.gsd/` in worktrees where it's untracked. Self-heal must use `git checkout -- .` or `git reset --hard`, never `git clean` without exclusions. + +## Open Risks + +- Self-heal retry on a real code conflict wastes time — the retry will fail identically. Need fast detection: if `git diff --diff-filter=U` returns files, skip retry and escalate immediately. +- `process.chdir` state during error recovery is fragile — if an exception occurs between chdir and merge, the cwd may be wrong for subsequent operations. + +## Skills Discovered + +| Technology | Skill | Status | +|------------|-------|--------| +| git | N/A — git CLI operations, no specialized skill needed | none found | + +## Sources + +- Existing codebase analysis (auto.ts, auto-worktree.ts, git-service.ts) +- S01/S02/S03 forward intelligence sections diff --git a/.gsd/milestones/M003/slices/S05/S05-SUMMARY.md b/.gsd/milestones/M003/slices/S05/S05-SUMMARY.md new file mode 100644 index 000000000..09cc5bb22 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/S05-SUMMARY.md @@ -0,0 +1,112 @@ +--- +id: S05 +parent: M003 +milestone: M003 +provides: + - git-self-heal module (abortAndReset, withMergeHeal, recoverCheckout, formatGitError) + - self-heal wrappers integrated into merge/checkout paths in auto-worktree.ts and auto.ts +requires: + - slice: S01 + provides: worktree detection functions (isInAutoWorktree) + - slice: S02 + provides: mergeSliceToMilestone merge operation + - slice: S03 + provides: mergeMilestoneToMain squash merge operation +affects: + - S06 +key_files: + - src/resources/extensions/gsd/git-self-heal.ts + - src/resources/extensions/gsd/tests/git-self-heal.test.ts + - src/resources/extensions/gsd/auto-worktree.ts + - src/resources/extensions/gsd/auto.ts +key_decisions: + - D030 applied — withMergeHeal detects real conflicts via git diff --diff-filter=U and escalates immediately without retry; only transient failures get abort+reset+retry + - MergeConflictError re-thrown with correct branch context after withMergeHeal (heal function uses "unknown" placeholders) + - abortAndReset checks both rebase-apply and rebase-merge dirs for completeness +patterns_established: + - Synchronous git recovery functions returning structured results ({ cleaned: string[] }) + - Error pattern matching with user-friendly messages suggesting /gsd doctor + - withMergeHeal wraps merge calls; catch block re-throws MergeConflictError with correct branch names + - recoverCheckout replaces raw git checkout at all checkout sites +observability_surfaces: + - abortAndReset returns { cleaned: string[] } describing actions taken + - formatGitError output always includes /gsd doctor suggestion + - withMergeHeal re-throws MergeConflictError with structured conflict data for real conflicts +drill_down_paths: + - .gsd/milestones/M003/slices/S05/tasks/T01-SUMMARY.md + - .gsd/milestones/M003/slices/S05/tasks/T02-SUMMARY.md +duration: 16m +verification_result: passed +completed_at: 2026-03-14 +--- + +# S05: Self-healing git repair + +**Automatic git failure recovery — abort, reset, retry for transient failures; immediate escalation for real code conflicts** + +## What Happened + +Built `git-self-heal.ts` with four synchronous recovery functions: `abortAndReset` (clears MERGE_HEAD/SQUASH_MSG/rebase state), `withMergeHeal` (wraps merge ops with conflict detection and auto-retry), `recoverCheckout` (resets dirty index before checkout), and `formatGitError` (translates git errors to user-friendly messages with `/gsd doctor` suggestion). All tested against real temp git repos with deliberate broken state (14 assertions). + +Wired self-heal into `auto-worktree.ts` — `recoverCheckout` replaces raw `git checkout` at both checkout sites (slice merge and milestone merge), `withMergeHeal` wraps both merge blocks. In `auto.ts`, `formatGitError` replaces raw error messages in the non-conflict error notification path. MergeConflictError propagation preserved with correct branch context. + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — 14/14 pass ✅ +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` — 21/21 pass ✅ +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` — 23/23 pass ✅ +- `npx tsc --noEmit` — zero errors ✅ + +## Requirements Advanced + +- R035 — Self-healing git repair now implemented: abortAndReset, withMergeHeal, recoverCheckout handle transient failures automatically +- R037 — Zero git errors for vibe coders: formatGitError translates all git errors to non-technical messages with `/gsd doctor` suggestion + +## Requirements Validated + +- None moved to validated — full validation requires S06 (doctor) and S07 (test suite) to complete the coverage + +## New Requirements Surfaced + +- None + +## Requirements Invalidated or Re-scoped + +- None + +## Deviations + +- Added `rebase-merge` dir check alongside `rebase-apply` in `abortAndReset` — git uses either depending on rebase type (interactive vs non-interactive). Minor addition, no plan change. + +## Known Limitations + +- Self-heal retry is limited to one attempt — repeated transient failures will still escalate +- `/gsd doctor` command referenced in error messages doesn't exist yet (S06) +- No self-heal for remote push failures (out of scope for this slice) + +## Follow-ups + +- S06: `/gsd doctor` command to detect and fix orphaned worktrees, stale branches, corrupt merge state +- S06: Remove dead `.gsd/` conflict resolution code from worktree-mode paths + +## Files Created/Modified + +- `src/resources/extensions/gsd/git-self-heal.ts` — module with 4 exported recovery functions +- `src/resources/extensions/gsd/tests/git-self-heal.test.ts` — 14-assertion integration test suite +- `src/resources/extensions/gsd/auto-worktree.ts` — replaced checkout/merge with recoverCheckout/withMergeHeal wrappers +- `src/resources/extensions/gsd/auto.ts` — added formatGitError in non-conflict error notification path + +## Forward Intelligence + +### What the next slice should know +- `formatGitError` suggests `/gsd doctor` which doesn't exist yet — S06 must implement the doctor git health checks that users will be directed to +- The self-heal patterns (try/abort/reset/retry) established here should inform doctor's fix operations + +### What's fragile +- `withMergeHeal` re-throw block manually reconstructs MergeConflictError with correct branch names — if MergeConflictError constructor changes, this breaks silently + +### Authoritative diagnostics +- `git-self-heal.test.ts` — tests against real git repos with real broken state, not mocks. If these pass, the recovery logic works. + +### What assumptions changed +- Original plan assumed `recoverCheckout` might need stash — confirmed worktree changes are expendable so `git reset --hard HEAD` suffices diff --git a/.gsd/milestones/M003/slices/S05/S05-UAT.md b/.gsd/milestones/M003/slices/S05/S05-UAT.md new file mode 100644 index 000000000..a1fb7b479 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/S05-UAT.md @@ -0,0 +1,96 @@ +# S05: Self-healing git repair — UAT + +**Milestone:** M003 +**Written:** 2026-03-14 + +## UAT Type + +- UAT mode: live-runtime +- Why this mode is sufficient: Self-healing must be verified against real git repos with real failures — artifact inspection alone cannot prove recovery works + +## Preconditions + +- Project has a git repo initialized +- `npx tsc --noEmit` passes (no type errors) +- All three test suites pass (git-self-heal, auto-worktree-merge, auto-worktree-milestone-merge) + +## Smoke Test + +Run `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — all 14 assertions pass, confirming core self-heal functions work against real git repos. + +## Test Cases + +### 1. Transient merge failure auto-recovery + +1. Create a temp git repo with a milestone branch +2. Leave a stale MERGE_HEAD file in `.git/` +3. Trigger `mergeSliceToMilestone` — the self-heal should detect the stale state, abort, reset, and retry successfully +4. **Expected:** Merge completes without error. No user intervention required. + +### 2. Real code conflict escalation + +1. Create a temp git repo with conflicting changes on two branches (same file, same line, different content) +2. Trigger `withMergeHeal` with a merge that produces UU (unmerged) files +3. **Expected:** MergeConflictError thrown immediately — no retry attempted. Error includes conflict file list. + +### 3. Dirty index checkout recovery + +1. Create a temp git repo with uncommitted changes in the index +2. Call `recoverCheckout(cwd, targetBranch)` +3. **Expected:** Index is reset, checkout succeeds to target branch. + +### 4. User-friendly error messages + +1. Trigger a git error (e.g., run git command in non-repo directory) +2. Pass the error through `formatGitError` +3. **Expected:** Output is a non-technical message suggesting `/gsd doctor`. No raw git stderr visible to user. + +### 5. Existing merge tests still pass + +1. Run `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` +2. Run `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` +3. **Expected:** 21/21 and 23/23 pass respectively. Self-heal wrappers cause zero regressions. + +## Edge Cases + +### SQUASH_MSG cleanup + +1. Create `.git/SQUASH_MSG` file in a repo +2. Call `abortAndReset(cwd)` +3. **Expected:** SQUASH_MSG removed, `cleaned` array includes "SQUASH_MSG" + +### Rebase state cleanup + +1. Create `.git/rebase-merge/` or `.git/rebase-apply/` directory +2. Call `abortAndReset(cwd)` +3. **Expected:** Rebase aborted, `cleaned` array includes the rebase type + +### No-op on clean state + +1. Call `abortAndReset(cwd)` on a clean repo with no merge/rebase state +2. **Expected:** Returns `{ cleaned: [] }` — no actions taken + +## Failure Signals + +- Any test suite assertion failure +- `MergeConflictError` thrown for transient failures (should only throw for real conflicts) +- Raw git error messages appearing in auto.ts error notifications (should be formatted) +- `git clean` appearing anywhere in the codebase (explicitly forbidden — only `git reset --hard HEAD` used) + +## Requirements Proved By This UAT + +- R035 — Self-healing git repair: transient failures auto-recovered, real conflicts escalated +- R037 — Zero git errors for vibe coders: all error messages are user-friendly with `/gsd doctor` suggestion + +## Not Proven By This UAT + +- R040 — Doctor git health checks (S06) +- R036 — Dead conflict resolution code removal (S06) +- Remote push failure recovery (out of scope) +- Full end-to-end auto-mode self-heal during live milestone execution (S07 integration tests) + +## Notes for Tester + +- The `/gsd doctor` command referenced in error messages doesn't exist yet — that's expected (S06 will implement it) +- Self-heal retry is intentionally limited to one attempt — this is a design choice, not a bug +- All tests use real temp git repos with real git operations, not mocks diff --git a/.gsd/milestones/M003/slices/S05/tasks/T01-PLAN.md b/.gsd/milestones/M003/slices/S05/tasks/T01-PLAN.md new file mode 100644 index 000000000..944dea151 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/tasks/T01-PLAN.md @@ -0,0 +1,51 @@ +--- +estimated_steps: 6 +estimated_files: 2 +--- + +# T01: Create git-self-heal.ts module with repair functions and tests + +**Slice:** S05 — Self-healing git repair +**Milestone:** M003 + +## Description + +Create `git-self-heal.ts` with four focused synchronous functions for automated git state recovery, plus an integration test suite exercising each function against real temp git repos with deliberately broken state. + +## Steps + +1. Create `git-self-heal.ts` with `abortAndReset(cwd)`: check for `.git/MERGE_HEAD`, `.git/SQUASH_MSG`, `.git/rebase-apply`; abort merge/rebase if detected; `git reset --hard HEAD`. Return `{ cleaned: string[] }` describing what was cleared. +2. Add `withMergeHeal(cwd, mergeFn)`: call `mergeFn()`. On error, run `git diff --diff-filter=U` — if conflicted files exist, re-throw as `MergeConflictError` immediately (no retry). Otherwise `abortAndReset(cwd)`, retry `mergeFn()` once. On second failure, throw. +3. Add `recoverCheckout(cwd, targetBranch)`: `git reset --hard HEAD` then `git checkout `. If checkout still fails, throw with context. +4. Add `formatGitError(error)`: pattern-match common git error strings (merge conflict, checkout failure, detached HEAD, lock file) to user-friendly messages suggesting `/gsd doctor`. +5. Create test file with temp git repo fixtures: test `abortAndReset` with leftover MERGE_HEAD, with leftover SQUASH_MSG, with clean state (no-op). Test `withMergeHeal` with transient failure (succeeds on retry), with real conflict (escalates immediately). Test `recoverCheckout` with dirty index. Test `formatGitError` with known error patterns. +6. Run `npx tsc --noEmit` to verify types. + +## Must-Haves + +- [ ] All four functions exported and synchronous (execSync) +- [ ] Never uses `git clean` — only `git reset --hard HEAD` +- [ ] Real conflict detection skips retry and escalates immediately +- [ ] Test suite uses real temp git repos, not mocks + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — all pass +- `npx tsc --noEmit` — zero errors + +## Inputs + +- S05-RESEARCH.md — existing patterns from auto.ts L1524-1580 (abort/reset), MergeConflictError from git-service.ts +- auto-worktree.ts — `execSync` patterns for git operations + +## Observability Impact + +- **Structured results:** `abortAndReset` returns `{ cleaned: string[] }` listing every action taken (e.g. "aborted merge", "removed SQUASH_MSG", "reset to HEAD"). Empty array = no-op. +- **Error translation:** `formatGitError` maps raw git errors to user-facing messages that always suggest `/gsd doctor`. +- **Conflict escalation:** `withMergeHeal` detects real conflicts via `git diff --diff-filter=U` and re-throws `MergeConflictError` without retry — callers see structured conflict data. +- **Failure inspection:** All functions throw with descriptive messages on unrecoverable failure; `recoverCheckout` includes branch name and underlying git error in the thrown Error. + +## Expected Output + +- `src/resources/extensions/gsd/git-self-heal.ts` — module with 4 exports +- `src/resources/extensions/gsd/tests/git-self-heal.test.ts` — integration tests proving recovery diff --git a/.gsd/milestones/M003/slices/S05/tasks/T01-SUMMARY.md b/.gsd/milestones/M003/slices/S05/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..41c99f55e --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/tasks/T01-SUMMARY.md @@ -0,0 +1,58 @@ +--- +id: T01 +parent: S05 +milestone: M003 +provides: + - git-self-heal module with abortAndReset, withMergeHeal, recoverCheckout, formatGitError +key_files: + - src/resources/extensions/gsd/git-self-heal.ts + - src/resources/extensions/gsd/tests/git-self-heal.test.ts +key_decisions: + - withMergeHeal checks git diff --diff-filter=U to detect real conflicts and skips retry entirely + - abortAndReset also checks for rebase-merge dir (not just rebase-apply) for completeness +patterns_established: + - Synchronous git recovery functions returning structured results ({ cleaned: string[] }) + - Error pattern matching with user-friendly messages suggesting /gsd doctor +observability_surfaces: + - abortAndReset returns { cleaned: string[] } describing actions taken + - formatGitError output always includes /gsd doctor suggestion +duration: 8m +verification_result: passed +completed_at: 2026-03-14 +blocker_discovered: false +--- + +# T01: Create git-self-heal.ts module with repair functions and tests + +**Built git-self-heal.ts with 4 synchronous recovery functions and 14-assertion integration test suite against real temp git repos** + +## What Happened + +Created `git-self-heal.ts` exporting `abortAndReset`, `withMergeHeal`, `recoverCheckout`, and `formatGitError`. All functions are synchronous (execSync), never use `git clean`, and return structured results. `withMergeHeal` detects real conflicts via `git diff --diff-filter=U` and escalates immediately without retry — only transient failures get abort+reset+retry. Test suite creates real temp git repos with deliberate broken state (leftover MERGE_HEAD, SQUASH_MSG, merge conflicts, dirty indexes). + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/git-self-heal.test.ts` — 14/14 pass ✅ +- `npx tsc --noEmit` — zero errors ✅ +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` — 21/21 pass ✅ +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` — 23/23 pass ✅ + +## Diagnostics + +- `abortAndReset` result `.cleaned` array shows exactly what was cleaned (empty = no-op) +- `formatGitError` always suggests `/gsd doctor` in output +- `withMergeHeal` re-throws `MergeConflictError` with structured conflict data for real conflicts + +## Deviations + +- Added `rebase-merge` dir check alongside `rebase-apply` in `abortAndReset` — git uses either depending on rebase type. + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/git-self-heal.ts` — module with 4 exported recovery functions +- `src/resources/extensions/gsd/tests/git-self-heal.test.ts` — 14-assertion integration test suite +- `.gsd/milestones/M003/slices/S05/tasks/T01-PLAN.md` — added Observability Impact section diff --git a/.gsd/milestones/M003/slices/S05/tasks/T02-PLAN.md b/.gsd/milestones/M003/slices/S05/tasks/T02-PLAN.md new file mode 100644 index 000000000..35fa1d56a --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/tasks/T02-PLAN.md @@ -0,0 +1,51 @@ +--- +estimated_steps: 5 +estimated_files: 2 +--- + +# T02: Wire self-heal into auto-worktree.ts and auto.ts + +**Slice:** S05 — Self-healing git repair +**Milestone:** M003 + +## Description + +Integrate the self-heal utilities from `git-self-heal.ts` into the existing merge and checkout paths in `auto-worktree.ts` and `auto.ts`, replacing raw error handling with structured recovery. + +## Steps + +1. In `mergeSliceToMilestone` (auto-worktree.ts): replace the raw `execSync git checkout` with `recoverCheckout(cwd, milestoneBranch)`. Wrap the `execSync git merge --no-ff` block with `withMergeHeal` — pass a function that does the merge, let `withMergeHeal` handle abort+reset+retry for transient failures and immediate escalation for real conflicts. +2. In `mergeMilestoneToMain` (auto-worktree.ts): replace checkout main with `recoverCheckout(originalBasePath_, mainBranch)`. Wrap the squash-merge block with `withMergeHeal`. +3. In auto.ts ~L1670-1695 (non-conflict error handling): replace raw `error.message` in the notify call with `formatGitError(error)`. +4. Verify MergeConflictError still propagates correctly through `withMergeHeal` to auto.ts fix-merge dispatch. +5. Run all existing merge test suites to confirm zero regressions. + +## Must-Haves + +- [ ] `MergeConflictError` propagates unchanged to auto.ts fix-merge dispatch +- [ ] Existing test suites pass without modification +- [ ] `recoverCheckout` used at both checkout sites in auto-worktree.ts +- [ ] `formatGitError` used in auto.ts error notification + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` — 21 pass +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` — 23 pass +- `npx tsc --noEmit` — zero errors + +## Observability Impact + +- Signals added/changed: merge failures now show user-friendly messages instead of raw git output +- How a future agent inspects this: error messages include `/gsd doctor` suggestion +- Failure state exposed: self-heal retry action visible in error context + +## Inputs + +- `src/resources/extensions/gsd/git-self-heal.ts` — T01 output (4 exported functions) +- `src/resources/extensions/gsd/auto-worktree.ts` — existing merge functions to wrap +- `src/resources/extensions/gsd/auto.ts` — existing error handling block ~L1670 + +## Expected Output + +- `src/resources/extensions/gsd/auto-worktree.ts` — modified with self-heal wrappers +- `src/resources/extensions/gsd/auto.ts` — modified with formatGitError diff --git a/.gsd/milestones/M003/slices/S05/tasks/T02-SUMMARY.md b/.gsd/milestones/M003/slices/S05/tasks/T02-SUMMARY.md new file mode 100644 index 000000000..de3a56489 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/tasks/T02-SUMMARY.md @@ -0,0 +1,55 @@ +--- +id: T02 +parent: S05 +milestone: M003 +provides: + - self-heal wrappers integrated into merge and checkout paths in auto-worktree.ts and auto.ts +key_files: + - src/resources/extensions/gsd/auto-worktree.ts + - src/resources/extensions/gsd/auto.ts +key_decisions: + - Re-throw MergeConflictError with correct branch context after withMergeHeal, since withMergeHeal uses "unknown" placeholders +patterns_established: + - withMergeHeal wraps merge execSync calls; catch block re-throws MergeConflictError with correct branch names + - recoverCheckout replaces raw git checkout execSync at both checkout sites +observability_surfaces: + - formatGitError output in auto.ts error notifications includes /gsd doctor suggestion +duration: 8 minutes +verification_result: passed +completed_at: 2026-03-14 +blocker_discovered: false +--- + +# T02: Wire self-heal into auto-worktree.ts and auto.ts + +**Integrated self-heal recovery (recoverCheckout, withMergeHeal, formatGitError) into merge/checkout paths** + +## What Happened + +Replaced raw `execSync git checkout` calls with `recoverCheckout` at both checkout sites (mergeSliceToMilestone and mergeMilestoneToMain). Wrapped both merge blocks with `withMergeHeal` for automatic abort+reset+retry on transient failures. Added `formatGitError` import to auto.ts and used it in the non-conflict error notification path (~L1675). MergeConflictError is re-thrown with correct branch context after withMergeHeal since the heal function uses "unknown" placeholders. + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts` — 21 passed, 0 failed +- `npx tsx src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts` — 23 passed, 0 failed +- `npx tsc --noEmit` — zero errors +- MergeConflictError propagation confirmed: test "branch includes S01" passes (correct branch context preserved) + +## Diagnostics + +- Merge failures in auto-mode now show user-friendly messages via formatGitError instead of raw git output +- All error messages include `/gsd doctor` suggestion +- Self-heal retry is transparent — withMergeHeal handles abort+reset+retry internally + +## Deviations + +MergeConflictError from withMergeHeal needed re-throw with correct branch names (sliceBranch/milestoneBranch) since withMergeHeal creates errors with "unknown" placeholders. This was discovered via test failure and fixed. + +## Known Issues + +None + +## Files Created/Modified + +- `src/resources/extensions/gsd/auto-worktree.ts` — replaced checkout/merge with recoverCheckout/withMergeHeal wrappers +- `src/resources/extensions/gsd/auto.ts` — added formatGitError import and usage in non-conflict error path diff --git a/packages/pi-coding-agent/src/core/system-prompt.ts b/packages/pi-coding-agent/src/core/system-prompt.ts index 949809765..1b57d13fe 100644 --- a/packages/pi-coding-agent/src/core/system-prompt.ts +++ b/packages/pi-coding-agent/src/core/system-prompt.ts @@ -14,6 +14,7 @@ const toolDescriptions: Record = { grep: "Search file contents for patterns (respects .gitignore)", find: "Find files by glob pattern (respects .gitignore)", ls: "List directory contents", + lsp: "Code intelligence via Language Server Protocol (go-to-definition, references, diagnostics, hover, rename, symbols)", }; export interface BuildSystemPromptOptions { @@ -131,6 +132,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); + const hasLsp = tools.includes("lsp"); // File exploration guidelines if (hasBash && !hasGrep && !hasFind && !hasLs) { @@ -154,6 +156,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin addGuideline("Use write only for new files or complete rewrites"); } + // LSP guideline + if (hasLsp) { + addGuideline( + "Use lsp for go-to-definition, find-references, hover, rename, and diagnostics when working in typed codebases. Prefer lsp over grep for semantic navigation (finding call sites, implementations, type info). Falls back gracefully if no language server is available for the file type.", + ); + } + // Output guideline (only when actually writing or executing) if (hasEdit || hasWrite) { addGuideline( diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index cafa4e09e..c1801e3c4 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -23,6 +23,7 @@ import { inferCommitType, } from "./git-service.js"; import type { MergeSliceResult } from "./git-service.js"; +import { recoverCheckout, withMergeHeal } from "./git-self-heal.js"; import { nativeBranchExists, nativeCommitCountBetween, @@ -275,12 +276,8 @@ export function mergeSliceToMilestone( ); } - // Checkout milestone branch - execSync(`git checkout ${milestoneBranch}`, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); + // Checkout milestone branch (with self-healing reset) + recoverCheckout(cwd, milestoneBranch); // Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format) const commitType = inferCommitType(sliceTitle); @@ -308,36 +305,26 @@ export function mergeSliceToMilestone( // Fall back to subject-only message } - // Merge --no-ff + // Merge --no-ff (with self-healing retry for transient failures) try { - execSync(`git merge --no-ff -m "${message.replace(/"/g, '\\"')}" ${sliceBranch}`, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch { - // Check if this is a merge conflict - try { - const conflictOutput = execSync("git diff --name-only --diff-filter=U", { + withMergeHeal(cwd, () => { + execSync(`git merge --no-ff -m "${message.replace(/"/g, '\\"')}" ${sliceBranch}`, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", - }).trim(); - - if (conflictOutput) { - const conflictedFiles = conflictOutput.split("\n").filter(Boolean); - throw new MergeConflictError( - conflictedFiles, - "merge", - sliceBranch, - milestoneBranch, - ); - } - } catch (innerErr) { - if (innerErr instanceof MergeConflictError) throw innerErr; + }); + }); + } catch (err) { + if (err instanceof MergeConflictError) { + // Re-throw with correct branch context + throw new MergeConflictError( + err.conflictedFiles, + err.strategy, + sliceBranch, + milestoneBranch, + ); } - // Non-conflict git error - throw new Error(`git merge --no-ff failed for ${sliceBranch} into ${milestoneBranch}`); + throw err; } // Delete slice branch @@ -426,12 +413,8 @@ export function mergeMilestoneToMain( const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; const mainBranch = prefs.main_branch || "main"; - // 5. Checkout main - execSync(`git checkout ${mainBranch}`, { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); + // 5. Checkout main (with self-healing reset) + recoverCheckout(originalBasePath_, mainBranch); // 6. Build rich commit message const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId; @@ -443,32 +426,24 @@ export function mergeMilestoneToMain( } const commitMessage = subject + body; - // 7. Squash merge + // 7. Squash merge (with self-healing retry for transient failures) try { - execSync(`git merge --squash ${milestoneBranch}`, { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch { - // Check for merge conflicts - try { - const conflictOutput = execSync("git diff --name-only --diff-filter=U", { + withMergeHeal(originalBasePath_, () => { + execSync(`git merge --squash ${milestoneBranch}`, { cwd: originalBasePath_, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", - }).trim(); - if (conflictOutput) { - const conflictedFiles = conflictOutput.split("\n").filter(Boolean); - throw new MergeConflictError( - conflictedFiles, - "merge", - milestoneBranch, - mainBranch, - ); - } - } catch (innerErr) { - if (innerErr instanceof MergeConflictError) throw innerErr; + }); + }); + } catch (err) { + if (err instanceof MergeConflictError) { + // Re-throw with correct branch context + throw new MergeConflictError( + err.conflictedFiles, + err.strategy, + milestoneBranch, + mainBranch, + ); } // Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index ed7b8e323..ba866014d 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -86,6 +86,7 @@ import { import { GitServiceImpl, runGit } from "./git-service.js"; import { nativeCommitCountBetween } from "./native-git-bridge.js"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; +import { formatGitError } from "./git-self-heal.js"; import { createAutoWorktree, enterAutoWorktree, @@ -1703,7 +1704,7 @@ async function dispatchNextUnit( } // Non-conflict errors: reset and stop - const message = error instanceof Error ? error.message : String(error); + const message = formatGitError(error instanceof Error ? error : String(error)); try { const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true }); if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) { diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts new file mode 100644 index 000000000..513f54b6f --- /dev/null +++ b/src/resources/extensions/gsd/git-self-heal.ts @@ -0,0 +1,198 @@ +/** + * git-self-heal.ts — Automated git state recovery utilities. + * + * Four synchronous functions for recovering from broken git state + * during auto-mode operations. Uses only `git reset --hard HEAD` — + * never `git clean` (which would delete untracked .gsd/ dirs). + * + * Observability: Each function returns structured results describing + * what actions were taken. `formatGitError` maps raw git errors to + * user-friendly messages suggesting `/gsd doctor`. + */ + +import { execSync } from "node:child_process"; +import { existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { MergeConflictError } from "./git-service.js"; + +// Re-export for consumers +export { MergeConflictError }; + +/** Result from abortAndReset describing what was cleaned up. */ +export interface AbortAndResetResult { + /** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */ + cleaned: string[]; +} + +/** + * Detect and clean up leftover merge/rebase state, then hard-reset. + * + * Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply. + * Aborts in-progress merge or rebase if detected. Always finishes + * with `git reset --hard HEAD`. + * + * @returns Structured result listing what was cleaned. Empty `cleaned` + * array means repo was already in a clean state. + */ +export function abortAndReset(cwd: string): AbortAndResetResult { + const gitDir = join(cwd, ".git"); + const cleaned: string[] = []; + + // Abort in-progress merge + if (existsSync(join(gitDir, "MERGE_HEAD"))) { + try { + execSync("git merge --abort", { cwd, stdio: "pipe" }); + cleaned.push("aborted merge"); + } catch { + // merge --abort can fail if state is really broken; continue to reset + cleaned.push("merge abort attempted (may have failed)"); + } + } + + // Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD) + const squashMsgPath = join(gitDir, "SQUASH_MSG"); + if (existsSync(squashMsgPath)) { + try { + unlinkSync(squashMsgPath); + cleaned.push("removed SQUASH_MSG"); + } catch { + // Not critical + } + } + + // Abort in-progress rebase + if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) { + try { + execSync("git rebase --abort", { cwd, stdio: "pipe" }); + cleaned.push("aborted rebase"); + } catch { + cleaned.push("rebase abort attempted (may have failed)"); + } + } + + // Always hard-reset to HEAD + try { + execSync("git reset --hard HEAD", { cwd, stdio: "pipe" }); + if (cleaned.length > 0) { + cleaned.push("reset to HEAD"); + } + } catch { + cleaned.push("reset to HEAD failed"); + } + + return { cleaned }; +} + +/** + * Wrap a merge operation with self-healing retry logic. + * + * Calls `mergeFn()`. On failure: + * - If conflicted files exist (via `git diff --diff-filter=U`), re-throws + * as MergeConflictError immediately — no retry for real code conflicts. + * - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once. + * - On second failure, throws the error. + * + * @param cwd - Working directory for git operations + * @param mergeFn - Synchronous function that performs the merge + * @returns The return value of `mergeFn()` + */ +export function withMergeHeal(cwd: string, mergeFn: () => T): T { + try { + return mergeFn(); + } catch (firstError) { + // Check for real code conflicts — escalate immediately, no retry + try { + const conflictOutput = execSync("git diff --name-only --diff-filter=U", { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + if (conflictOutput.length > 0) { + const conflictedFiles = conflictOutput.split("\n").filter(Boolean); + // If the original error is already a MergeConflictError, re-throw as-is + if (firstError instanceof MergeConflictError) { + throw firstError; + } + throw new MergeConflictError( + conflictedFiles, + "merge", + "unknown", + "unknown", + ); + } + } catch (diffErr) { + // If diffErr is a MergeConflictError we just created/re-threw, propagate it + if (diffErr instanceof MergeConflictError) throw diffErr; + // Otherwise git diff itself failed — proceed with retry + } + + // No real conflict detected — try abort+reset+retry once + abortAndReset(cwd); + + // Retry + return mergeFn(); + } +} + +/** + * Recover a failed checkout by resetting first, then checking out. + * + * Performs `git reset --hard HEAD` then `git checkout `. + * If checkout still fails after reset, throws with context. + */ +export function recoverCheckout(cwd: string, targetBranch: string): void { + execSync("git reset --hard HEAD", { cwd, stdio: "pipe" }); + + try { + execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`, + ); + } +} + +/** Known git error patterns mapped to user-friendly messages. */ +const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ + { + pattern: /conflict|CONFLICT|merge conflict/i, + message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.", + }, + { + pattern: /cannot checkout|did not match any|pathspec .* did not match/i, + message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.", + }, + { + pattern: /HEAD detached|detached HEAD/i, + message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.", + }, + { + pattern: /\.lock|Unable to create .* lock|lock file/i, + message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.", + }, + { + pattern: /fatal: not a git repository/i, + message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.", + }, +]; + +/** + * Translate raw git error strings into user-friendly messages. + * + * Pattern-matches against common git error strings and returns + * a non-technical message suggesting `/gsd doctor`. Returns the + * original message if no pattern matches. + */ +export function formatGitError(error: string | Error): string { + const errorStr = error instanceof Error ? error.message : error; + + for (const { pattern, message } of ERROR_PATTERNS) { + if (pattern.test(errorStr)) { + return message; + } + } + + return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`; +} diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 7bf62e149..fc50ad77c 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -120,17 +120,37 @@ Templates showing the expected format for each artifact type are in: ## Execution Heuristics -### Tool-routing hierarchy +### Tool rules -Use the lightest sufficient tool first. +**File reading:** Use `read` for inspecting files. Never use `cat`, `head`, `tail`, or `sed -n` to view file contents. Use `read` with `offset`/`limit` for slicing. `bash` is for searching (`rg`, `grep`, `find`) and running commands — not for displaying file contents. -- Broad unfamiliar subsystem mapping -> `subagent` with `scout` -- Library, package, or framework truth -> `resolve_library` then `get_library_docs` -- Current external facts -> `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction -- Long-running processes (servers, watchers, persistent daemons) -> `bg_shell` with `start` + `wait_for_ready` -- Background process status -> `bg_shell` with `digest` (not `output`). Token budget: `digest` (~30 tokens) < `highlights` (~100) < `output` (~2000). -- One-shot commands where you want the result delivered back (builds, tests, installs) -> `async_bash`; result is pushed to you automatically when the command exits. -- Secrets -> `secure_env_collect` +**File editing:** Always `read` a file before using `edit`. The `edit` tool requires exact text match — you need the real content, not a guess. Use `write` only for new files or complete rewrites. + +**Code navigation:** Use `lsp` for go-to-definition, find-references, and type info. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. + +**Codebase exploration:** Use `subagent` with `scout` for broad unfamiliar subsystem mapping. Use `rg` for text search across files. Use `lsp` for structural navigation. Never read files one-by-one to "explore" — search first, then read what's relevant. + +**Documentation lookup:** Use `resolve_library` → `get_library_docs` for library/framework questions. Start with `tokens=5000`. Never guess at API signatures from memory when docs are available. + +**External facts:** Use `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction. Use `freshness` for recency. Never state current facts from training data without verification. + +**Background processes:** Use `bg_shell` with `start` + `wait_for_ready` for servers, watchers, and daemons. Never poll with `sleep`/retry loops — `wait_for_ready` exists for this. For status checks, use `digest` (~30 tokens), not `output` (~2000 tokens). Use `highlights` (~100 tokens) when you need significant lines only. Use `output` only when actively debugging. + +**One-shot commands:** Use `async_bash` for builds, tests, and installs. The result is pushed to you when the command exits — no polling needed. Use `await_job` to block on a specific job. + +**Secrets:** Use `secure_env_collect`. Never ask the user to edit `.env` files or paste secrets. + +**Browser verification:** Verify frontend work against a running app. Discovery: `browser_find`/`browser_snapshot_refs`. Action: refs/selectors → `browser_batch` for obvious sequences. Verification: `browser_assert` for explicit pass/fail. Diagnostics: `browser_diff` for ambiguous outcomes → console/network logs when assertions fail → full page inspection as last resort. Debug in order: failing assertion → diff → diagnostics → element state → broader inspection. Retry only with a new hypothesis. + +### Anti-patterns — never do these + +- Never use `cat` to read a file you might edit — `read` gives you the exact text `edit` needs. +- Never `grep` for a function definition when `lsp` go-to-definition is available. +- Never poll a server with `sleep 1 && curl` loops — use `bg_shell` `wait_for_ready`. +- Never use `bg_shell` `output` for a status check — use `digest`. +- Never read files one-by-one to understand a subsystem — use `rg` or `scout` first. +- Never guess at library APIs from training data — use `get_library_docs`. +- Never ask the user to run a command, set a variable, or check something you can check yourself. ### Ask vs infer @@ -150,32 +170,15 @@ Verify according to task type: bug fix → rerun repro, script fix → rerun com For non-trivial work, verify both the feature and the failure/diagnostic surface. If a command fails, loop: inspect error, fix, rerun until it passes or a real blocker requires user input. +Work is not done when the code compiles. Work is done when the verification passes. + ### Agent-First Observability For relevant work: add health/status surfaces, persist failure state (last error, phase, timestamp, retry count), verify both happy path and at least one diagnostic signal. Never log secrets. Remove noisy one-off instrumentation before finishing unless it provides durable diagnostic value. ### Root-cause-first debugging -Fix the root cause, not symptoms. When applying a temporary mitigation, label it clearly and preserve the path to the real fix. - -## Situational Playbooks - -### Background processes - -Use `bg_shell` for persistent processes — servers, watchers, anything that keeps running. Set `type:'server'` + `ready_port` for dev servers, `group:'name'` for related processes. Use `wait_for_ready` instead of polling. Use `digest` for status checks, `highlights` for significant output, `output` only when debugging. Use `send_and_wait` for interactive CLIs. Kill processes when done. - -Use `async_bash` for one-shot commands (builds, tests, installs) where you want the output delivered back automatically. Result arrives as a follow-up message when the command exits — no polling needed. Use `await_job` to explicitly wait for a specific job, `cancel_job` to stop one, `/jobs` to see what's running. - -### Web behavior - -Verify frontend work with browser tools against a running app. Operating order: `browser_find`/`browser_snapshot_refs` for discovery → refs/selectors for targeting → `browser_batch` for obvious sequences → `browser_assert` for verification → `browser_diff` for ambiguous outcomes → console/network logs when assertions fail → full page inspection as last resort. - -Debug browser failures in order: failing assertion → `browser_diff` → console/network diagnostics → element/accessibility state → broader inspection. Retry only with a new hypothesis. - -### Libraries and current facts - -- Libraries: `resolve_library` → `get_library_docs` with specific topic query. Start with `tokens=5000`. -- Current facts: `search-the-web` to evaluate the landscape and pick URLs, or `search_and_read` when you know what you're looking for. Use `freshness` for recency, `domain` to scope to a specific site. +Fix the root cause, not symptoms. When applying a temporary mitigation, label it clearly and preserve the path to the real fix. Never add a guard or try/catch to suppress an error you haven't diagnosed. ## Communication diff --git a/src/resources/extensions/gsd/tests/git-self-heal.test.ts b/src/resources/extensions/gsd/tests/git-self-heal.test.ts new file mode 100644 index 000000000..942787d28 --- /dev/null +++ b/src/resources/extensions/gsd/tests/git-self-heal.test.ts @@ -0,0 +1,234 @@ +/** + * git-self-heal.test.ts — Integration tests for git self-healing utilities. + * + * Uses real temporary git repos with deliberately broken state. + * No mocks — exercises actual git operations. + */ + +import { execSync } from "node:child_process"; +import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rmSync } from "node:fs"; +import assert from "node:assert/strict"; +import { + abortAndReset, + withMergeHeal, + recoverCheckout, + formatGitError, + MergeConflictError, +} from "../git-self-heal.js"; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function makeTempRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "gsd-self-heal-")); + execSync("git init", { cwd: dir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "README.md"), "# init\n"); + execSync("git add -A && git commit -m 'init'", { cwd: dir, stdio: "pipe" }); + return dir; +} + +function cleanup(dir: string) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +// ─── abortAndReset ─────────────────────────────────────────────────── + +console.log("── abortAndReset ──"); + +// Test: leftover MERGE_HEAD +{ + const dir = makeTempRepo(); + try { + // Create a conflicting branch + execSync("git checkout -b feature", { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "file.txt"), "feature content\n"); + execSync("git add -A && git commit -m 'feature'", { cwd: dir, stdio: "pipe" }); + execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "file.txt"), "main content\n"); + execSync("git add -A && git commit -m 'main change'", { cwd: dir, stdio: "pipe" }); + + // Create a merge conflict → MERGE_HEAD will exist + try { + execSync("git merge feature", { cwd: dir, stdio: "pipe" }); + } catch { + // expected conflict + } + + assert.ok(existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should exist before abort"); + + const result = abortAndReset(dir); + assert.ok(result.cleaned.some((s) => s.includes("aborted merge")), "should report aborted merge"); + assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should be gone after abort"); + + console.log(" ✓ cleans up leftover MERGE_HEAD"); + } finally { + cleanup(dir); + } +} + +// Test: leftover SQUASH_MSG (no MERGE_HEAD) +{ + const dir = makeTempRepo(); + try { + // Manually create a SQUASH_MSG to simulate leftover state + writeFileSync(join(dir, ".git", "SQUASH_MSG"), "leftover squash message\n"); + + const result = abortAndReset(dir); + assert.ok(result.cleaned.some((s) => s.includes("SQUASH_MSG")), "should report SQUASH_MSG removal"); + assert.ok(!existsSync(join(dir, ".git", "SQUASH_MSG")), "SQUASH_MSG should be gone"); + + console.log(" ✓ cleans up leftover SQUASH_MSG"); + } finally { + cleanup(dir); + } +} + +// Test: clean state (no-op) +{ + const dir = makeTempRepo(); + try { + const result = abortAndReset(dir); + assert.deepStrictEqual(result.cleaned, [], "clean repo should produce empty cleaned array"); + + console.log(" ✓ no-op on clean state"); + } finally { + cleanup(dir); + } +} + +// ─── withMergeHeal ─────────────────────────────────────────────────── + +console.log("── withMergeHeal ──"); + +// Test: transient failure succeeds on retry +{ + const dir = makeTempRepo(); + try { + let callCount = 0; + const result = withMergeHeal(dir, () => { + callCount++; + if (callCount === 1) throw new Error("transient git error"); + return "success"; + }); + + assert.strictEqual(result, "success", "should return mergeFn result on retry"); + assert.strictEqual(callCount, 2, "should have called mergeFn twice"); + + console.log(" ✓ transient failure succeeds on retry"); + } finally { + cleanup(dir); + } +} + +// Test: real conflict escalates immediately (no retry) +{ + const dir = makeTempRepo(); + try { + // Set up a real merge conflict + execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "conflict.txt"), "branch A\n"); + execSync("git add -A && git commit -m 'branch A'", { cwd: dir, stdio: "pipe" }); + execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" }); + writeFileSync(join(dir, "conflict.txt"), "branch B\n"); + execSync("git add -A && git commit -m 'branch B'", { cwd: dir, stdio: "pipe" }); + + let callCount = 0; + try { + withMergeHeal(dir, () => { + callCount++; + // Actually perform the conflicting merge + execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" }); + }); + assert.fail("should have thrown MergeConflictError"); + } catch (err) { + assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`); + assert.strictEqual(callCount, 1, "should NOT retry on real conflict"); + } + + console.log(" ✓ real conflict escalates immediately without retry"); + } finally { + cleanup(dir); + } +} + +// ─── recoverCheckout ───────────────────────────────────────────────── + +console.log("── recoverCheckout ──"); + +// Test: dirty index recovery +{ + const dir = makeTempRepo(); + try { + // Create a branch to checkout to + execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" }); + execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" }); + + // Dirty the index + writeFileSync(join(dir, "README.md"), "dirty changes\n"); + execSync("git add README.md", { cwd: dir, stdio: "pipe" }); + + // Normal checkout would complain about dirty index + recoverCheckout(dir, "target-branch"); + + const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim(); + assert.strictEqual(branch, "target-branch", "should be on target branch after recovery"); + + console.log(" ✓ recovers checkout with dirty index"); + } finally { + cleanup(dir); + } +} + +// Test: non-existent branch throws with context +{ + const dir = makeTempRepo(); + try { + try { + recoverCheckout(dir, "nonexistent-branch"); + assert.fail("should have thrown"); + } catch (err) { + assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error"); + assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name"); + } + + console.log(" ✓ throws with context for non-existent branch"); + } finally { + cleanup(dir); + } +} + +// ─── formatGitError ────────────────────────────────────────────────── + +console.log("── formatGitError ──"); + +{ + const cases: Array<{ input: string; shouldContain: string; label: string }> = [ + { input: "CONFLICT (content): Merge conflict in file.ts", shouldContain: "/gsd doctor", label: "merge conflict" }, + { input: "error: pathspec 'foo' did not match any file(s)", shouldContain: "/gsd doctor", label: "checkout failure" }, + { input: "HEAD detached at abc123", shouldContain: "/gsd doctor", label: "detached HEAD" }, + { input: "Unable to create '/path/.git/index.lock': File exists", shouldContain: "/gsd doctor", label: "lock file" }, + { input: "fatal: not a git repository", shouldContain: "/gsd doctor", label: "not a repo" }, + { input: "some unknown error", shouldContain: "/gsd doctor", label: "unknown error" }, + ]; + + for (const { input, shouldContain, label } of cases) { + const result = formatGitError(input); + assert.ok(result.includes(shouldContain), `${label}: should suggest /gsd doctor`); + console.log(` ✓ ${label} → suggests /gsd doctor`); + } + + // Test with Error object + const result = formatGitError(new Error("CONFLICT in merge")); + assert.ok(result.includes("/gsd doctor"), "should handle Error objects"); + console.log(" ✓ handles Error objects"); +} + +console.log("\n✅ All git-self-heal tests passed");