From 4d60b49f2544a30b654ae6ea6b97edf94ec1ef85 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 23:20:30 -0600 Subject: [PATCH] feat(M003/S04): worktree-aware merge + isolation preferences --- .gsd/DECISIONS.md | 2 + .gsd/PROJECT.md | 2 +- .gsd/REQUIREMENTS.md | 18 +-- .gsd/milestones/M003/M003-ROADMAP.md | 2 +- .gsd/milestones/M003/slices/S04/S04-PLAN.md | 68 ++++++++++ .../M003/slices/S04/S04-RESEARCH.md | 66 ++++++++++ .../milestones/M003/slices/S04/S04-SUMMARY.md | 117 ++++++++++++++++++ .gsd/milestones/M003/slices/S04/S04-UAT.md | 109 ++++++++++++++++ .../M003/slices/S04/tasks/T01-PLAN.md | 58 +++++++++ .../M003/slices/S04/tasks/T01-SUMMARY.md | 92 ++++++++++++++ src/resources/extensions/gsd/auto-worktree.ts | 62 +++++++--- src/resources/extensions/gsd/auto.ts | 15 ++- src/resources/extensions/gsd/git-service.ts | 2 + src/resources/extensions/gsd/preferences.ts | 18 ++- .../gsd/tests/isolation-resolver.test.ts | 107 ++++++++++++++++ .../gsd/tests/preferences-git.test.ts | 88 +++++++++++++ 16 files changed, 792 insertions(+), 34 deletions(-) create mode 100644 .gsd/milestones/M003/slices/S04/S04-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S04/S04-RESEARCH.md create mode 100644 .gsd/milestones/M003/slices/S04/S04-SUMMARY.md create mode 100644 .gsd/milestones/M003/slices/S04/S04-UAT.md create mode 100644 .gsd/milestones/M003/slices/S04/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M003/slices/S04/tasks/T01-SUMMARY.md create mode 100644 src/resources/extensions/gsd/tests/isolation-resolver.test.ts create mode 100644 src/resources/extensions/gsd/tests/preferences-git.test.ts diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index 959eeaa05..c1a9aaaaf 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -47,3 +47,5 @@ | D039 | M003/S03 | bugfix | Nothing-to-commit detection in mergeMilestoneToMain | Check err.stdout/stderr properties, not just err.message | Node's execSync wraps the error; err.message contains Node's wrapper text, not git's output. The actual "nothing to commit" text is in err.stdout. | No | | D040 | M003/S03 | bugfix | Worktree removal before branch deletion in mergeMilestoneToMain | Swap ordering: removeWorktree first, then git branch -D | Git refuses to delete a branch checked out in a worktree. Must remove worktree first to unlock the ref. | No | | 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 | diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 4af63f6a0..5c78a60d7 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -41,4 +41,4 @@ See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement sta - [x] M001: Proactive Secret Management — Front-loaded API key collection into planning so auto-mode runs uninterrupted (10 requirements validated) - [x] M002: Browser Tools Performance & Intelligence — Module decomposition, action pipeline optimization, sharp-based screenshots, form intelligence, intent-ranked retrieval, semantic actions, 108-test suite (12 requirements validated) -- [ ] M003: Worktree-Isolated Git Architecture — S01-S03 complete (worktree lifecycle, --no-ff slice merges, milestone squash-merge to main). S04-S07 remaining. +- [ ] M003: Worktree-Isolated Git Architecture — S01-S04 complete (worktree lifecycle, --no-ff slice merges, milestone squash-merge, preferences + backwards compat). S05-S07 remaining. diff --git a/.gsd/REQUIREMENTS.md b/.gsd/REQUIREMENTS.md index cdce74991..5acbd6fe7 100644 --- a/.gsd/REQUIREMENTS.md +++ b/.gsd/REQUIREMENTS.md @@ -50,24 +50,24 @@ This file is the explicit capability and coverage contract for the project. ### R033 — `git.isolation` preference - Class: core-capability -- Status: active +- Status: validated - Description: A `git.isolation` preference with values `"worktree"` (default for new projects) and `"branch"` (legacy model). New projects that have never run GSD default to worktree isolation. Existing projects with an established branch-per-slice history default to branch mode. - Why it matters: Backwards compatibility — existing projects must not break. New projects get the better model by default. - Source: user - Primary owning slice: M003/S04 - Supporting slices: none -- Validation: unmapped +- Validation: Set-based validation in validatePreferences, shouldUseWorktreeIsolation resolver with three-tier resolution (explicit pref > legacy detection > default). 25 test assertions in preferences-git.test.ts and isolation-resolver.test.ts. - Notes: Detection heuristic: if the project has existing `gsd/*` branches or milestone metadata with integration branch records, it's a legacy project → default to "branch". Otherwise → default to "worktree". ### R034 — `git.merge_to_main` preference - Class: core-capability -- Status: active +- Status: validated - Description: A `git.merge_to_main` preference with values `"milestone"` (default) and `"slice"`. In milestone mode, main only receives commits when milestones complete. In slice mode, each completed slice squash-merges to main immediately (current behavior). - Why it matters: Senior engineers who want frequent integration can opt into slice-level merges. Vibe coders get the cleaner milestone-level default. - Source: user - Primary owning slice: M003/S04 - Supporting slices: M003/S03 -- Validation: unmapped +- Validation: Set-based validation in validatePreferences, getMergeToMainMode helper, auto.ts merge routing gated behind preference. Tested in preferences-git.test.ts. - Notes: `merge_to_main: "slice"` with `isolation: "worktree"` is valid — slices squash-merge to main from within the worktree, but the worktree still provides `.gsd/` isolation. ### R035 — Self-healing git repair on failure @@ -530,8 +530,8 @@ This file is the explicit capability and coverage contract for the project. | R030 | core-capability | active | M003/S03 | M003/S01 | unmapped | | R031 | core-capability | active | M003/S02 | M003/S01 | unmapped | | R032 | core-capability | active | M003/S03 | none | unmapped | -| R033 | core-capability | active | M003/S04 | none | unmapped | -| R034 | core-capability | active | M003/S04 | M003/S03 | unmapped | +| R033 | core-capability | validated | M003/S04 | none | Set-based validation, shouldUseWorktreeIsolation resolver, 25 test assertions | +| R034 | core-capability | validated | M003/S04 | M003/S03 | Set-based validation, getMergeToMainMode, auto.ts merge routing gated | | R035 | core-capability | active | M003/S05 | M003/S01, M003/S02, M003/S03 | unmapped | | R036 | quality-attribute | active | M003/S02 | M003/S06 | unmapped | | R037 | primary-user-loop | active | M003/S05 | all M003 | unmapped | @@ -545,9 +545,9 @@ This file is the explicit capability and coverage contract for the project. ## Coverage Summary -- Active requirements: 13 -- Mapped to slices: 13 -- Validated: 22 +- Active requirements: 11 +- Mapped to slices: 11 +- Validated: 24 - 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 27d739d05..851f4cc5f 100644 --- a/.gsd/milestones/M003/M003-ROADMAP.md +++ b/.gsd/milestones/M003/M003-ROADMAP.md @@ -62,7 +62,7 @@ This milestone is complete only when all are true: - [x] **S03: Milestone-to-main squash merge + worktree teardown** `risk:high` `depends:[S01,S02]` > After this: `complete-milestone` squash-merges the milestone branch to main with a rich commit message listing all slices, removes the worktree, `chdir`s back to the main project root. `git log main` shows one clean commit. Auto-push works if enabled. Verified in temp repo with remote. -- [ ] **S04: Preferences + backwards compatibility** `risk:medium` `depends:[S01]` +- [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]` diff --git a/.gsd/milestones/M003/slices/S04/S04-PLAN.md b/.gsd/milestones/M003/slices/S04/S04-PLAN.md new file mode 100644 index 000000000..5702f5915 --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/S04-PLAN.md @@ -0,0 +1,68 @@ +# S04: Preferences + backwards compatibility + +**Goal:** `git.isolation` and `git.merge_to_main` preferences are validated and respected. Existing branch-per-slice projects auto-detect as `"branch"` mode and work identically. New projects default to `"worktree"`. + +**Demo:** Set `git.isolation: "branch"` in preferences → auto-mode skips worktree creation and uses legacy branch-per-slice. Remove the preference on a project with no `gsd/*` branches → auto-mode creates worktrees. Set `git.merge_to_main: "slice"` → slices merge directly to main even in worktree mode. + +## Must-Haves + +- `git.isolation: "worktree" | "branch"` preference with validation +- `git.merge_to_main: "milestone" | "slice"` preference with validation +- `shouldUseWorktreeIsolation(basePath)` resolver that checks preference then falls back to legacy detection heuristic +- All 3 worktree creation/entry sites in auto.ts gated behind the resolver +- Milestone-to-main merge gated behind `merge_to_main` preference +- `merge_to_main: "slice"` + `isolation: "worktree"` combo works (slices merge to main, not milestone branch) +- Resolve merge conflict markers in auto-worktree.ts inherited from S03 branch merge + +## Proof Level + +- This slice proves: contract + integration +- Real runtime required: no (preference logic is testable without a real git repo for most paths; legacy detection needs git commands but can use test repos) +- Human/UAT required: no + +## Observability / Diagnostics + +- `shouldUseWorktreeIsolation()` logs nothing by default -- its resolution is observable through the auto-mode notify messages ("Created auto-worktree" vs normal branch flow). +- When `isolation` or `merge_to_main` preferences are invalid, `validatePreferences()` returns clear error strings in the `errors` array; these surface in the UI during preference loading. +- Legacy detection result (branch-per-slice vs worktree) is implicit in auto-mode behavior: worktree creation messages appear only when resolver returns true. +- Failure path: invalid preference values produce structured error messages matching the pattern `"git. must be one of: "`. + +## Verification + +- `npx tsc --noEmit` — clean build +- `node --test src/resources/extensions/gsd/tests/preferences-git.test.ts` — validates new preference fields +- `node --test src/resources/extensions/gsd/tests/isolation-resolver.test.ts` — validates shouldUseWorktreeIsolation with preference override, legacy detection, and default +- Grep for `<<<<` in auto-worktree.ts returns 0 matches (conflict markers resolved) +- Verify `validatePreferences({ git: { isolation: "bad" } })` returns error containing "git.isolation" (failure-path check) + +## Integration Closure + +- Upstream surfaces consumed: `auto-worktree.ts` (S01 lifecycle functions), `auto.ts` (S01/S02/S03 worktree wiring), `git-service.ts` (GitPreferences interface), `preferences.ts` (validatePreferences) +- New wiring introduced: `shouldUseWorktreeIsolation()` call at 3 sites in auto.ts, `merge_to_main` check at milestone merge site +- What remains: S05 (self-healing), S06 (doctor/cleanup), S07 (full test suite) + +## Tasks + +- [x] **T01: Resolve auto-worktree.ts merge conflicts + add preference fields + validation + resolver + gate auto.ts** `est:45m` + - Why: This is a single coherent unit — the interface change, validation, resolver function, and gating are all tightly coupled and small. The merge conflicts must be resolved first since we're editing the same file. + - Files: `src/resources/extensions/gsd/auto-worktree.ts`, `src/resources/extensions/gsd/git-service.ts`, `src/resources/extensions/gsd/preferences.ts`, `src/resources/extensions/gsd/auto.ts`, `src/resources/extensions/gsd/tests/preferences-git.test.ts`, `src/resources/extensions/gsd/tests/isolation-resolver.test.ts` + - Do: + 1. Resolve merge conflict markers in `auto-worktree.ts` — keep both sides (HEAD imports + S03's `mergeMilestoneToMain` function and its helpers) + 2. Add `isolation?: "worktree" | "branch"` and `merge_to_main?: "milestone" | "slice"` to `GitPreferences` in `git-service.ts` + 3. Add validation for both fields in `validatePreferences()` in `preferences.ts` following the `merge_strategy` Set pattern + 4. Add `shouldUseWorktreeIsolation(basePath: string): boolean` in `auto-worktree.ts` — checks `loadEffectiveGSDPreferences().preferences.git.isolation`, falls back to legacy detection (`git branch --list 'gsd/*/*'` returns branches → `false`, otherwise → `true`) + 5. Gate the 3 worktree sites in `auto.ts` (fresh start ~785, resume ~620, milestone merge ~1735) behind `shouldUseWorktreeIsolation()` + 6. For `merge_to_main: "slice"` + worktree mode: override `isInAutoWorktree()` merge routing at lines ~558 and ~1603 to use `mergeSliceToMain` instead of `mergeSliceToMilestone` + 7. Write test file `preferences-git.test.ts` — validates new fields accept valid values, reject invalid, and pass through undefined + 8. Write test file `isolation-resolver.test.ts` — tests shouldUseWorktreeIsolation with explicit preference, legacy detection, and default behavior + - Verify: `npx tsc --noEmit && node --test src/resources/extensions/gsd/tests/preferences-git.test.ts && node --test src/resources/extensions/gsd/tests/isolation-resolver.test.ts && ! grep -l '<<<<<<' src/resources/extensions/gsd/auto-worktree.ts` + - Done when: Both new preferences validated, resolver returns correct mode for all 3 cases (explicit pref, legacy project, new project), auto.ts gates worktree code behind preference, merge routing respects merge_to_main, all tests pass, no conflict markers remain + +## Files Likely Touched + +- `src/resources/extensions/gsd/auto-worktree.ts` +- `src/resources/extensions/gsd/git-service.ts` +- `src/resources/extensions/gsd/preferences.ts` +- `src/resources/extensions/gsd/auto.ts` +- `src/resources/extensions/gsd/tests/preferences-git.test.ts` +- `src/resources/extensions/gsd/tests/isolation-resolver.test.ts` diff --git a/.gsd/milestones/M003/slices/S04/S04-RESEARCH.md b/.gsd/milestones/M003/slices/S04/S04-RESEARCH.md new file mode 100644 index 000000000..36564f458 --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/S04-RESEARCH.md @@ -0,0 +1,66 @@ +# S04: Preferences + backwards compatibility — Research + +**Date:** 2026-03-14 + +## Summary + +This slice adds two new git preferences (`git.isolation` and `git.merge_to_main`) and gates all worktree-mode code behind them. The codebase is well-structured for this: `GitPreferences` interface in `git-service.ts` already has 9 fields, `validatePreferences()` in `preferences.ts` already validates each field with error messages, and `auto.ts` already uses `isInAutoWorktree()` to branch between worktree and legacy merge paths. The main work is: (1) extend the interface, (2) add validation, (3) add a `shouldUseWorktreeIsolation()` resolver with legacy detection heuristic, (4) gate worktree creation/entry in auto.ts behind the preference, (5) gate milestone-to-main merge behind `merge_to_main`. + +The legacy detection heuristic is straightforward: if the repo has `gsd/*/*` branches (checked via `git branch --list 'gsd/*/*'`), it's a legacy project → default to `"branch"`. Otherwise → default to `"worktree"`. This aligns with D033. + +## Recommendation + +Implement in this order: +1. Add `isolation` and `merge_to_main` to `GitPreferences` interface +2. Add validation in `validatePreferences()` following the existing pattern (Set of valid values, string check, cast) +3. Add `shouldUseWorktreeIsolation(basePath)` function in `auto-worktree.ts` — resolves effective mode from preference + legacy detection +4. Gate the 3 worktree creation/entry sites in `auto.ts` (lines ~785-800, ~620-637, ~794) behind `shouldUseWorktreeIsolation()` +5. Gate `mergeMilestoneToMain` call (line ~1739) behind `merge_to_main` preference +6. Ensure `isInAutoWorktree()` branch checks in merge paths (lines ~558, ~1603) continue working — they already handle both modes correctly since they check runtime state, not preference + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| Preference validation | `validatePreferences()` in preferences.ts | Established pattern with error accumulation, type narrowing, and Set-based enum validation | +| Preference loading | `loadEffectiveGSDPreferences()` | Already merges global + project prefs with override semantics | +| Legacy branch detection | `git branch --list 'gsd/*/*'` | Already used in `mergeOrphanedSliceBranches()` at auto.ts:506 | +| Worktree state detection | `isInAutoWorktree()` | Already gates merge strategy selection at runtime | + +## Existing Code and Patterns + +- `src/resources/extensions/gsd/git-service.ts:31-39` — `GitPreferences` interface. Add `isolation?: "worktree" | "branch"` and `merge_to_main?: "milestone" | "slice"` here. +- `src/resources/extensions/gsd/preferences.ts:860-912` — git preference validation block. Follow the `merge_strategy` pattern (Set + string check + cast) for new fields. +- `src/resources/extensions/gsd/auto.ts:558,1603` — `isInAutoWorktree(base)` already gates merge strategy at runtime. These don't need preference changes — they check actual worktree state. +- `src/resources/extensions/gsd/auto.ts:785-800` — worktree creation/entry on fresh milestone start. Gate with `shouldUseWorktreeIsolation()`. +- `src/resources/extensions/gsd/auto.ts:620-637` — worktree re-entry on resume. Gate with same check. +- `src/resources/extensions/gsd/auto.ts:1739` — `mergeMilestoneToMain()` call. Gate with `merge_to_main` preference. +- `src/resources/extensions/gsd/auto.ts:506` — `git branch --list 'gsd/*/*'` already used for orphan detection. Reuse same pattern for legacy detection. + +## Constraints + +- `GitPreferences` is exported from `git-service.ts` and imported by `preferences.ts` — the interface lives in git-service, validation lives in preferences. Follow this split. +- `shouldUseWorktreeIsolation()` needs both the preference value AND a basePath for legacy detection. It should live in `auto-worktree.ts` since that module owns worktree lifecycle. +- The `merge_to_main: "slice"` + `isolation: "worktree"` combination is valid per R034 — slices squash-merge to main from within worktree. The existing `mergeSliceToMain()` path handles this. +- Existing `merge_strategy` preference ("squash" | "merge") is per-slice merge strategy, separate from the new `merge_to_main` preference. Don't confuse them. + +## Common Pitfalls + +- **Gating resume path but not fresh-start path** — Both auto.ts:785-800 (fresh start) AND auto.ts:620-637 (resume) must be gated. Missing either causes inconsistent behavior. +- **Legacy detection on worktree basePath** — Legacy branch detection (`git branch --list 'gsd/*/*'`) must run against the main repo, not a worktree path. Use `originalBasePath` if available, fall back to `basePath`. +- **merge_to_main: "slice" in worktree mode** — When `isolation: "worktree"` + `merge_to_main: "slice"`, the slice merge path at auto.ts:1603 should use `mergeSliceToMain` (not `mergeSliceToMilestone`). Currently `isInAutoWorktree()` gates this — need to override when `merge_to_main: "slice"`. +- **Preference loading timing** — `loadEffectiveGSDPreferences()` reads from disk. In worktree mode, `.gsd/preferences.md` might not exist in the worktree. Preference loading should happen BEFORE entering the worktree, or fall back to the main tree's preferences. + +## Open Risks + +- The `merge_to_main: "slice"` + `isolation: "worktree"` combination needs the slice merge to go to main, not the milestone branch. This means `isInAutoWorktree()` alone is no longer sufficient to decide merge target — the preference must also be consulted. This is a behavioral change in the merge decision logic. + +## Skills Discovered + +| Technology | Skill | Status | +|------------|-------|--------| +| Git worktrees | N/A | No relevant skill — this is internal architecture | + +## Sources + +- All findings from direct codebase exploration of preferences.ts, git-service.ts, auto.ts, and auto-worktree.ts diff --git a/.gsd/milestones/M003/slices/S04/S04-SUMMARY.md b/.gsd/milestones/M003/slices/S04/S04-SUMMARY.md new file mode 100644 index 000000000..061b04897 --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/S04-SUMMARY.md @@ -0,0 +1,117 @@ +--- +id: S04 +parent: M003 +milestone: M003 +provides: + - git.isolation preference ("worktree" | "branch") with validation + - git.merge_to_main preference ("milestone" | "slice") with validation + - shouldUseWorktreeIsolation resolver with legacy detection heuristic + - getMergeToMainMode helper + - All worktree/merge sites in auto.ts gated behind preferences +requires: + - slice: S01 + provides: auto-worktree lifecycle functions (createAutoWorktree, enterAutoWorktree, isInAutoWorktree) +affects: + - S05 + - S06 + - S07 +key_files: + - src/resources/extensions/gsd/git-service.ts + - src/resources/extensions/gsd/preferences.ts + - src/resources/extensions/gsd/auto-worktree.ts + - src/resources/extensions/gsd/auto.ts + - src/resources/extensions/gsd/tests/preferences-git.test.ts + - src/resources/extensions/gsd/tests/isolation-resolver.test.ts +key_decisions: + - D042: shouldUseWorktreeIsolation accepts optional overridePrefs for testability + - D043: validatePreferences exported for direct test access +patterns_established: + - Set-based validation pattern extended for isolation and merge_to_main fields + - Preference override parameter pattern for functions that load preferences internally +observability_surfaces: + - Preference validation errors as structured strings in errors array + - Worktree vs branch mode observable through auto-mode notify messages +drill_down_paths: + - .gsd/milestones/M003/slices/S04/tasks/T01-SUMMARY.md +duration: 30m +verification_result: passed +completed_at: 2026-03-14 +--- + +# S04: Preferences + backwards compatibility + +**Added git.isolation and git.merge_to_main preferences with validation, resolver, and auto.ts gating for full backwards compatibility** + +## What Happened + +Extended GitPreferences with `isolation` ("worktree" | "branch") and `merge_to_main` ("milestone" | "slice") fields. Added Set-based validation for both in validatePreferences(). Implemented `shouldUseWorktreeIsolation(basePath)` with three-tier resolution: explicit preference → legacy branch detection (gsd/*/* branches) → default to worktree. Added `getMergeToMainMode()` helper. + +Gated 5 sites in auto.ts: fresh-start worktree creation, resume worktree re-entry, milestone merge, and two slice merge routing sites. When `merge_to_main: "slice"`, slices merge to main via mergeSliceToMain instead of mergeSliceToMilestone, even in worktree mode. + +Resolved 3 merge conflict regions in auto-worktree.ts and 1 in auto.ts from S03 merge. Fixed Unicode characters in JSDoc comments that broke Node's --experimental-strip-types parser. + +## Verification + +- `npx tsc --noEmit` — zero errors +- `preferences-git.test.ts` — 21 assertions pass (valid/invalid/undefined for both fields, combined) +- `isolation-resolver.test.ts` — 4 assertions pass (default/legacy/explicit worktree/explicit branch) +- `grep '<<<<<<' auto-worktree.ts` — 0 matches (all conflicts resolved) + +## Requirements Advanced + +- R033 — git.isolation preference implemented with validation and three-tier resolver +- R034 — git.merge_to_main preference implemented with validation and auto.ts merge routing +- R038 — Backwards compatibility ensured: legacy detection defaults existing projects to branch mode + +## Requirements Validated + +- R033 — git.isolation preference validated: Set-based validation rejects invalid values, resolver correctly handles explicit pref, legacy detection, and default. 25 test assertions cover all paths. +- R034 — git.merge_to_main preference validated: validation rejects invalid values, auto.ts routes slice merges to main or milestone branch based on preference. Tested alongside isolation. + +## New Requirements Surfaced + +- none + +## Requirements Invalidated or Re-scoped + +- none + +## Deviations + +- Exported `validatePreferences` (was module-private) for direct test access — no downstream impact. +- Added `overridePrefs` parameter to `shouldUseWorktreeIsolation` — loadEffectiveGSDPreferences uses module-level path constant, making chdir-based test fixtures unreliable. +- Fixed Unicode characters in JSDoc comments — Node's strip-types parser misinterprets `/*` inside backtick-quoted strings within `/** */` comments. + +## Known Limitations + +- `auto-worktree.test.ts` (pre-existing) may still have Unicode issues from S03 merge content — not in scope for this slice. +- The legacy detection heuristic (`git branch --list 'gsd/*/*'`) requires git CLI — won't work in environments without git. + +## Follow-ups + +- S07 should add integration tests verifying the full preference → behavior flow (set isolation: "branch" → confirm no worktree created). +- Other test files may need the same Unicode fix applied in auto-worktree.ts. + +## Files Created/Modified + +- `src/resources/extensions/gsd/git-service.ts` — added isolation and merge_to_main to GitPreferences +- `src/resources/extensions/gsd/preferences.ts` — added validation for both fields, exported validatePreferences +- `src/resources/extensions/gsd/auto-worktree.ts` — resolved conflicts, added shouldUseWorktreeIsolation + getMergeToMainMode, fixed Unicode +- `src/resources/extensions/gsd/auto.ts` — resolved import conflict, gated 5 worktree/merge sites +- `src/resources/extensions/gsd/tests/preferences-git.test.ts` — new: 21 assertions for git preference validation +- `src/resources/extensions/gsd/tests/isolation-resolver.test.ts` — new: 4 assertions for resolver logic + +## Forward Intelligence + +### What the next slice should know +- The preference system is fully wired. `shouldUseWorktreeIsolation()` and `getMergeToMainMode()` are the two entry points all downstream code should use. + +### What's fragile +- Node's `--experimental-strip-types` chokes on Unicode in JSDoc comments — any new functions with fancy chars in comments will break tests. + +### Authoritative diagnostics +- `validatePreferences({ git: { isolation: "bad" } }).errors` — structured error messages for invalid prefs +- Auto-mode notify messages ("Created auto-worktree" vs absence) indicate which mode is active + +### What assumptions changed +- None — the plan was accurate. diff --git a/.gsd/milestones/M003/slices/S04/S04-UAT.md b/.gsd/milestones/M003/slices/S04/S04-UAT.md new file mode 100644 index 000000000..432a8387d --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/S04-UAT.md @@ -0,0 +1,109 @@ +# S04: Preferences + backwards compatibility — UAT + +**Milestone:** M003 +**Written:** 2026-03-14 + +## UAT Type + +- UAT mode: artifact-driven +- Why this mode is sufficient: Preferences are configuration logic — validation and routing are fully testable through automated tests and CLI inspection. No live runtime or visual verification needed. + +## Preconditions + +- Project checked out with current S04 changes +- Node.js available with `--experimental-strip-types` support +- The resolve-ts.mjs loader is present at `src/resources/extensions/gsd/tests/resolve-ts.mjs` + +## Smoke Test + +Run `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/preferences-git.test.ts` — should show 21 assertions passing. + +## Test Cases + +### 1. git.isolation accepts valid values + +1. Run: `node -e "const {validatePreferences} = require('./dist/preferences.js'); console.log(JSON.stringify(validatePreferences({git:{isolation:'worktree'}})))"` + (Or use the test: `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/preferences-git.test.ts`) +2. **Expected:** errors array is empty, `preferences.git.isolation` is `"worktree"`. Same for `"branch"`. + +### 2. git.isolation rejects invalid values + +1. Call `validatePreferences({ git: { isolation: "invalid" } })` +2. **Expected:** errors array contains a string mentioning `"isolation"` and listing valid values. + +### 3. git.merge_to_main accepts valid values + +1. Call `validatePreferences({ git: { merge_to_main: "milestone" } })` +2. **Expected:** errors array is empty, value preserved. Same for `"slice"`. + +### 4. git.merge_to_main rejects invalid values + +1. Call `validatePreferences({ git: { merge_to_main: "invalid" } })` +2. **Expected:** errors array contains a string mentioning `"merge_to_main"`. + +### 5. shouldUseWorktreeIsolation with explicit preference + +1. Call `shouldUseWorktreeIsolation("/tmp/test", { git: { isolation: "branch" } })` +2. **Expected:** returns `false` +3. Call with `{ git: { isolation: "worktree" } }` +4. **Expected:** returns `true` + +### 6. shouldUseWorktreeIsolation with legacy detection + +1. In a git repo with `gsd/M001/S01` branch, call `shouldUseWorktreeIsolation(repoPath)` +2. **Expected:** returns `false` (legacy project detected) + +### 7. shouldUseWorktreeIsolation default (new project) + +1. In a git repo with no `gsd/*` branches, call `shouldUseWorktreeIsolation(repoPath)` +2. **Expected:** returns `true` (new project defaults to worktree) + +### 8. No merge conflict markers remain + +1. Run: `grep -c '<<<<<<' src/resources/extensions/gsd/auto-worktree.ts` +2. **Expected:** returns 0 + +### 9. TypeScript compiles clean + +1. Run: `npx tsc --noEmit` +2. **Expected:** zero errors + +## Edge Cases + +### Both fields invalid simultaneously + +1. Call `validatePreferences({ git: { isolation: "bad", merge_to_main: "bad" } })` +2. **Expected:** errors array contains two entries, one for each field. + +### Undefined fields pass through + +1. Call `validatePreferences({ git: { auto_push: true } })` (no isolation or merge_to_main) +2. **Expected:** errors array is empty, isolation and merge_to_main are undefined. + +### Non-string type for preference values + +1. Call `validatePreferences({ git: { isolation: 42 } })` +2. **Expected:** errors array is non-empty (rejects non-string types). + +## Failure Signals + +- Any test assertion failure in preferences-git.test.ts or isolation-resolver.test.ts +- TypeScript compilation errors +- Merge conflict markers (`<<<<<<`) found in auto-worktree.ts +- Auto-mode creating worktrees when `git.isolation: "branch"` is set + +## Requirements Proved By This UAT + +- R033 — git.isolation preference validated and respected +- R034 — git.merge_to_main preference validated and respected +- R038 — Backwards compatibility: legacy detection defaults existing projects to branch mode + +## Not Proven By This UAT + +- R038 full integration — running a complete auto-mode session in branch mode vs worktree mode (deferred to S07) +- merge_to_main: "slice" + isolation: "worktree" end-to-end merge behavior (logic is wired but not integration-tested) + +## Notes for Tester + +- The automated tests are the primary verification. Run them with the resolve-ts loader as shown in the smoke test. +- The shouldUseWorktreeIsolation tests use the `overridePrefs` parameter to avoid filesystem setup for preference loading. diff --git a/.gsd/milestones/M003/slices/S04/tasks/T01-PLAN.md b/.gsd/milestones/M003/slices/S04/tasks/T01-PLAN.md new file mode 100644 index 000000000..8f621a54e --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/tasks/T01-PLAN.md @@ -0,0 +1,58 @@ +--- +estimated_steps: 8 +estimated_files: 6 +--- + +# T01: Resolve merge conflicts + add git preferences + resolver + gate auto.ts + +**Slice:** S04 — Preferences + backwards compatibility +**Milestone:** M003 + +## Description + +Single task covering the full S04 scope: resolve inherited merge conflicts in auto-worktree.ts, add `git.isolation` and `git.merge_to_main` preferences with validation, implement `shouldUseWorktreeIsolation()` resolver with legacy detection heuristic, gate all worktree creation/entry/merge sites in auto.ts behind the preferences, and write tests proving the contract. This is one task because all pieces are tightly coupled — the interface change, validation, resolver, and gating form a single logical unit with ~8 steps across 6 files. + +## Steps + +1. Resolve merge conflict markers in `auto-worktree.ts` — accept both HEAD (no new imports) and S03 (mergeMilestoneToMain function + helpers). Verify no `<<<<` markers remain. +2. Add `isolation?: "worktree" | "branch"` and `merge_to_main?: "milestone" | "slice"` to `GitPreferences` interface in `git-service.ts`. +3. Add validation blocks for both new fields in `validatePreferences()` in `preferences.ts`, following the existing `merge_strategy` Set-based pattern. +4. Add `shouldUseWorktreeIsolation(basePath: string): boolean` to `auto-worktree.ts`. Logic: load preferences → if `git.isolation` is set, return it === "worktree" → else run `git branch --list 'gsd/*/*'` → if branches exist, return false (legacy) → else return true (new project default). +5. In `auto.ts` fresh-start (~785) and resume (~620): wrap worktree creation/entry blocks with `if (shouldUseWorktreeIsolation(originalBasePath || base))`. +6. In `auto.ts` milestone merge (~1735): wrap `mergeMilestoneToMain` call with check for `merge_to_main !== "slice"` (skip milestone merge when user wants slice-level merging). +7. In `auto.ts` slice merge routing (~558 and ~1603): when `merge_to_main === "slice"`, force `mergeSliceToMain` path even when `isInAutoWorktree()` is true. +8. Write test files: `preferences-git.test.ts` (validation of new fields) and `isolation-resolver.test.ts` (resolver logic with mocked preferences and git state). + +## Must-Haves + +- [ ] `GitPreferences` interface extended with both new fields +- [ ] Validation rejects invalid values with clear error messages +- [ ] `shouldUseWorktreeIsolation` checks preference first, then legacy heuristic, then defaults to worktree +- [ ] All 3 worktree sites in auto.ts gated +- [ ] `merge_to_main: "slice"` overrides merge routing even in worktree mode +- [ ] Merge conflicts in auto-worktree.ts fully resolved +- [ ] Tests pass for preference validation and resolver logic + +## Verification + +- `npx tsc --noEmit` — zero errors +- `node --test src/resources/extensions/gsd/tests/preferences-git.test.ts` — all pass +- `node --test src/resources/extensions/gsd/tests/isolation-resolver.test.ts` — all pass +- `grep -c '<<<<<<' src/resources/extensions/gsd/auto-worktree.ts` returns 0 + +## Inputs + +- `src/resources/extensions/gsd/git-service.ts` — existing `GitPreferences` interface (lines 31-39) +- `src/resources/extensions/gsd/preferences.ts` — existing `validatePreferences()` with Set-based pattern (lines 860-912) +- `src/resources/extensions/gsd/auto-worktree.ts` — S01 lifecycle functions + S03 merge functions (with conflict markers) +- `src/resources/extensions/gsd/auto.ts` — worktree creation/entry at ~785/~620, merge routing at ~558/~1603, milestone merge at ~1735 +- S01 summary — `shouldUseWorktreeIsolation` must use `originalBasePath` for legacy detection + +## Expected Output + +- `src/resources/extensions/gsd/git-service.ts` — `GitPreferences` with 2 new optional fields +- `src/resources/extensions/gsd/preferences.ts` — 2 new validation blocks +- `src/resources/extensions/gsd/auto-worktree.ts` — conflict-free, with `shouldUseWorktreeIsolation()` exported +- `src/resources/extensions/gsd/auto.ts` — 5 sites gated behind preferences +- `src/resources/extensions/gsd/tests/preferences-git.test.ts` — preference validation tests +- `src/resources/extensions/gsd/tests/isolation-resolver.test.ts` — resolver logic tests diff --git a/.gsd/milestones/M003/slices/S04/tasks/T01-SUMMARY.md b/.gsd/milestones/M003/slices/S04/tasks/T01-SUMMARY.md new file mode 100644 index 000000000..f98b149db --- /dev/null +++ b/.gsd/milestones/M003/slices/S04/tasks/T01-SUMMARY.md @@ -0,0 +1,92 @@ +--- +id: T01 +parent: S04 +milestone: M003 +provides: + - git.isolation and git.merge_to_main preference validation + - shouldUseWorktreeIsolation resolver with legacy detection + - getMergeToMainMode helper + - All worktree sites in auto.ts gated behind preferences +key_files: + - src/resources/extensions/gsd/git-service.ts + - src/resources/extensions/gsd/preferences.ts + - src/resources/extensions/gsd/auto-worktree.ts + - src/resources/extensions/gsd/auto.ts + - src/resources/extensions/gsd/tests/preferences-git.test.ts + - src/resources/extensions/gsd/tests/isolation-resolver.test.ts +key_decisions: + - shouldUseWorktreeIsolation accepts optional overridePrefs parameter for testability (loadEffectiveGSDPreferences uses module-level cwd constant) + - validatePreferences exported (was private) so tests can call it directly + - Replaced Unicode arrows/dashes in auto-worktree.ts JSDoc comments to fix Node --experimental-strip-types parser +patterns_established: + - Set-based validation pattern extended for isolation and merge_to_main fields + - Preference override parameter pattern for functions that load preferences internally +observability_surfaces: + - Preference validation errors surface as structured strings in errors array + - Worktree creation/skip observable through auto-mode notify messages +duration: 30m +verification_result: passed +completed_at: 2026-03-14 +blocker_discovered: false +--- + +# T01: Resolve merge conflicts + add git preferences + resolver + gate auto.ts + +**Added git.isolation and git.merge_to_main preferences with validation, resolver, and gating across all worktree sites in auto.ts** + +## What Happened + +1. Resolved all merge conflict markers in `auto-worktree.ts` (3 conflict regions from S03 merge) and `auto.ts` (1 conflict in imports). Kept both HEAD and S03 content: imports for `parseRoadmap`/`loadEffectiveGSDPreferences` and the full `mergeMilestoneToMain` function with helpers. + +2. Extended `GitPreferences` interface with `isolation?: "worktree" | "branch"` and `merge_to_main?: "milestone" | "slice"`. + +3. Added Set-based validation blocks for both new fields in `validatePreferences()`, following the existing `merge_strategy` pattern. Also exported `validatePreferences` (was private) for direct test access. + +4. Implemented `shouldUseWorktreeIsolation(basePath, overridePrefs?)` in `auto-worktree.ts` with three-tier resolution: explicit preference > legacy branch detection (`gsd/*/*` branches) > default to worktree. Added `getMergeToMainMode()` helper. + +5. Gated 5 sites in `auto.ts`: + - Fresh-start worktree creation (~785): `shouldUseWorktreeIsolation(base)` + - Resume worktree re-entry (~620): `shouldUseWorktreeIsolation(originalBasePath)` + - Milestone merge (~1735): `getMergeToMainMode() === "milestone"` + - Two slice merge routing sites (~558, ~1603): `getMergeToMainMode() !== "slice"` controls whether `mergeSliceToMilestone` or `mergeSliceToMain` is called + +6. Fixed Unicode characters (`→`, `—`, backtick-quoted `gsd/*/*`) in JSDoc comments that caused Node's `--experimental-strip-types` parser to fail. + +## Verification + +- `npx tsc --noEmit` — zero errors +- `node --test preferences-git.test.ts` — 21 assertions, all pass (valid/invalid/undefined for both fields) +- `node --test isolation-resolver.test.ts` — 4 assertions, all pass (default/legacy/explicit worktree/explicit branch) +- `grep -c '<<<<<<' auto-worktree.ts` — returns 0 + +Slice-level verification status (this is the only task): +- [x] `npx tsc --noEmit` — clean build +- [x] `node --test preferences-git.test.ts` — pass +- [x] `node --test isolation-resolver.test.ts` — pass +- [x] Grep for `<<<<` in auto-worktree.ts — 0 matches + +## Diagnostics + +- Invalid preference values produce errors matching `"git. must be one of: "` pattern +- Worktree vs branch mode observable through auto-mode notify messages (presence/absence of "Created auto-worktree" or "Entered auto-worktree") +- `shouldUseWorktreeIsolation` can be tested with `overridePrefs` parameter without filesystem setup + +## Deviations + +- Made `validatePreferences` exported (was module-private) — needed for direct test access without going through the full file-loading pipeline. +- Added `overridePrefs` parameter to `shouldUseWorktreeIsolation` — `loadEffectiveGSDPreferences` computes paths at module load time from `process.cwd()`, making chdir-based test fixtures unreliable. +- Replaced Unicode box-drawing and arrow characters in auto-worktree.ts JSDoc comments — Node's `--experimental-strip-types` parser incorrectly interprets `/*` inside backtick-quoted strings within `/** */` comments. + +## Known Issues + +- `auto-worktree.test.ts` (pre-existing, not part of this task) was already broken by S03's merge adding content that triggers the same strip-types parser bug. The Unicode fix in this task only covers auto-worktree.ts; other files may have similar issues. + +## Files Created/Modified + +- `src/resources/extensions/gsd/git-service.ts` — added isolation and merge_to_main fields to GitPreferences +- `src/resources/extensions/gsd/preferences.ts` — added validation for both new fields, exported validatePreferences +- `src/resources/extensions/gsd/auto-worktree.ts` — resolved conflicts, added shouldUseWorktreeIsolation + getMergeToMainMode, fixed Unicode chars +- `src/resources/extensions/gsd/auto.ts` — resolved import conflict, gated 5 worktree/merge sites behind preferences +- `src/resources/extensions/gsd/tests/preferences-git.test.ts` — new: validates git.isolation and git.merge_to_main preference fields +- `src/resources/extensions/gsd/tests/isolation-resolver.test.ts` — new: validates shouldUseWorktreeIsolation resolver logic +- `.gsd/milestones/M003/slices/S04/S04-PLAN.md` — added observability section, marked T01 done diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d2804e094..cafa4e09e 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1,5 +1,5 @@ /** - * GSD Auto-Worktree — lifecycle management for auto-mode worktrees. + * GSD Auto-Worktree -- lifecycle management for auto-mode worktrees. * * Auto-mode creates worktrees with `milestone/` branches (distinct from * manual `/worktree` which uses `worktree/` branches). This module @@ -27,17 +27,54 @@ import { nativeBranchExists, nativeCommitCountBetween, } from "./native-git-bridge.js"; -<<<<<<< HEAD -======= import { parseRoadmap } from "./files.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; ->>>>>>> gsd/M003/S03 // ─── Module State ────────────────────────────────────────────────────────── /** Original project root before chdir into auto-worktree. */ let originalBase: string | null = null; +// ─── Isolation Resolver ──────────────────────────────────────────────────── + +/** + * Determine whether auto-mode should use worktree isolation. + * + * Resolution order: + * 1. Explicit git.isolation preference -> return (isolation === "worktree") + * 2. Legacy detection: if gsd branches exist -> return false (branch mode) + * 3. Default: return true (worktree mode for new projects) + */ +export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean { + const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git; + if (prefs?.isolation) { + return prefs.isolation === "worktree"; + } + + // Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern) + try { + const output = execSync("git branch --list 'gsd/*/*'", { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + if (output) return false; // Legacy branch-per-slice project + } catch { + // If git command fails, default to worktree + } + + return true; // New project default +} + +/** + * Resolve the merge_to_main preference value. + * Returns "milestone" (default) or "slice". + */ +export function getMergeToMainMode(): "milestone" | "slice" { + const prefs = loadEffectiveGSDPreferences()?.preferences?.git; + return prefs?.merge_to_main ?? "milestone"; +} + // ─── Git Helpers (local, mirrors worktree-command.ts pattern) ────────────── function resolveGitHeadPath(dir: string): string | null { @@ -110,7 +147,7 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin originalBase = basePath; } catch (err) { // If chdir fails, the worktree was created but we couldn't enter it. - // Don't store originalBase — caller can retry or clean up. + // Don't store originalBase -- caller can retry or clean up. throw new Error( `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -165,7 +202,7 @@ export function getAutoWorktreePath(basePath: string, milestoneId: string): stri /** * Enter an existing auto-worktree (chdir into it, store originalBase). - * Use for resume — the worktree already exists from a prior create. + * Use for resume -- the worktree already exists from a prior create. * * Atomic: chdir + originalBase update in same try block. */ @@ -198,7 +235,7 @@ export function getAutoWorktreeOriginalBase(): string | null { return originalBase; } -// ─── Merge Slice → Milestone ─────────────────────────────────────────────── +// ─── Merge Slice -> Milestone ─────────────────────────────────────────────── /** * Merge a completed slice branch into the milestone branch via `--no-ff`. @@ -322,10 +359,8 @@ export function mergeSliceToMilestone( deletedBranch, }; } -<<<<<<< HEAD -======= -// ─── Merge Milestone → Main ─────────────────────────────────────────────── +// ─── Merge Milestone -> Main ─────────────────────────────────────────────── /** * Auto-commit any dirty (uncommitted) state in the given directory. @@ -435,7 +470,7 @@ export function mergeMilestoneToMain( } catch (innerErr) { if (innerErr instanceof MergeConflictError) throw innerErr; } - // Possibly "already up to date" — fall through to commit which will handle nothing-to-commit + // Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit } // 8. Commit (handle nothing-to-commit gracefully) @@ -447,7 +482,7 @@ export function mergeMilestoneToMain( encoding: "utf-8", }); } catch (err: unknown) { - // execSync errors have stdout/stderr as properties — check those for git's message + // execSync errors have stdout/stderr as properties -- check those for git's message const errObj = err as { stdout?: string; stderr?: string; message?: string }; const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" "); if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) { @@ -477,7 +512,7 @@ export function mergeMilestoneToMain( try { removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false }); } catch { - // Best-effort — worktree dir may already be gone + // Best-effort -- worktree dir may already be gone } // 11. Delete milestone branch (after worktree removal so ref is unlocked) @@ -497,4 +532,3 @@ export function mergeMilestoneToMain( return { commitMessage, pushed }; } ->>>>>>> gsd/M003/S03 diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 0ff55cd29..ed7b8e323 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -94,10 +94,9 @@ import { getAutoWorktreePath, getAutoWorktreeOriginalBase, mergeSliceToMilestone, -<<<<<<< HEAD -======= mergeMilestoneToMain, ->>>>>>> gsd/M003/S03 + shouldUseWorktreeIsolation, + getMergeToMainMode, } from "./auto-worktree.js"; import type { GitPreferences } from "./git-service.js"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; @@ -562,7 +561,7 @@ async function mergeOrphanedSliceBranches( ); try { let mergeResult; - if (isInAutoWorktree(base)) { + if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") { mergeResult = mergeSliceToMilestone( base, milestoneId, sliceId, sliceEntry.title || sliceId, ); @@ -625,7 +624,7 @@ export async function startAuto( if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId); // ── Auto-worktree: re-enter worktree on resume if not already inside ── - if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath)) { + if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) { try { const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId); if (existingWtPath) { @@ -789,7 +788,7 @@ export async function startAuto( // ── Auto-worktree: create or enter worktree for the active milestone ── // Store the original project root before any chdir so we can restore on stop. originalBasePath = base; - if (currentMilestoneId) { + if (currentMilestoneId && shouldUseWorktreeIsolation(base)) { try { const existingWtPath = getAutoWorktreePath(base, currentMilestoneId); if (existingWtPath) { @@ -1631,7 +1630,7 @@ async function dispatchNextUnit( try { const sliceTitleForMerge = sliceEntry.title || branchSid; let mergeResult; - if (isInAutoWorktree(basePath)) { + if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") { mergeResult = mergeSliceToMilestone( basePath, branchMid, branchSid, sliceTitleForMerge, ); @@ -1763,7 +1762,7 @@ async function dispatchNextUnit( } catch { /* non-fatal */ } // ── Milestone merge: squash-merge milestone branch to main before stopping ── - if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) { + if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") { try { const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP"); const roadmapContent = readFileSync(roadmapPath, "utf-8"); diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 89c20dd7d..5dea28188 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -37,6 +37,8 @@ export interface GitPreferences { commit_type?: string; main_branch?: string; merge_strategy?: "squash" | "merge"; + isolation?: "worktree" | "branch"; + merge_to_main?: "milestone" | "slice"; } export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 283f2dda4..f2f7bef66 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -634,7 +634,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr }; } -function validatePreferences(preferences: GSDPreferences): { +export function validatePreferences(preferences: GSDPreferences): { preferences: GSDPreferences; errors: string[]; } { @@ -905,6 +905,22 @@ function validatePreferences(preferences: GSDPreferences): { errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)"); } } + if (g.isolation !== undefined) { + const validIsolation = new Set(["worktree", "branch"]); + if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) { + git.isolation = g.isolation as "worktree" | "branch"; + } else { + errors.push("git.isolation must be one of: worktree, branch"); + } + } + if (g.merge_to_main !== undefined) { + const validMergeToMain = new Set(["milestone", "slice"]); + if (typeof g.merge_to_main === "string" && validMergeToMain.has(g.merge_to_main)) { + git.merge_to_main = g.merge_to_main as "milestone" | "slice"; + } else { + errors.push("git.merge_to_main must be one of: milestone, slice"); + } + } if (Object.keys(git).length > 0) { validated.git = git as GitPreferences; diff --git a/src/resources/extensions/gsd/tests/isolation-resolver.test.ts b/src/resources/extensions/gsd/tests/isolation-resolver.test.ts new file mode 100644 index 000000000..0973a2105 --- /dev/null +++ b/src/resources/extensions/gsd/tests/isolation-resolver.test.ts @@ -0,0 +1,107 @@ +/** + * isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver. + * + * Tests three resolution paths: + * 1. Explicit git.isolation preference overrides everything + * 2. Legacy detection: existing gsd/*\/* branches = branch mode + * 3. Default: new project = worktree mode + */ + +import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { shouldUseWorktreeIsolation } from "../auto-worktree.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, report } = createTestContext(); + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function createTempRepo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-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); + return dir; +} + +async function main(): Promise { + const savedCwd = process.cwd(); + + console.log("\n=== shouldUseWorktreeIsolation ==="); + + // Test 1: New project with no gsd branches → defaults to worktree (true) + { + const dir = createTempRepo(); + try { + const result = shouldUseWorktreeIsolation(dir); + assertEq(result, true, "new project defaults to worktree isolation"); + } finally { + process.chdir(savedCwd); + rmSync(dir, { recursive: true, force: true }); + } + } + + // Test 2: Legacy project with gsd/*/* branches → returns false (branch mode) + { + const dir = createTempRepo(); + try { + // Create a legacy gsd/*/* branch + run("git checkout -b gsd/M001/S01", dir); + writeFileSync(join(dir, "slice.md"), "# S01\n"); + run("git add .", dir); + run("git commit -m 'slice work'", dir); + run("git checkout main", dir); + + const result = shouldUseWorktreeIsolation(dir); + assertEq(result, false, "legacy project with gsd branches → branch mode"); + } finally { + process.chdir(savedCwd); + rmSync(dir, { recursive: true, force: true }); + } + } + + // Test 3: Explicit preference override -- isolation: "worktree" + { + const dir = createTempRepo(); + try { + // Create legacy branches that would normally trigger branch mode + run("git checkout -b gsd/M001/S01", dir); + writeFileSync(join(dir, "slice.md"), "# S01\n"); + run("git add .", dir); + run("git commit -m 'slice work'", dir); + run("git checkout main", dir); + + const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" }); + assertEq(result, true, "explicit isolation: worktree overrides legacy detection"); + } finally { + process.chdir(savedCwd); + rmSync(dir, { recursive: true, force: true }); + } + } + + // Test 4: Explicit preference override -- isolation: "branch" + { + const dir = createTempRepo(); + try { + // No legacy branches -- would normally default to worktree + const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" }); + assertEq(result, false, "explicit isolation: branch overrides default"); + } finally { + process.chdir(savedCwd); + rmSync(dir, { recursive: true, force: true }); + } + } + + report(); +} + +main(); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts new file mode 100644 index 000000000..616cc9f9c --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences-git.test.ts @@ -0,0 +1,88 @@ +/** + * preferences-git.test.ts — Validates git.isolation and git.merge_to_main preference fields. + */ + +import { createTestContext } from "./test-helpers.ts"; +import { validatePreferences } from "../preferences.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +async function main(): Promise { + console.log("\n=== git.isolation validation ==="); + + // Valid values + { + const { preferences, errors } = validatePreferences({ git: { isolation: "worktree" } }); + assertEq(errors.length, 0, "isolation: worktree — no errors"); + assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved"); + } + { + const { preferences, errors } = validatePreferences({ git: { isolation: "branch" } }); + assertEq(errors.length, 0, "isolation: branch — no errors"); + assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved"); + } + + // Invalid values + { + const { errors } = validatePreferences({ git: { isolation: "invalid" } }); + assertTrue(errors.length > 0, "isolation: invalid — produces error"); + assertTrue(errors[0].includes("isolation"), "isolation: invalid — error mentions isolation"); + } + { + const { errors } = validatePreferences({ git: { isolation: 42 } }); + assertTrue(errors.length > 0, "isolation: number — produces error"); + } + + // Undefined passes through + { + const { preferences, errors } = validatePreferences({ git: { auto_push: true } }); + assertEq(errors.length, 0, "isolation: undefined — no errors"); + assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set"); + } + + console.log("\n=== git.merge_to_main validation ==="); + + // Valid values + { + const { preferences, errors } = validatePreferences({ git: { merge_to_main: "milestone" } }); + assertEq(errors.length, 0, "merge_to_main: milestone — no errors"); + assertEq(preferences.git?.merge_to_main, "milestone", "merge_to_main: milestone — value preserved"); + } + { + const { preferences, errors } = validatePreferences({ git: { merge_to_main: "slice" } }); + assertEq(errors.length, 0, "merge_to_main: slice — no errors"); + assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main: slice — value preserved"); + } + + // Invalid values + { + const { errors } = validatePreferences({ git: { merge_to_main: "invalid" } }); + assertTrue(errors.length > 0, "merge_to_main: invalid — produces error"); + assertTrue(errors[0].includes("merge_to_main"), "merge_to_main: invalid — error mentions merge_to_main"); + } + { + const { errors } = validatePreferences({ git: { merge_to_main: false } }); + assertTrue(errors.length > 0, "merge_to_main: boolean — produces error"); + } + + // Undefined passes through + { + const { preferences, errors } = validatePreferences({ git: { auto_push: true } }); + assertEq(errors.length, 0, "merge_to_main: undefined — no errors"); + assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set"); + } + + console.log("\n=== both fields together ==="); + { + const { preferences, errors } = validatePreferences({ + git: { isolation: "worktree", merge_to_main: "slice" }, + }); + assertEq(errors.length, 0, "both fields valid — no errors"); + assertEq(preferences.git?.isolation, "worktree", "isolation preserved"); + assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main preserved"); + } + + report(); +} + +main();