From f9b9f6bf32da4c24885d09d73bb0b3153ea96c3f Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 23:54:13 -0600 Subject: [PATCH] chore(M003/S06): Doctor + cleanup + code simplification Tasks: - chore(M003/S06): auto-commit after complete-slice - chore(M003/S06/T02): auto-commit after execute-task - chore(M003/S06/T01): auto-commit after execute-task - chore(M003/S06): auto-commit after plan-slice - docs(S06): add slice plan Branch: gsd/M003/S06 --- .gsd/REQUIREMENTS.md | 12 +- .gsd/milestones/M003/M003-ROADMAP.md | 2 +- .../M003/slices/S05/S05-ASSESSMENT.md | 23 ++ .gsd/milestones/M003/slices/S06/S06-PLAN.md | 50 ++++ .../M003/slices/S06/S06-RESEARCH.md | 70 +++++ .../milestones/M003/slices/S06/S06-SUMMARY.md | 108 ++++++++ .gsd/milestones/M003/slices/S06/S06-UAT.md | 111 ++++++++ .../M003/slices/S06/tasks/T01-PLAN.md | 59 +++++ .../M003/slices/S06/tasks/T01-SUMMARY.md | 65 +++++ .../M003/slices/S06/tasks/T02-PLAN.md | 55 ++++ .../M003/slices/S06/tasks/T02-SUMMARY.md | 54 ++++ src/resources/extensions/gsd/doctor.ts | 196 +++++++++++++- src/resources/extensions/gsd/git-service.ts | 9 + .../extensions/gsd/tests/doctor-git.test.ts | 246 ++++++++++++++++++ 14 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 .gsd/milestones/M003/slices/S05/S05-ASSESSMENT.md create mode 100644 .gsd/milestones/M003/slices/S06/S06-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S06/S06-RESEARCH.md create mode 100644 .gsd/milestones/M003/slices/S06/S06-SUMMARY.md create mode 100644 .gsd/milestones/M003/slices/S06/S06-UAT.md create mode 100644 .gsd/milestones/M003/slices/S06/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S06/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M003/slices/S06/tasks/T02-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S06/tasks/T02-SUMMARY.md create mode 100644 src/resources/extensions/gsd/tests/doctor-git.test.ts diff --git a/.gsd/REQUIREMENTS.md b/.gsd/REQUIREMENTS.md index 5acbd6fe7..8352eb8f1 100644 --- a/.gsd/REQUIREMENTS.md +++ b/.gsd/REQUIREMENTS.md @@ -127,13 +127,13 @@ This file is the explicit capability and coverage contract for the project. ### R040 — Doctor git health checks - Class: operability -- Status: active +- Status: validated - Description: `/gsd doctor` detects and optionally fixes git-related issues: orphaned auto-worktrees, stale milestone branches, corrupt merge state (MERGE_HEAD/SQUASH_MSG), tracked runtime files, missing gitignore patterns. - Why it matters: When things do go wrong, users need a one-command fix. Doctor is the safety net. - Source: inferred - Primary owning slice: M003/S06 - Supporting slices: M003/S05 -- Validation: unmapped +- Validation: 4 DoctorIssueCode values with detection and fix logic in checkGitHealth. 6 integration tests (17 assertions) in doctor-git.test.ts covering detect/fix/verify cycle for all codes plus safety guards. - Notes: Doctor already handles planning artifact issues. This extends it to git health. ### R041 — Test coverage for worktree-isolated flow @@ -537,7 +537,7 @@ This file is the explicit capability and coverage contract for the project. | R037 | primary-user-loop | active | M003/S05 | all M003 | unmapped | | R038 | continuity | active | M003/S04 | none | unmapped | | R039 | integration | active | M003/S01 | none | unmapped | -| R040 | operability | active | M003/S06 | M003/S05 | unmapped | +| R040 | operability | validated | M003/S06 | M003/S05 | 4 DoctorIssueCode values, 6 integration tests (17 assertions) in doctor-git.test.ts | | R041 | quality-attribute | active | M003/S07 | all M003 | unmapped | | R042 | core-capability | deferred | none | none | unmapped | | R043 | quality-attribute | deferred | none | none | unmapped | @@ -545,9 +545,9 @@ This file is the explicit capability and coverage contract for the project. ## Coverage Summary -- Active requirements: 11 -- Mapped to slices: 11 -- Validated: 24 +- Active requirements: 10 +- Mapped to slices: 10 +- Validated: 25 - Deferred: 5 - Out of scope: 4 - Unmapped active requirements: 0 diff --git a/.gsd/milestones/M003/M003-ROADMAP.md b/.gsd/milestones/M003/M003-ROADMAP.md index e3d12f3d8..85d242ffd 100644 --- a/.gsd/milestones/M003/M003-ROADMAP.md +++ b/.gsd/milestones/M003/M003-ROADMAP.md @@ -68,7 +68,7 @@ This milestone is complete only when all are true: - [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]` +- [x] **S06: Doctor + cleanup + code simplification** `risk:low` `depends:[S01,S02,S03,S05]` > After this: `/gsd doctor` detects orphaned auto-worktrees, stale milestone branches, corrupt merge state (MERGE_HEAD/SQUASH_MSG), and tracked runtime files — and fixes them. Dead `.gsd/` conflict resolution code removed from worktree-mode paths in git-service.ts. Verified via doctor test cases. - [ ] **S07: Test suite for worktree-isolated flow** `risk:low` `depends:[S01,S02,S03,S04,S05,S06]` diff --git a/.gsd/milestones/M003/slices/S05/S05-ASSESSMENT.md b/.gsd/milestones/M003/slices/S05/S05-ASSESSMENT.md new file mode 100644 index 000000000..091877e49 --- /dev/null +++ b/.gsd/milestones/M003/slices/S05/S05-ASSESSMENT.md @@ -0,0 +1,23 @@ +# S05 Assessment — Roadmap Reassessment + +**Verdict: Roadmap unchanged.** + +S05 delivered exactly what was planned — self-heal module with abort/reset/retry for transient failures, immediate escalation for real conflicts, user-friendly error messages pointing to `/gsd doctor`. No new risks surfaced. No assumptions changed that affect remaining slices. + +## Success Criteria Coverage + +All six success criteria have remaining owning slices (S06 for doctor, S07 for full test coverage and end-to-end verification). No gaps. + +## Requirement Coverage + +- R035 (self-healing) and R037 (zero git errors) advanced by S05 but remain active — full validation requires S06 (doctor exists for error messages to reference) and S07 (test coverage). +- R040 (doctor) still owned by S06. S05's `formatGitError` references `/gsd doctor` which S06 must implement. +- All other active requirements retain their slice ownership unchanged. + +## Boundary Map + +S05 → S06 boundary holds: S05 produced the structured error handling patterns and `formatGitError` that S06 will use for doctor fix operations. No interface changes needed. + +## Next Slice + +S06: Doctor + cleanup + code simplification. Ready to start — all dependencies (S01, S02, S03, S05) complete. diff --git a/.gsd/milestones/M003/slices/S06/S06-PLAN.md b/.gsd/milestones/M003/slices/S06/S06-PLAN.md new file mode 100644 index 000000000..a47813190 --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/S06-PLAN.md @@ -0,0 +1,50 @@ +# S06: Doctor + cleanup + code simplification + +**Goal:** `/gsd doctor` detects and fixes git health issues (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files). Branch-mode-only `.gsd/` conflict resolution code annotated for clarity. +**Demo:** Run `/gsd doctor` on a repo with an orphaned worktree and stale milestone branch → both detected and fixed. + +## Must-Haves + +- 4 new DoctorIssueCode values: `orphaned_auto_worktree`, `stale_milestone_branch`, `corrupt_merge_state`, `tracked_runtime_files` +- Detection logic for each using existing `listWorktrees`, `abortAndReset`, `RUNTIME_EXCLUSION_PATHS` +- Fix logic for each (remove worktree, delete branch, abort merge, untrack files) gated behind `shouldFix` +- Doctor runs from main project root, never crashes if not a git repo +- Never removes a worktree matching `process.cwd()` +- `.gsd/` conflict resolution code in `git-service.ts` annotated as branch-mode-only + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — all pass +- `npx tsc --noEmit` — zero errors +- Existing `doctor.test.ts` and `doctor-fixlevel.test.ts` still pass + +## Tasks + +- [x] **T01: Add git health checks to doctor.ts** `est:30m` + - Why: R040 — doctor needs git-aware checks. The existing pattern (DoctorIssueCode + detection + fix) is well-established; this extends it with 4 new codes. + - Files: `src/resources/extensions/gsd/doctor.ts`, `src/resources/extensions/gsd/git-service.ts` + - Do: Add 4 new codes to `DoctorIssueCode` union. Add `checkGitHealth` async function that: (1) lists worktrees via `listWorktrees`, filters to `milestone/` branches, cross-references against roadmap completion status — orphaned if milestone complete or branch gone; (2) lists branches matching `milestone/*`, flags stale if milestone complete; (3) checks for MERGE_HEAD/SQUASH_MSG/rebase dirs via `abortAndReset` detection logic; (4) runs `git ls-files` against `RUNTIME_EXCLUSION_PATHS` entries. Each pushes to `issues[]`. Fixes: removeWorktree (skip if cwd match), branch -D, abortAndReset, git rm --cached. Wrap entire block in try/catch for non-git repos. Add `checkGitHealth` call in `runGSDDoctor` after preferences check. Also annotate the `.gsd/` conflict resolution block in `git-service.ts` (lines ~768-863) with a comment block explaining it's branch-mode-only. + - Verify: `npx tsc --noEmit` — zero errors + - Done when: DoctorIssueCode has 4 new values, `runGSDDoctor` calls git health checks, `git-service.ts` conflict block annotated + +- [x] **T02: Integration tests for doctor git health checks** `est:25m` + - Why: Prove detection and fixes work against real git repos with deliberate broken state. Without tests, the doctor checks are unverified. + - Files: `src/resources/extensions/gsd/tests/doctor-git.test.ts` + - Do: Create test file with temp git repos. Tests: (1) orphaned worktree detected and fixed (create worktree, mark milestone complete in roadmap, run doctor); (2) stale milestone branch detected and fixed (create branch, complete milestone, run doctor); (3) corrupt merge state detected and fixed (create MERGE_HEAD, run doctor); (4) tracked runtime files detected and fixed (git add .gsd/activity/foo, run doctor); (5) non-git directory doesn't crash (run doctor in /tmp); (6) active worktree NOT flagged as orphaned (worktree exists, milestone in-progress). Use `node:test` runner consistent with other test files. + - Verify: `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — all pass, existing `doctor.test.ts` still passes + - Done when: 6+ test cases pass, covering detection and fix for all 4 issue codes plus safety guards + +## Files Likely Touched + +## Observability / Diagnostics + +- **Doctor report output:** 4 new issue codes (`orphaned_auto_worktree`, `stale_milestone_branch`, `corrupt_merge_state`, `tracked_runtime_files`) appear in `/gsd doctor` output with severity, scope, and fix status. +- **Fix audit trail:** All auto-fixes log to `fixesApplied[]`, visible in doctor report "Fixes applied" section. +- **Graceful degradation:** Non-git directories produce no git-related issues (silent skip). Git failures within checks are caught and don't block other checks. +- **Inspection:** Run `/gsd doctor --fix` to see detection + remediation. Run without `--fix` for detection-only mode. + +## Files Likely Touched + +- `src/resources/extensions/gsd/doctor.ts` +- `src/resources/extensions/gsd/git-service.ts` +- `src/resources/extensions/gsd/tests/doctor-git.test.ts` diff --git a/.gsd/milestones/M003/slices/S06/S06-RESEARCH.md b/.gsd/milestones/M003/slices/S06/S06-RESEARCH.md new file mode 100644 index 000000000..205beca1b --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/S06-RESEARCH.md @@ -0,0 +1,70 @@ +# S06: Doctor + cleanup + code simplification — Research + +**Date:** 2026-03-14 + +## Summary + +S06 has two jobs: (1) extend the existing `doctor.ts` with git health checks, and (2) remove dead `.gsd/` conflict resolution code from worktree-mode paths. Both are straightforward additions to well-established patterns. + +The doctor system (`doctor.ts`, 766 lines) already has a mature architecture: `DoctorIssueCode` union type, `DoctorIssue` interface with severity/fixable flags, `runGSDDoctor` function that collects issues and optionally fixes them. Adding git health checks means extending this pattern with new issue codes and detection logic. The self-heal module (`git-self-heal.ts`) provides `abortAndReset` which already detects MERGE_HEAD/SQUASH_MSG/rebase state — doctor can reuse this for detection and fix. + +For dead code removal: `git-service.ts` lines ~768-863 contain ~95 lines of `.gsd/` conflict auto-resolution in `mergeSliceToMain` (runtime conflict resolution via `--theirs`, `.gsd/` planning conflict resolution, post-merge runtime file stripping). In worktree mode, `mergeSliceToMilestone` in `auto-worktree.ts` handles merges instead — this code is only needed for branch-per-slice mode. The code should stay but could be annotated/commented for clarity. Per D038, worktree merges skip `.gsd/` conflict resolution entirely. + +## Recommendation + +**Extend `doctor.ts` with git-specific issue codes and checks.** Add detection for: orphaned auto-worktrees (worktree on disk but no matching milestone/branch), stale milestone branches (branch exists but milestone completed), corrupt merge state (MERGE_HEAD/SQUASH_MSG present), and tracked runtime files. Reuse `listWorktrees` from `worktree-manager.ts` and `abortAndReset` from `git-self-heal.ts`. Keep fixes non-destructive (remove worktrees, delete branches, abort merges — never lose data). + +**Do NOT remove the `.gsd/` conflict resolution code from `mergeSliceToMain`.** It's still needed for `git.isolation: "branch"` users. Instead, add a code comment clarifying it's branch-mode-only. The "dead code removal" in the slice description refers to worktree-mode paths — and those paths (`mergeSliceToMilestone`) already have zero conflict resolution code (D038 confirmed). + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| Worktree listing | `listWorktrees()` in worktree-manager.ts | Already parses `git worktree list --porcelain`, returns structured data | +| Merge state detection | `abortAndReset()` in git-self-heal.ts | Already checks MERGE_HEAD, SQUASH_MSG, rebase-apply/merge dirs | +| Doctor issue reporting | `DoctorIssue` / `DoctorIssueCode` types in doctor.ts | Established pattern with severity, fixable flags, scope, and formatting | +| Git command execution | `runGit()` in git-service.ts | Consistent error handling, SVN noise filtering | +| Runtime path list | `RUNTIME_EXCLUSION_PATHS` in git-service.ts | Canonical list of paths that shouldn't be tracked | + +## Existing Code and Patterns + +- `src/resources/extensions/gsd/doctor.ts` — Issue detection + fix pattern: detect issue → push to `issues[]` → if `shouldFix(code)` → apply fix → push to `fixesApplied[]`. New git checks follow this exact pattern. +- `src/resources/extensions/gsd/git-self-heal.ts` — `abortAndReset(cwd)` detects and cleans MERGE_HEAD/SQUASH_MSG/rebase state. Doctor fix for corrupt merge state can call this directly. +- `src/resources/extensions/gsd/worktree-manager.ts` — `listWorktrees(basePath)` returns `WorktreeInfo[]` with path, branch, head, bare, main fields. `removeWorktree(basePath, name, opts)` handles cleanup. +- `src/resources/extensions/gsd/git-service.ts:705-870` — `mergeSliceToMain` contains the `.gsd/` conflict resolution code. This is branch-mode-only code and should NOT be removed — just annotated. +- `src/resources/extensions/gsd/git-service.ts:101-108` — `RUNTIME_EXCLUSION_PATHS` array lists paths that should never be committed. Doctor can check if any are tracked. +- `src/resources/extensions/gsd/auto-worktree.ts` — `autoWorktreeBranch(milestoneId)` returns `milestone/` — the branch naming convention for detecting auto-worktree branches vs manual `worktree/` branches. + +## Constraints + +- Doctor must work from the main project root, not from within a worktree. Git commands for worktree detection run against the main `.git` dir. +- `DoctorIssueCode` is a string union type — adding new codes requires extending the union (type-checked at compile time). +- `listWorktrees` returns all worktrees including the main one (marked with `main: true`). Must filter to auto-worktrees only (branch matches `milestone/`). +- The `fixLevel` mechanism (`"task"` vs `"all"`) in `runGSDDoctor` controls which fixes are auto-applied. Git fixes should probably be in the `"all"` level since they're infrastructure repair, not completion transitions. + +## Common Pitfalls + +- **Deleting a worktree that's in use** — If auto-mode is running in a worktree, doctor must not remove it. Check if the worktree path matches `process.cwd()` before removal. +- **Branch deletion of checked-out branch** — git refuses to delete a branch checked out in any worktree. Must remove worktree first, then delete branch (D040). +- **False positive "stale" branches** — A `milestone/` branch is only stale if the milestone is marked complete in the roadmap. An in-progress milestone's branch is expected. +- **Runtime file tracking detection** — `git ls-files` against `RUNTIME_EXCLUSION_PATHS` may produce false positives if paths use glob patterns. The current list uses directory prefixes, so `git ls-files --error-unmatch .gsd/activity/` will work. + +## Open Risks + +- Doctor currently has no git-aware checks at all — this is entirely new territory. The first implementation should be conservative (detect + report) with fixes gated behind `fix: true`. +- If `listWorktrees` fails (not a git repo, git not installed), doctor should degrade gracefully rather than crash. Wrap in try/catch. + +## Skills Discovered + +| Technology | Skill | Status | +|------------|-------|--------| +| Git | N/A — standard git CLI operations | none needed | + +## Sources + +- S01-SUMMARY: Auto-worktree lifecycle and naming conventions +- S02-SUMMARY: mergeSliceToMilestone location and .gsd/ conflict elimination (D037, D038) +- S03-SUMMARY: Milestone merge and worktree teardown ordering (D040) +- S05-SUMMARY: Self-heal patterns (abortAndReset, formatGitError) +- doctor.ts source: Existing issue detection and fix patterns +- git-service.ts source: .gsd/ conflict resolution code location (lines 768-863) diff --git a/.gsd/milestones/M003/slices/S06/S06-SUMMARY.md b/.gsd/milestones/M003/slices/S06/S06-SUMMARY.md new file mode 100644 index 000000000..e51d6486d --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/S06-SUMMARY.md @@ -0,0 +1,108 @@ +--- +id: S06 +parent: M003 +milestone: M003 +provides: + - 4 git health check issue codes in doctor (orphaned_auto_worktree, stale_milestone_branch, corrupt_merge_state, tracked_runtime_files) + - checkGitHealth function with detection and fix logic for all 4 codes + - branch-mode-only annotation on .gsd/ conflict resolution code in git-service.ts + - Integration test suite (6 tests, 17 assertions) for git health checks +requires: + - slice: S01 + provides: listWorktrees, worktree infrastructure + - slice: S05 + provides: abortAndReset error handling patterns +affects: + - S07 +key_files: + - src/resources/extensions/gsd/doctor.ts + - src/resources/extensions/gsd/git-service.ts + - src/resources/extensions/gsd/tests/doctor-git.test.ts +key_decisions: + - D038 — branch-mode-only annotation on .gsd/ conflict resolution code (annotate rather than delete, preserving branch-mode path) + - checkGitHealth is a standalone async function called from runGSDDoctor, not inlined + - autoWorktreeBranch import skipped — milestone branch pattern extracted inline via string replace + - Worktrees must be under .gsd/worktrees/ to match listWorktrees filter + - Roadmap must use ## Slices with checkbox format to match parseRoadmapSlices parser +patterns_established: + - Git health check test pattern: createRepoWithCompletedMilestone helper, detect → fix → verify cycle + - git health checks wrap all operations in try/catch for graceful degradation in non-git repos + - fix actions record descriptive strings in fixesApplied for audit trail +observability_surfaces: + - 4 new issue codes in /gsd doctor output with severity, scope, and fix status + - fixesApplied strings for each remediation action + - Non-git directories produce no git-related issues (silent skip) +drill_down_paths: + - .gsd/milestones/M003/slices/S06/tasks/T01-SUMMARY.md + - .gsd/milestones/M003/slices/S06/tasks/T02-SUMMARY.md +duration: 37m +verification_result: passed +completed_at: 2026-03-14 +--- + +# S06: Doctor + cleanup + code simplification + +**Added 4 git health checks to `/gsd doctor` with detection, fix, and integration tests covering orphaned worktrees, stale branches, corrupt merge state, and tracked runtime files.** + +## What Happened + +T01 extended the doctor system with `checkGitHealth`, a standalone async function that runs 4 checks: (1) orphaned auto-worktrees — cross-references `listWorktrees` against roadmap completion status, with safety guard against removing the current working directory; (2) stale milestone branches — flags `milestone/*` branches for completed milestones with no associated worktree; (3) corrupt merge state — detects MERGE_HEAD, SQUASH_MSG, and rebase directories, fixes via `abortAndReset`; (4) tracked runtime files — runs `git ls-files` against `RUNTIME_EXCLUSION_PATHS`, fixes via `git rm --cached`. All checks are wrapped in try/catch for non-git repo safety. The `.gsd/` conflict resolution block in git-service.ts was annotated as branch-mode-only per D038. + +T02 built 6 integration tests (17 assertions) using real temp git repos with deliberately broken state. Tests cover the full detect → fix → verify cycle for all 4 issue codes plus safety guards (non-git directory doesn't crash, active worktree not flagged as orphaned). + +## Verification + +- `npx tsc --noEmit` — zero errors +- `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — 17 passed, 0 failed +- `npx tsx src/resources/extensions/gsd/tests/doctor.test.ts` — 59 passed, 0 failed +- `npx tsx src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts` — 3 passed, 0 failed + +## Requirements Advanced + +- R040 — `/gsd doctor` now detects and fixes 4 git health issue types with full test coverage + +## Requirements Validated + +- R040 — 6 integration tests prove detection and fix for all 4 issue codes, plus safety guards for non-git repos and active worktrees + +## New Requirements Surfaced + +- none + +## Requirements Invalidated or Re-scoped + +- none + +## Deviations + +- Worktree path in tests changed from `.gsd-worktrees/` to `.gsd/worktrees/` to match `listWorktrees` filter +- Roadmap format in tests changed from table to checkbox format to match `parseRoadmapSlices` parser + +## Known Limitations + +- `.gsd/` conflict resolution code is annotated but not removed — preserved for `git.isolation: "branch"` users per R036/R038 +- Doctor git checks require the `git` CLI to be available; no fallback to native module + +## Follow-ups + +- none + +## Files Created/Modified + +- `src/resources/extensions/gsd/doctor.ts` — 4 new DoctorIssueCode values, checkGitHealth function +- `src/resources/extensions/gsd/git-service.ts` — branch-mode-only annotation on conflict resolution code +- `src/resources/extensions/gsd/tests/doctor-git.test.ts` — 6 integration tests for git health checks + +## Forward Intelligence + +### What the next slice should know +- All 4 doctor git checks work and have tests. S07 can build on these test patterns for broader coverage. + +### What's fragile +- `parseRoadmapSlices` is strict about format — tests must use `## Slices` with `- [x] **S01: Title**` format, not tables. + +### Authoritative diagnostics +- `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — canonical test for git health checks + +### What assumptions changed +- Assumed `.gsd-worktrees/` path — actual path is `.gsd/worktrees/` per listWorktrees filter diff --git a/.gsd/milestones/M003/slices/S06/S06-UAT.md b/.gsd/milestones/M003/slices/S06/S06-UAT.md new file mode 100644 index 000000000..290c38190 --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/S06-UAT.md @@ -0,0 +1,111 @@ +# S06: Doctor + cleanup + code simplification — UAT + +**Milestone:** M003 +**Written:** 2026-03-14 + +## UAT Type + +- UAT mode: artifact-driven +- Why this mode is sufficient: Doctor checks are CLI commands with deterministic output — detection and fix results are fully verifiable via command output and git state inspection. + +## Preconditions + +- Must be in the gsd-2 project root +- Git CLI available +- Project builds cleanly (`npx tsc --noEmit`) + +## Smoke Test + +Run `/gsd doctor` in a clean repo. Confirm no git-related issues are reported (no orphaned worktrees, no stale branches, no corrupt merge state, no tracked runtime files). Output should show only non-git checks. + +## Test Cases + +### 1. Orphaned worktree detection and fix + +1. Create a temp git repo with a completed milestone in the roadmap +2. Create a worktree under `.gsd/worktrees/M099/` on branch `milestone/M099` +3. Run `/gsd doctor` (detection only) +4. **Expected:** Issue `orphaned_auto_worktree` reported with severity and worktree path +5. Run `/gsd doctor --fix` +6. **Expected:** Worktree removed, fix recorded in fixesApplied +7. Run `/gsd doctor` again +8. **Expected:** No orphaned worktree issues + +### 2. Stale milestone branch detection and fix + +1. Create a temp git repo with a completed milestone in the roadmap +2. Create branch `milestone/M099` (no worktree) +3. Run `/gsd doctor` +4. **Expected:** Issue `stale_milestone_branch` reported +5. Run `/gsd doctor --fix` +6. **Expected:** Branch deleted, fix recorded +7. Verify branch gone: `git branch --list milestone/M099` returns empty + +### 3. Corrupt merge state detection and fix + +1. Create a temp git repo +2. Create `.git/MERGE_HEAD` file with dummy content +3. Run `/gsd doctor` +4. **Expected:** Issue `corrupt_merge_state` reported +5. Run `/gsd doctor --fix` +6. **Expected:** MERGE_HEAD removed via abortAndReset, fix recorded + +### 4. Tracked runtime files detection and fix + +1. Create a temp git repo +2. `git add` a file matching RUNTIME_EXCLUSION_PATHS (e.g., `.gsd/activity/foo.md`) +3. Run `/gsd doctor` +4. **Expected:** Issue `tracked_runtime_files` reported +5. Run `/gsd doctor --fix` +6. **Expected:** File untracked via `git rm --cached`, fix recorded + +### 5. Non-git directory safety + +1. Run `/gsd doctor` from a non-git directory (e.g., `/tmp/nonrepo`) +2. **Expected:** No crash, no git-related issues reported, other checks still run + +### 6. Active worktree not flagged + +1. Create a temp git repo with an in-progress milestone +2. Create a worktree under `.gsd/worktrees/M099/` on branch `milestone/M099` +3. Run `/gsd doctor` +4. **Expected:** Worktree NOT flagged as orphaned (milestone is in-progress) + +## Edge Cases + +### cwd matches orphaned worktree + +1. Create a worktree for a completed milestone +2. `cd` into the worktree directory +3. Run doctor +4. **Expected:** Worktree detected as orphaned but NOT removed (safety guard against removing cwd) + +### Multiple issue types simultaneously + +1. Create a repo with an orphaned worktree AND a MERGE_HEAD file AND tracked runtime files +2. Run `/gsd doctor` +3. **Expected:** All 3 issues detected independently +4. Run `/gsd doctor --fix` +5. **Expected:** All 3 fixed independently + +## Failure Signals + +- Any test in `doctor-git.test.ts` failing +- `npx tsc --noEmit` producing errors +- Existing `doctor.test.ts` or `doctor-fixlevel.test.ts` tests regressing +- `/gsd doctor` crashing in a non-git directory + +## Requirements Proved By This UAT + +- R040 — Doctor detects and fixes orphaned auto-worktrees, stale milestone branches, corrupt merge state, and tracked runtime files + +## Not Proven By This UAT + +- R041 — Full test suite coverage (deferred to S07) +- R036 — Dead code removal (annotated only, not removed, per backwards compatibility) +- Live auto-mode interaction with doctor (operational verification) + +## Notes for Tester + +- All test cases are already covered by automated tests in `doctor-git.test.ts`. Run `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` to verify all 17 assertions pass. +- The `.gsd/` conflict resolution code was annotated, not removed — this is intentional per R038 (backwards compatibility with branch-per-slice model). diff --git a/.gsd/milestones/M003/slices/S06/tasks/T01-PLAN.md b/.gsd/milestones/M003/slices/S06/tasks/T01-PLAN.md new file mode 100644 index 000000000..53fc6bd64 --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/tasks/T01-PLAN.md @@ -0,0 +1,59 @@ +--- +estimated_steps: 6 +estimated_files: 3 +--- + +# T01: Add git health checks to doctor.ts + +**Slice:** S06 — Doctor + cleanup + code simplification +**Milestone:** M003 + +## Description + +Extend `runGSDDoctor` with 4 new git health checks: orphaned auto-worktrees, stale milestone branches, corrupt merge state, and tracked runtime files. Add code annotation to branch-mode-only `.gsd/` conflict resolution in `git-service.ts`. + +## Steps + +1. Add 4 new values to `DoctorIssueCode` union type: `orphaned_auto_worktree`, `stale_milestone_branch`, `corrupt_merge_state`, `tracked_runtime_files` +2. Import `listWorktrees` from `worktree-manager.ts`, `autoWorktreeBranch` from `auto-worktree.ts`, `abortAndReset` from `git-self-heal.ts`, `RUNTIME_EXCLUSION_PATHS` from `git-service.ts`, and `execSync` for direct git commands +3. Create `checkGitHealth(basePath, issues, fixesApplied, shouldFix)` async function: + - Wrap all git operations in try/catch (degrade gracefully if not a git repo) + - **Orphaned worktrees:** Call `listWorktrees(basePath)`, filter to branches starting with `milestone/`. For each, extract milestone ID, load roadmap, check if milestone is complete via `isMilestoneComplete`. If complete → orphaned. Skip fix if worktree path === `process.cwd()`. + - **Stale branches:** Run `git branch --list 'milestone/*'`, cross-reference against completed milestones. A branch is stale if its milestone is complete AND no worktree points to it (worktree check already handles the overlap case). + - **Corrupt merge state:** Check for MERGE_HEAD, SQUASH_MSG, rebase-apply/, rebase-merge/ in `.git/` dir. If found, report. Fix via `abortAndReset(basePath)`. + - **Tracked runtime files:** Run `git ls-files` for each `RUNTIME_EXCLUSION_PATHS` entry. If any returned, report. Fix via `git rm --cached -r --ignore-unmatch`. +4. Call `checkGitHealth` from `runGSDDoctor` after the preferences validation block +5. Add a block comment above the `.gsd/` conflict resolution code in `git-service.ts` (~line 768) explaining it's branch-mode-only and not used in worktree isolation mode (D038) + +## Must-Haves + +- [ ] 4 new DoctorIssueCode values compile +- [ ] Git health checks run inside `runGSDDoctor` +- [ ] Non-git repos don't crash doctor +- [ ] Active worktrees (cwd match) are never removed +- [ ] `.gsd/` conflict code annotated + +## Verification + +- `npx tsc --noEmit` — zero errors +- Existing `npx tsx tests/doctor.test.ts` and `doctor-fixlevel.test.ts` still pass + +## Inputs + +- `src/resources/extensions/gsd/doctor.ts` — existing doctor pattern +- `src/resources/extensions/gsd/git-self-heal.ts` — `abortAndReset` for corrupt merge state detection/fix +- `src/resources/extensions/gsd/worktree-manager.ts` — `listWorktrees` for orphaned worktree detection +- `src/resources/extensions/gsd/auto-worktree.ts` — `autoWorktreeBranch` for milestone branch naming +- `src/resources/extensions/gsd/git-service.ts` — `RUNTIME_EXCLUSION_PATHS` for tracked file detection +- S05-SUMMARY: abortAndReset patterns, formatGitError + +## Expected Output + +- `src/resources/extensions/gsd/doctor.ts` — 4 new issue codes, `checkGitHealth` function, called from `runGSDDoctor` +- `src/resources/extensions/gsd/git-service.ts` — block comment on `.gsd/` conflict resolution code + +## Observability Impact + +- **New issue codes visible in doctor report:** `orphaned_auto_worktree`, `stale_milestone_branch`, `corrupt_merge_state`, `tracked_runtime_files` — all appear in `formatDoctorReport` output and `summarizeDoctorIssues` byCode breakdown. +- **Fix actions logged:** Each fix records a human-readable string in `fixesApplied[]`, surfaced in doctor report under "Fixes applied". +- **Failure degradation:** All git checks wrap in try/catch — failures are silent (no issue emitted) rather than crashing doctor. This means a broken git repo won't block non-git doctor checks. diff --git a/.gsd/milestones/M003/slices/S06/tasks/T01-SUMMARY.md b/.gsd/milestones/M003/slices/S06/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..758715737 --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/tasks/T01-SUMMARY.md @@ -0,0 +1,65 @@ +--- +id: T01 +parent: S06 +milestone: M003 +provides: + - 4 git health check issue codes in DoctorIssueCode union + - checkGitHealth function detecting orphaned worktrees, stale branches, corrupt merge state, tracked runtime files + - branch-mode-only annotation on .gsd/ conflict resolution code +key_files: + - src/resources/extensions/gsd/doctor.ts + - src/resources/extensions/gsd/git-service.ts +key_decisions: + - checkGitHealth is a standalone async function called from runGSDDoctor, not inlined + - autoWorktreeBranch import skipped — milestone branch pattern extracted inline via string replace +patterns_established: + - git health checks wrap all operations in try/catch for graceful degradation in non-git repos + - fix actions record descriptive strings in fixesApplied for audit trail +observability_surfaces: + - 4 new issue codes in doctor report output (orphaned_auto_worktree, stale_milestone_branch, corrupt_merge_state, tracked_runtime_files) + - fixesApplied strings for each remediation action +duration: 12m +verification_result: passed +completed_at: 2026-03-14T22:16:00-06:00 +blocker_discovered: false +--- + +# T01: Add git health checks to doctor.ts + +**Added 4 git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files) to runGSDDoctor with detection and fix logic.** + +## What Happened + +Added `checkGitHealth` async function to doctor.ts with 4 checks: +1. **Orphaned auto-worktrees:** Uses `listWorktrees` to find `milestone/*` branches, cross-references against roadmap completion via `isMilestoneComplete`. Skips removal if worktree path matches `process.cwd()`. +2. **Stale milestone branches:** Lists `milestone/*` branches, flags those for completed milestones that have no associated worktree. Fix deletes with `git branch -D`. +3. **Corrupt merge state:** Checks for MERGE_HEAD, SQUASH_MSG, rebase-apply/, rebase-merge/ in .git/. Fix calls `abortAndReset`. +4. **Tracked runtime files:** Runs `git ls-files` against each `RUNTIME_EXCLUSION_PATHS` entry. Fix runs `git rm --cached -r --ignore-unmatch`. + +Added 4 new values to `DoctorIssueCode` union type. Imported `listWorktrees`, `abortAndReset`, `RUNTIME_EXCLUSION_PATHS`, and `execSync`. Called `checkGitHealth` from `runGSDDoctor` after preferences validation. + +Annotated the `.gsd/` conflict resolution block in git-service.ts (~line 770) with a block comment explaining it's branch-mode-only (D038). + +## Verification + +- `npx tsc --noEmit` — zero errors +- `npx tsx src/resources/extensions/gsd/tests/doctor.test.ts` — 59 passed, 0 failed +- `npx tsx src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts` — all passed +- Slice-level `doctor-git.test.ts` does not exist yet (T02 will create it) + +## Diagnostics + +Run `/gsd doctor` to see git health issues. Run `/gsd doctor --fix` to auto-remediate. Issue codes appear in `summarizeDoctorIssues` byCode breakdown. Non-git repos produce no git-related output. + +## Deviations + +- Skipped importing `autoWorktreeBranch` — the branch naming pattern (`milestone/${milestoneId}`) is trivial enough to inline as a string replace, avoiding an unnecessary import. + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/doctor.ts` — 4 new DoctorIssueCode values, checkGitHealth function, called from runGSDDoctor +- `src/resources/extensions/gsd/git-service.ts` — block comment on .gsd/ conflict resolution code (branch-mode-only, D038) diff --git a/.gsd/milestones/M003/slices/S06/tasks/T02-PLAN.md b/.gsd/milestones/M003/slices/S06/tasks/T02-PLAN.md new file mode 100644 index 000000000..76f27e08c --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/tasks/T02-PLAN.md @@ -0,0 +1,55 @@ +--- +estimated_steps: 4 +estimated_files: 1 +--- + +# T02: Integration tests for doctor git health checks + +**Slice:** S06 — Doctor + cleanup + code simplification +**Milestone:** M003 + +## Description + +Build integration tests that create real temp git repos with deliberate broken state, run `runGSDDoctor`, and assert correct detection and fixing of all 4 git issue codes. + +## Steps + +1. Create `doctor-git.test.ts` using `node:test` with temp dir helpers (consistent with `auto-worktree.test.ts` pattern — `mkdtempSync`, `realpathSync`, `execSync` for git init) +2. Write helper to create a minimal GSD project with roadmap containing a milestone (reuse pattern from auto-worktree tests) +3. Implement test cases: + - Orphaned worktree: create worktree with `milestone/M001` branch, mark M001 complete in roadmap → doctor detects `orphaned_auto_worktree`, fix removes it + - Stale branch: create `milestone/M001` branch (no worktree), mark M001 complete → doctor detects `stale_milestone_branch`, fix deletes branch + - Corrupt merge state: write MERGE_HEAD file in `.git/` → doctor detects `corrupt_merge_state`, fix cleans it + - Tracked runtime files: `git add -f .gsd/activity/test.log` → doctor detects `tracked_runtime_files`, fix untracks + - Non-git dir: run doctor in a plain temp dir → no crash, no git issues reported + - Active worktree safety: create worktree, milestone in-progress → NOT flagged as orphaned +4. Each test: run `runGSDDoctor(basePath)` for detection assertions, then `runGSDDoctor(basePath, { fix: true })` for fix assertions, then verify git state after fix + +## Must-Haves + +- [ ] All 4 issue codes tested for detection +- [ ] All 4 issue codes tested for fix +- [ ] Non-git directory graceful degradation tested +- [ ] Active worktree not flagged (false positive prevention) + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — all pass +- `npx tsx src/resources/extensions/gsd/tests/doctor.test.ts` — still passes +- `npx tsx src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts` — still passes + +## Inputs + +- `src/resources/extensions/gsd/doctor.ts` — T01's new `checkGitHealth` function and issue codes +- `src/resources/extensions/gsd/tests/auto-worktree.test.ts` — temp repo setup patterns +- `src/resources/extensions/gsd/tests/git-self-heal.test.ts` — corrupt state injection patterns + +## Expected Output + +- `src/resources/extensions/gsd/tests/doctor-git.test.ts` — 6+ test cases with real git repos + +## Observability Impact + +- **Test output:** Running `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` prints pass/fail for all 6 test cases (17 assertions) covering detection and fix of all 4 git issue codes plus graceful degradation and false positive prevention. +- **Failure diagnostics:** Each failed assertion prints the expected vs actual value with a descriptive label. +- **No runtime signals changed** — this task adds tests only, no production behavior changes. diff --git a/.gsd/milestones/M003/slices/S06/tasks/T02-SUMMARY.md b/.gsd/milestones/M003/slices/S06/tasks/T02-SUMMARY.md new file mode 100644 index 000000000..49752cf14 --- /dev/null +++ b/.gsd/milestones/M003/slices/S06/tasks/T02-SUMMARY.md @@ -0,0 +1,54 @@ +--- +id: T02 +parent: S06 +milestone: M003 +provides: + - Integration tests for all 4 git health check issue codes in doctor +key_files: + - src/resources/extensions/gsd/tests/doctor-git.test.ts +key_decisions: + - Worktrees must be under .gsd/worktrees/ to match listWorktrees filter (not .gsd-worktrees/) + - Roadmap must use `## Slices` with checkbox format to match parseRoadmapSlices parser +patterns_established: + - Git health check test pattern: createRepoWithCompletedMilestone helper, detect → fix → verify cycle +observability_surfaces: + - none +duration: 25m +verification_result: passed +completed_at: 2026-03-14 +blocker_discovered: false +--- + +# T02: Integration tests for doctor git health checks + +**Built 6 integration tests (17 assertions) covering detection, fix, and false-positive prevention for all 4 git health check issue codes.** + +## What Happened + +Created `doctor-git.test.ts` with real temp git repos. Each test injects deliberate broken state, runs `runGSDDoctor` for detection, then `runGSDDoctor({fix:true})` for remediation, then verifies git state post-fix. Key discovery: worktrees must be under `.gsd/worktrees/` (not `.gsd-worktrees/`) and roadmaps must use the `## Slices` checkbox format (not table format) to match the actual parsers. + +## Verification + +- `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` — 17 passed, 0 failed ✓ +- `npx tsx src/resources/extensions/gsd/tests/doctor.test.ts` — 59 passed, 0 failed ✓ +- `npx tsx src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts` — all pass ✓ +- `npx tsc --noEmit` — zero errors ✓ + +## Diagnostics + +Run `npx tsx src/resources/extensions/gsd/tests/doctor-git.test.ts` to see all git health check test results. + +## Deviations + +- Roadmap format in tests changed from table (`## Slice Inventory` with `| |` rows) to checkbox format (`## Slices` with `- [x] **S01: ...**`) to match `parseRoadmapSlices` parser expectations. +- Worktree path changed from `.gsd-worktrees/` to `.gsd/worktrees/` to match `listWorktrees` filter. + +## Known Issues + +None. + +## Files Created/Modified + +- `src/resources/extensions/gsd/tests/doctor-git.test.ts` — 6 integration tests for git health checks +- `.gsd/milestones/M003/slices/S06/S06-PLAN.md` — marked T02 done +- `.gsd/milestones/M003/slices/S06/tasks/T02-PLAN.md` — added Observability Impact section diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 4b83aff08..80bf451f1 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -5,6 +6,9 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js"; +import { listWorktrees } from "./worktree-manager.js"; +import { abortAndReset } from "./git-self-heal.js"; +import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js"; export type DoctorSeverity = "info" | "warning" | "error"; export type DoctorIssueCode = @@ -23,7 +27,11 @@ export type DoctorIssueCode = | "active_requirement_missing_owner" | "blocked_requirement_missing_reason" | "blocker_discovered_no_replan" - | "delimiter_in_title"; + | "delimiter_in_title" + | "orphaned_auto_worktree" + | "stale_milestone_branch" + | "corrupt_merge_state" + | "tracked_runtime_files"; export interface DoctorIssue { severity: DoctorSeverity; @@ -451,6 +459,189 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string { }).join("\n"); } +async function checkGitHealth( + basePath: string, + issues: DoctorIssue[], + fixesApplied: string[], + shouldFix: (code: DoctorIssueCode) => boolean, +): Promise { + // Degrade gracefully if not a git repo + try { + execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" }); + } catch { + return; // Not a git repo — skip all git health checks + } + + const gitDir = join(basePath, ".git"); + + // ── Orphaned auto-worktrees ────────────────────────────────────────── + try { + const worktrees = listWorktrees(basePath); + const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/")); + + // Load roadmap state once for cross-referencing + const state = await deriveState(basePath); + + for (const wt of milestoneWorktrees) { + // Extract milestone ID from branch name "milestone/M001" → "M001" + const milestoneId = wt.branch.replace(/^milestone\//, ""); + const milestoneEntry = state.registry.find(m => m.id === milestoneId); + + // Check if milestone is complete via roadmap + let isComplete = false; + if (milestoneEntry) { + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + isComplete = isMilestoneComplete(roadmap); + } + } + + if (isComplete) { + issues.push({ + severity: "warning", + code: "orphaned_auto_worktree", + scope: "milestone", + unitId: milestoneId, + message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`, + fixable: true, + }); + + if (shouldFix("orphaned_auto_worktree")) { + // Never remove a worktree matching current working directory + const cwd = process.cwd(); + if (wt.path === cwd || cwd.startsWith(wt.path + "/")) { + fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`); + } else { + try { + execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" }); + fixesApplied.push(`removed orphaned worktree ${wt.path}`); + } catch { + fixesApplied.push(`failed to remove worktree ${wt.path}`); + } + } + } + } + } + + // ── Stale milestone branches ───────────────────────────────────────── + try { + const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim(); + if (branchOutput) { + const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean); + const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch)); + + for (const branch of branches) { + // Skip branches that have a worktree (handled above) + if (worktreeBranches.has(branch)) continue; + + const milestoneId = branch.replace(/^milestone\//, ""); + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; + if (!roadmapContent) continue; + + const roadmap = parseRoadmap(roadmapContent); + if (isMilestoneComplete(roadmap)) { + issues.push({ + severity: "info", + code: "stale_milestone_branch", + scope: "milestone", + unitId: milestoneId, + message: `Branch ${branch} exists for completed milestone ${milestoneId}`, + fixable: true, + }); + + if (shouldFix("stale_milestone_branch")) { + try { + execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" }); + fixesApplied.push(`deleted stale branch ${branch}`); + } catch { + fixesApplied.push(`failed to delete branch ${branch}`); + } + } + } + } + } + } catch { + // git branch list failed — skip stale branch check + } + } catch { + // listWorktrees or deriveState failed — skip worktree/branch checks + } + + // ── Corrupt merge state ──────────────────────────────────────────────── + try { + const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"]; + const mergeStateDirs = ["rebase-apply", "rebase-merge"]; + const found: string[] = []; + + for (const f of mergeStateFiles) { + if (existsSync(join(gitDir, f))) found.push(f); + } + for (const d of mergeStateDirs) { + if (existsSync(join(gitDir, d))) found.push(d); + } + + if (found.length > 0) { + issues.push({ + severity: "error", + code: "corrupt_merge_state", + scope: "project", + unitId: "project", + message: `Corrupt merge/rebase state detected: ${found.join(", ")}`, + fixable: true, + }); + + if (shouldFix("corrupt_merge_state")) { + const result = abortAndReset(basePath); + fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`); + } + } + } catch { + // Can't check .git dir — skip + } + + // ── Tracked runtime files ────────────────────────────────────────────── + try { + const trackedPaths: string[] = []; + for (const exclusion of RUNTIME_EXCLUSION_PATHS) { + try { + const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim(); + if (output) { + trackedPaths.push(...output.split("\n").filter(Boolean)); + } + } catch { + // Individual ls-files can fail — continue + } + } + + if (trackedPaths.length > 0) { + issues.push({ + severity: "warning", + code: "tracked_runtime_files", + scope: "project", + unitId: "project", + message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`, + fixable: true, + }); + + if (shouldFix("tracked_runtime_files")) { + try { + for (const exclusion of RUNTIME_EXCLUSION_PATHS) { + execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" }); + } + fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`); + } catch { + fixesApplied.push("failed to untrack runtime files"); + } + } + } + } catch { + // git ls-files failed — skip + } +} + export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise { const issues: DoctorIssue[] = []; const fixesApplied: string[] = []; @@ -491,6 +682,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } } + // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files) + await checkGitHealth(basePath, issues, fixesApplied, shouldFix); + const milestonesPath = milestonesDir(basePath); if (!existsSync(milestonesPath)) { return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied }; diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 5dea28188..6db4694f0 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -766,6 +766,15 @@ export class GitServiceImpl { this.git(mergeArgs); } catch (mergeError) { // Check if conflicts can be auto-resolved (#189, #218) + // + // ─── BRANCH-MODE ONLY (D038) ──────────────────────────────────────── + // The conflict resolution logic below applies ONLY when git.isolation = "branch". + // In worktree isolation mode, each milestone works in its own worktree directory + // so merge conflicts between slice branches and main are handled differently + // (worktree teardown merges via worktree-manager). This block is never reached + // in worktree mode because mergeSliceToMain is only called from the branch-mode + // code path. If you're modifying this logic, verify the isolation mode first. + // ───────────────────────────────────────────────────────────────────── const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); if (conflicted) { const conflictedFiles = conflicted.split("\n").filter(Boolean); diff --git a/src/resources/extensions/gsd/tests/doctor-git.test.ts b/src/resources/extensions/gsd/tests/doctor-git.test.ts new file mode 100644 index 000000000..00fab813f --- /dev/null +++ b/src/resources/extensions/gsd/tests/doctor-git.test.ts @@ -0,0 +1,246 @@ +/** + * doctor-git.test.ts — Integration tests for doctor git health checks. + * + * Creates real temp git repos with deliberate broken state, runs runGSDDoctor, + * and asserts correct detection and fixing of all 4 git issue codes: + * orphaned_auto_worktree, stale_milestone_branch, + * corrupt_merge_state, tracked_runtime_files + */ + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { runGSDDoctor } from "../doctor.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function run(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +/** Create a temp git repo with a completed milestone M001 in roadmap. */ +function createRepoWithCompletedMilestone(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-"))); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + + // Initial commit + writeFileSync(join(dir, "README.md"), "# test\n"); + run("git add .", dir); + run("git commit -m init", dir); + run("git branch -M main", dir); + + // Create .gsd structure with milestone M001 — all slices done → complete + const msDir = join(dir, ".gsd", "milestones", "M001"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "ROADMAP.md"), `--- +id: M001 +title: "Test Milestone" +--- + +# M001: Test Milestone + +## Vision +Test + +## Success Criteria +- Done + +## Slices +- [x] **S01: Test slice** \`risk:low\` \`depends:[]\` + > After this: done + +## Boundary Map +_None_ +`); + + // Commit .gsd files + run("git add -A", dir); + run("git commit -m 'add milestone'", dir); + + return dir; +} + +/** Create a repo with an in-progress milestone. */ +function createRepoWithActiveMilestone(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-"))); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + + writeFileSync(join(dir, "README.md"), "# test\n"); + run("git add .", dir); + run("git commit -m init", dir); + run("git branch -M main", dir); + + const msDir = join(dir, ".gsd", "milestones", "M001"); + mkdirSync(msDir, { recursive: true }); + writeFileSync(join(msDir, "ROADMAP.md"), `--- +id: M001 +title: "Active Milestone" +--- + +# M001: Active Milestone + +## Vision +Test + +## Success Criteria +- Done + +## Slices +- [ ] **S01: Test slice** \`risk:low\` \`depends:[]\` + > After this: done + +## Boundary Map +_None_ +`); + + run("git add -A", dir); + run("git commit -m 'add milestone'", dir); + + return dir; +} + +async function main(): Promise { + const cleanups: string[] = []; + + try { + // ─── Test 1: Orphaned worktree detection & fix ───────────────────── + console.log("\n=== orphaned_auto_worktree ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Create worktree with milestone/M001 branch under .gsd/worktrees/ + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); + + const detect = await runGSDDoctor(dir); + const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree"); + assertTrue(orphanIssues.length > 0, "detects orphaned worktree"); + assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001"); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree"); + + // Verify worktree is gone + const wtList = run("git worktree list", dir); + assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix"); + } + + // ─── Test 2: Stale milestone branch detection & fix ──────────────── + console.log("\n=== stale_milestone_branch ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Create a milestone/M001 branch (no worktree) + run("git branch milestone/M001", dir); + + const detect = await runGSDDoctor(dir); + const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch"); + assertTrue(staleIssues.length > 0, "detects stale milestone branch"); + assertEq(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001"); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch"); + + // Verify branch is gone + const branches = run("git branch --list 'milestone/*'", dir); + assertTrue(!branches.includes("milestone/M001"), "branch gone after fix"); + } + + // ─── Test 3: Corrupt merge state detection & fix ─────────────────── + console.log("\n=== corrupt_merge_state ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Inject MERGE_HEAD into .git + const headHash = run("git rev-parse HEAD", dir); + writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n"); + + const detect = await runGSDDoctor(dir); + const mergeIssues = detect.issues.filter(i => i.code === "corrupt_merge_state"); + assertTrue(mergeIssues.length > 0, "detects corrupt merge state"); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state"); + + // Verify MERGE_HEAD is gone + assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix"); + } + + // ─── Test 4: Tracked runtime files detection & fix ───────────────── + console.log("\n=== tracked_runtime_files ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Force-add a runtime file + const activityDir = join(dir, ".gsd", "activity"); + mkdirSync(activityDir, { recursive: true }); + writeFileSync(join(activityDir, "test.log"), "log data\n"); + run("git add -f .gsd/activity/test.log", dir); + run("git commit -m 'track runtime file'", dir); + + const detect = await runGSDDoctor(dir); + const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files"); + assertTrue(trackedIssues.length > 0, "detects tracked runtime files"); + + const fixed = await runGSDDoctor(dir, { fix: true }); + assertTrue(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files"); + + // Verify file is no longer tracked + const tracked = run("git ls-files .gsd/activity/", dir); + assertEq(tracked, "", "runtime file untracked after fix"); + } + + // ─── Test 5: Non-git directory — graceful degradation ────────────── + console.log("\n=== non-git directory ==="); + { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-"))); + cleanups.push(dir); + + // Create minimal .gsd structure (no git) + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + const result = await runGSDDoctor(dir); + const gitIssues = result.issues.filter(i => + ["orphaned_auto_worktree", "stale_milestone_branch", "corrupt_merge_state", "tracked_runtime_files"].includes(i.code) + ); + assertEq(gitIssues.length, 0, "no git issues in non-git directory"); + // Should not throw — reaching here means no crash + assertTrue(true, "non-git directory does not crash"); + } + + // ─── Test 6: Active worktree NOT flagged (false positive prevention) ─ + console.log("\n=== active worktree safety ==="); + { + const dir = createRepoWithActiveMilestone(); + cleanups.push(dir); + + // Create worktree for in-progress milestone under .gsd/worktrees/ + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); + + const detect = await runGSDDoctor(dir); + const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree"); + assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned"); + } + + } finally { + for (const dir of cleanups) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + } + + report(); +} + +main();