feat(M001/S02): Wire GitService into codebase

This commit is contained in:
Lex Christopherson 2026-03-12 11:53:48 -06:00
parent 91cf23a634
commit dfe9527641
15 changed files with 548 additions and 168 deletions

@ -0,0 +1 @@
Subproject commit afcbdfa956c83b3bb6f5476bb02437d9edeeda10

@ -0,0 +1 @@
Subproject commit 5f7e040254970b356e4e70d9d1a307fe07e2209a

View file

@ -1,18 +0,0 @@
# GSD State
**Active Milestone:** M001 — Deterministic GitService
**Active Slice:** S02 — Wire GitService into codebase
**Phase:** researching
**Requirements Status:** 18 active · 0 validated · 3 deferred · 6 out of scope
## Milestone Registry
- 🔄 **M001:** Deterministic GitService
## Recent Decisions
- None recorded
## Blockers
- None
## Next Action
Research slice S02 (Wire GitService into codebase).

View file

@ -55,7 +55,7 @@ This milestone is complete only when all are true:
- [x] **S01: GitService core implementation** `risk:high` `depends:[]`
> After this: `git-service.ts` exists with commit, autoCommit, ensureSliceBranch, switchToMain, mergeSliceToMain, inferCommitType, smart staging — all passing unit tests in temp git repos.
- [ ] **S02: Wire GitService into codebase** `risk:high` `depends:[S01]`
- [x] **S02: Wire GitService into codebase** `risk:high` `depends:[S01]`
> After this: auto.ts and worktree.ts delegate to GitService. Git preferences schema added to preferences.ts. `npm run build` passes. Existing worktree tests still pass.
- [ ] **S03: Bug fixes and doc corrections** `risk:medium` `depends:[S02]`

View file

@ -0,0 +1,82 @@
# S02: Wire GitService into codebase
**Goal:** All git-mutation functions in `worktree.ts` delegate to `GitServiceImpl`. `auto.ts` creates a `GitServiceImpl` instance and routes its git calls through it. `preferences.ts` exposes `git?: GitPreferences` with validation, merge, and documentation.
**Demo:** `npm run build` passes. All three test suites pass: `worktree.test.ts`, `worktree-integration.test.ts`, `git-service.test.ts`. All 6+ consumers of `worktree.ts` continue to import and call functions without changes.
## Must-Haves
- All 10 `worktree.ts` exports preserved with identical signatures
- `worktree.ts` git-mutation functions (`getMainBranch`, `getCurrentBranch`, `ensureSliceBranch`, `autoCommitCurrentBranch`, `switchToMain`, `mergeSliceToMain`) delegate to `GitServiceImpl` internally
- `MergeSliceResult` re-exported via `export type` from `git-service.ts` (eliminate type duplication per D014)
- No circular dependency crash at module-evaluation time between `worktree.ts` and `git-service.ts`
- `auto.ts` creates `GitServiceImpl` instance with `basePath` and git preferences after `basePath` is set
- `auto.ts` callsites for `autoCommitCurrentBranch`, `ensureSliceBranch`, `switchToMain`, `mergeSliceToMain` route through GitService (via worktree.ts facade)
- `GSDPreferences` interface includes `git?: GitPreferences` field
- `validatePreferences()` validates git sub-fields
- `mergePreferences()` merges git preferences with override semantics
- `templates/preferences.md` documents git section
- `docs/preferences-reference.md` documents git preferences
- Existing tests pass without regressions
## Proof Level
- This slice proves: integration
- Real runtime required: no (existing test suites with temp git repos exercise the full contract)
- Human/UAT required: no
## Verification
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/worktree.test.ts` — all pass
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/worktree-integration.test.ts` — all pass
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/git-service.test.ts` — all pass
- `npx tsc --noEmit` — clean (no errors)
- `npm run test` — same pass/fail as baseline (116 pass, 2 pre-existing failures)
- `grep -n 'import.*from.*worktree' src/resources/extensions/gsd/state.ts src/resources/extensions/gsd/workspace-index.ts src/resources/extensions/gsd/worktree-command.ts` — unchanged, still importing from worktree.ts
## Observability / Diagnostics
- Runtime signals: `mergeSliceToMain` now returns commit messages with inferred conventional types (via `inferCommitType`) instead of hardcoded `feat`. `console.error` warning from `smartStage()` fallback signals exclusion failure.
- Inspection surfaces: Re-run any of the three test suites to verify delegation is working. `git log --oneline` after merge shows inferred commit types.
- Failure visibility: `runGit` errors include full command, basePath, and stderr. Lazy `GitServiceImpl` construction errors surface at first call, not module load.
- Redaction constraints: none (no secrets involved)
## Integration Closure
- Upstream surfaces consumed: `git-service.ts``GitServiceImpl`, `GitPreferences`, `MergeSliceResult`, all public methods (from S01)
- New wiring introduced in this slice: `worktree.ts` facade delegates to `GitServiceImpl`; `auto.ts` creates `GitServiceImpl` instance from `basePath` + preferences; `preferences.ts` exposes `git` field in `GSDPreferences`
- What remains before the milestone is truly usable end-to-end: S03 (bug fixes), S04 (remove git from prompts), S05 (enhanced features), S06 (cleanup/archive)
## Tasks
- [x] **T01: Convert worktree.ts to thin facade delegating to GitServiceImpl** `est:45m`
- Why: Core integration task — all 6+ consumers depend on worktree.ts exports being stable while internals switch to GitServiceImpl. Satisfies R005 (facade delegation) and R009 (inferCommitType replaces hardcoded feat).
- Files: `src/resources/extensions/gsd/worktree.ts`, `src/resources/extensions/gsd/tests/worktree.test.ts`, `src/resources/extensions/gsd/tests/worktree-integration.test.ts`
- Do: Import `GitServiceImpl`, `GitPreferences`, and re-export `MergeSliceResult` via `export type` (per D014) from `git-service.ts`. Add lazy `GitServiceImpl` cache with basePath-change guard. Convert 6 git-mutation functions to delegate. Remove unused private `branchExists` and `runGit`. Keep 4 pure functions and query functions unchanged. Run tests and fix any assertion mismatches.
- Verify: All three test suites pass. `npx tsc --noEmit` clean. No consumer import changes needed.
- Done when: `worktree.ts` delegates to `GitServiceImpl` for all git-mutation functions, all tests green, build clean.
- [x] **T02: Wire auto.ts to use GitServiceImpl via worktree.ts facade** `est:30m`
- Why: auto.ts is the primary orchestrator — it must have a `GitServiceImpl` instance for future direct usage and verify all callsites work through the T01 facade. Satisfies R006.
- Files: `src/resources/extensions/gsd/auto.ts`
- Do: Import `GitServiceImpl` and `GitPreferences` from `git-service.ts`. Add module-level `gitService` variable. Initialize after `basePath` is set in `startAutoMode()` using git preferences from `loadEffectiveGSDPreferences()`. Keep bootstrap git calls and idle detection inline per spec. Verify build and tests.
- Verify: `npx tsc --noEmit` clean. `npm run test` same baseline. `grep -c 'new GitServiceImpl' src/resources/extensions/gsd/auto.ts` shows 1.
- Done when: auto.ts has a `GitServiceImpl` instance created from preferences, build clean, tests pass.
- [x] **T03: Add git preferences to preferences.ts, template, and docs** `est:30m`
- Why: Preferences schema is needed for S05 features (auto_push, merge guards, snapshots) and must exist before those slices. Satisfies R004.
- Files: `src/resources/extensions/gsd/preferences.ts`, `src/resources/extensions/gsd/templates/preferences.md`, `src/resources/extensions/gsd/docs/preferences-reference.md`
- Do: Add `git?: GitPreferences` to `GSDPreferences` interface (import from `git-service.ts`). Add git validation in `validatePreferences()` for all 6 sub-fields. Add git merge in `mergePreferences()` with override-wins semantics. Add `git:` section to preferences template. Document git preferences in reference doc.
- Verify: `npx tsc --noEmit` clean. `npm run test` same baseline. `grep 'git.*GitPreferences' src/resources/extensions/gsd/preferences.ts` confirms field.
- Done when: `GSDPreferences.git` field exists with full validation, merge, template, and reference doc.
## Files Likely Touched
- `src/resources/extensions/gsd/worktree.ts`
- `src/resources/extensions/gsd/auto.ts`
- `src/resources/extensions/gsd/preferences.ts`
- `src/resources/extensions/gsd/git-service.ts` (minor — only if re-export adjustments needed)
- `src/resources/extensions/gsd/templates/preferences.md`
- `src/resources/extensions/gsd/docs/preferences-reference.md`
- `src/resources/extensions/gsd/tests/worktree.test.ts` (assertion updates if needed)
- `src/resources/extensions/gsd/tests/worktree-integration.test.ts` (assertion updates if needed)

View file

@ -0,0 +1,66 @@
---
estimated_steps: 5
estimated_files: 3
---
# T01: Convert worktree.ts to thin facade delegating to GitServiceImpl
**Slice:** S02 — Wire GitService into codebase
**Milestone:** M001
## Description
Convert `worktree.ts` from a standalone git-operations module into a thin facade that preserves all 10 exports but delegates git-mutation functions to `GitServiceImpl` from `git-service.ts`. Pure utility functions stay as-is. The `MergeSliceResult` type duplication is eliminated by re-exporting from `git-service.ts` using `export type` per D014.
The circular dependency between `git-service.ts` (imports pure functions from `worktree.ts`) and `worktree.ts` (imports `GitServiceImpl` from `git-service.ts`) is handled by lazy construction: `GitServiceImpl` is only instantiated inside function bodies at call-time, never at module-evaluation time. The pure functions consumed by `git-service.ts` are available immediately at module evaluation.
## Steps
1. **Replace `MergeSliceResult` with type re-export:** Remove the local `MergeSliceResult` interface definition in `worktree.ts` and replace with `export type { MergeSliceResult } from "./git-service.ts"` (per D014 — type-only re-export avoids ESM cycle issues). Verify this doesn't break any consumer.
2. **Add lazy GitServiceImpl cache:** Import `GitServiceImpl` from `git-service.ts`. Add a `let cachedService: GitServiceImpl | null = null` and a `function getService(basePath: string): GitServiceImpl` that creates or returns the cached instance with `{}` default prefs. Include a basePath-change guard that resets the cache if basePath changes between calls.
3. **Convert 6 git-mutation functions to delegate:** Replace the bodies of `getMainBranch`, `getCurrentBranch`, `ensureSliceBranch`, `autoCommitCurrentBranch`, `switchToMain`, `mergeSliceToMain` with calls to the corresponding `GitServiceImpl` methods via `getService(basePath)`. Keep all function signatures identical. For `autoCommitCurrentBranch(basePath, unitType, unitId)`, map to `svc.autoCommit(unitType, unitId)`. For `mergeSliceToMain`, the GitServiceImpl version uses `inferCommitType` instead of hardcoded `feat` — this is the intended R009 fix.
4. **Remove now-unused private code:** Delete the private `branchExists()` function and private `runGit()` function from `worktree.ts` — these are now handled by `GitServiceImpl` and `git-service.ts` respectively. Keep `isOnSliceBranch()` and `getActiveSliceBranch()` as-is (they use `getCurrentBranch` which now delegates). Keep all pure utility functions unchanged: `detectWorktreeName`, `getSliceBranchName`, `SLICE_BRANCH_RE`, `parseSliceBranch`.
5. **Run all test suites and fix any assertion mismatches:** Run `worktree.test.ts`, `worktree-integration.test.ts`, and `git-service.test.ts`. The worktree tests don't assert on `mergedCommitMessage` format, so the switch from hardcoded `feat(` to `inferCommitType` should be transparent. Verify `npm run build` passes.
## Must-Haves
- [ ] All 10 `worktree.ts` exports preserved with identical type signatures
- [ ] `MergeSliceResult` re-exported via `export type` from `git-service.ts` (per D014, no local duplicate)
- [ ] Lazy `GitServiceImpl` construction (call-time, not module-evaluation)
- [ ] BasePath-change guard on cached service
- [ ] No circular dependency crash at import time
- [ ] `worktree.test.ts` passes — all assertions
- [ ] `worktree-integration.test.ts` passes — all assertions
- [ ] `git-service.test.ts` passes — all assertions
- [ ] `npx tsc --noEmit` clean
## Verification
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/worktree.test.ts` — all pass
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/worktree-integration.test.ts` — all pass
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/git-service.test.ts` — all pass
- `npx tsc --noEmit` — no errors
- `grep -c 'GitServiceImpl' src/resources/extensions/gsd/worktree.ts` — at least 1 (confirms delegation wiring)
- `grep 'export type.*MergeSliceResult' src/resources/extensions/gsd/worktree.ts` — exactly 1 (type re-export per D014)
## Observability Impact
- Signals added/changed: `mergeSliceToMain` now returns commits with inferred conventional types (via `inferCommitType`) instead of hardcoded `feat`. This is an intentional R009 fix, not a regression.
- How a future agent inspects this: `git log --oneline` after merge shows the inferred commit type. Run worktree test suites to verify delegation.
- Failure state exposed: `runGit` errors from `git-service.ts` include command, basePath, and stderr. Lazy init errors surface at first function call, not import time.
## Inputs
- `src/resources/extensions/gsd/git-service.ts``GitServiceImpl` class, `GitPreferences` interface, `MergeSliceResult` interface (from S01)
- `src/resources/extensions/gsd/worktree.ts` — 10 exports, private `branchExists`, private `runGit`, `MergeSliceResult` duplicate
- D014 — `export type { MergeSliceResult }` decision
## Expected Output
- `src/resources/extensions/gsd/worktree.ts` — thin facade: imports `GitServiceImpl`, lazy cache, 6 functions delegate, 4 pure functions unchanged, `MergeSliceResult` type re-exported
- `src/resources/extensions/gsd/tests/worktree.test.ts` — unchanged (no assertion format changes needed)
- `src/resources/extensions/gsd/tests/worktree-integration.test.ts` — unchanged (no assertion format changes needed)

View file

@ -0,0 +1,75 @@
---
id: T01
parent: S02
milestone: M001
provides:
- worktree.ts thin facade delegating git-mutation functions to GitServiceImpl
- MergeSliceResult type re-export from git-service.ts (D014)
- Lazy GitServiceImpl cache with basePath-change guard
key_files:
- src/resources/extensions/gsd/worktree.ts
key_decisions:
- Lazy construction via getService() avoids circular dependency crash at module-evaluation time
- isOnSliceBranch and getActiveSliceBranch kept as local functions using getCurrentBranch (which delegates) — no need to route through GitServiceImpl since they're pure regex checks on the branch name
patterns_established:
- Lazy singleton GitServiceImpl cache with basePath-change guard for facade modules
observability_surfaces:
- mergeSliceToMain now returns commits with inferred conventional types via inferCommitType (R009 fix)
- smartStage() console.error fallback warning surfaces exclusion failures
- runGit errors include command, basePath, and stderr
duration: 10m
verification_result: passed
completed_at: 2026-03-12
blocker_discovered: false
---
# T01: Convert worktree.ts to thin facade delegating to GitServiceImpl
**Converted worktree.ts from standalone git module to thin facade — all 13 exports preserved, 6 git-mutation functions delegate to GitServiceImpl via lazy cache, MergeSliceResult re-exported per D014.**
## What Happened
1. Removed local `MergeSliceResult` interface, replaced with `export type { MergeSliceResult } from "./git-service.ts"` (D014 type-only re-export).
2. Added lazy `GitServiceImpl` cache: `cachedService`/`cachedBasePath` module-level variables with `getService(basePath)` factory that resets on basePath change. Construction happens at call-time only, never at module evaluation — avoids circular dependency crash.
3. Converted 6 git-mutation functions to delegate: `getMainBranch`, `getCurrentBranch`, `ensureSliceBranch`, `autoCommitCurrentBranch` (maps to `svc.autoCommit`), `switchToMain`, `mergeSliceToMain`. All function signatures identical.
4. Removed private `runGit()` and `branchExists()` — now handled by `GitServiceImpl` internals.
5. Kept pure utility functions unchanged: `detectWorktreeName`, `getSliceBranchName`, `SLICE_BRANCH_RE`, `parseSliceBranch`.
6. Kept `isOnSliceBranch` and `getActiveSliceBranch` as local functions that use `getCurrentBranch` (which delegates).
The circular dependency (git-service.ts imports pure functions from worktree.ts, worktree.ts imports GitServiceImpl from git-service.ts) works because GitServiceImpl is only instantiated inside function bodies at call-time, and the pure functions consumed by git-service.ts are available immediately at module evaluation.
## Verification
- `worktree.test.ts`: 56 passed, 0 failed ✓
- `worktree-integration.test.ts`: 40 passed, 0 failed ✓
- `git-service.test.ts`: 113 passed, 0 failed ✓
- `npx tsc --noEmit`: clean, no errors ✓
- `grep -c 'GitServiceImpl' worktree.ts` → 9 (confirms delegation wiring) ✓
- `grep 'export type.*MergeSliceResult' worktree.ts` → exactly 1 match ✓
- Consumer imports in state.ts, workspace-index.ts, worktree-command.ts unchanged ✓
### Slice-level verification (partial — T01 of 3):
- ✅ worktree.test.ts passes
- ✅ worktree-integration.test.ts passes
- ✅ git-service.test.ts passes
- ✅ npx tsc --noEmit clean
- ⏳ npm run test (full suite) — deferred to final task
- ✅ Consumer imports unchanged
## Diagnostics
- Run any of the three test suites to verify delegation is working
- `git log --oneline` after a merge shows inferred commit types (feat/fix/refactor/etc. instead of always feat)
- Lazy init errors surface at first function call, not import time — look for "git ... failed in ..." error messages
## Deviations
None. Plan executed as written.
## Known Issues
None.
## Files Created/Modified
- `src/resources/extensions/gsd/worktree.ts` — converted to thin facade: imports GitServiceImpl, lazy cache, 6 functions delegate, 4 pure functions unchanged, MergeSliceResult type re-exported

View file

@ -0,0 +1,59 @@
---
estimated_steps: 4
estimated_files: 1
---
# T02: Wire auto.ts to use GitServiceImpl via worktree.ts facade
**Slice:** S02 — Wire GitService into codebase
**Milestone:** M001
## Description
After T01 converts `worktree.ts` to a facade, all auto.ts calls already route through `GitServiceImpl` transparently. This task makes that explicit by creating a `GitServiceImpl` instance in auto.ts (for future direct usage by S05) and verifying all callsites work correctly through the facade. The instance is initialized after `basePath` is set, using git preferences from the preferences system.
Bootstrap git calls (`git rev-parse --git-dir`, `git init`, `git add -A .gsd .gitignore && git commit`) and idle detection (`git status --porcelain`) remain inline per milestone success criteria.
## Steps
1. **Add GitServiceImpl import and module-level variable:** Import `GitServiceImpl` and `GitPreferences` from `./git-service.ts`. Add `let gitService: GitServiceImpl | null = null;` at module level near the existing `let basePath = ""` declaration.
2. **Initialize GitServiceImpl after basePath is set:** In `startAutoMode()`, after `basePath = base` is set and after the git init/bootstrap block (line ~375), create the instance: `gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {})`. This must happen after the git repo is confirmed to exist. Note: `loadEffectiveGSDPreferences` is already imported in auto.ts.
3. **Verify all callsites compile and route correctly:** The 8 imports from `worktree.ts` remain unchanged — `autoCommitCurrentBranch`, `ensureSliceBranch`, `getCurrentBranch`, `getMainBranch`, `getSliceBranchName`, `parseSliceBranch`, `switchToMain`, `mergeSliceToMain`. These now delegate through the T01 facade. No call signature changes needed. Remove `getSliceBranchName` from imports if unused (line 65 — imported but not called in auto.ts).
4. **Verify build and tests pass:** Run `npx tsc --noEmit` and `npm run test` to confirm no regressions. The auto.ts changes are additive — existing behavior is preserved.
## Must-Haves
- [ ] `GitServiceImpl` imported from `git-service.ts`
- [ ] Module-level `gitService` variable initialized after `basePath` is set
- [ ] Instance created with git preferences from `loadEffectiveGSDPreferences()`
- [ ] Bootstrap git calls remain inline (lines 360-375)
- [ ] Idle detection `git status --porcelain` remains inline (line 2545)
- [ ] `npx tsc --noEmit` clean
- [ ] `npm run test` same baseline (116 pass, 2 pre-existing failures)
## Verification
- `npx tsc --noEmit` — no errors
- `npm run test` — same pass/fail as baseline
- `grep -c 'GitServiceImpl' src/resources/extensions/gsd/auto.ts` — at least 1
- `grep -c 'new GitServiceImpl' src/resources/extensions/gsd/auto.ts` — exactly 1
## Observability Impact
- Signals added/changed: None — this task adds the instance but doesn't change runtime behavior (facade handles delegation)
- How a future agent inspects this: `grep GitServiceImpl src/resources/extensions/gsd/auto.ts` to confirm the instance exists
- Failure state exposed: If preferences loading fails, `?? {}` ensures default empty prefs — no crash
## Inputs
- `src/resources/extensions/gsd/auto.ts` — orchestrator with 8 worktree.ts imports and git callsites
- `src/resources/extensions/gsd/worktree.ts` — T01 facade (all function calls now route to GitServiceImpl)
- `src/resources/extensions/gsd/git-service.ts``GitServiceImpl` constructor takes `(basePath, prefs?)`
- T01 completion — facade must be in place before this task verifies routing
## Expected Output
- `src/resources/extensions/gsd/auto.ts` — added `GitServiceImpl` import, module-level `gitService` variable, initialization in `startAutoMode()` after basePath is set

View file

@ -0,0 +1,62 @@
---
id: T02
parent: S02
milestone: M001
provides:
- GitServiceImpl instance in auto.ts initialized from basePath + git preferences
- Removed unused getSliceBranchName import from auto.ts
key_files:
- src/resources/extensions/gsd/auto.ts
key_decisions:
- Initialize gitService after bootstrap block (git init + .gsd mkdir) but before crash-lock check — ensures git repo exists
- Use `loadEffectiveGSDPreferences()?.preferences?.git ?? {}` for safe fallback to empty prefs
patterns_established:
- Module-level `gitService` variable with post-init construction in startAutoMode()
observability_surfaces:
- If preferences loading fails, `?? {}` ensures default empty prefs — no crash. gitService null before startAutoMode() runs.
duration: 8m
verification_result: passed
completed_at: 2026-03-12
blocker_discovered: false
---
# T02: Wire auto.ts to use GitServiceImpl via worktree.ts facade
**Added GitServiceImpl instance in auto.ts, initialized after basePath is set with git preferences from the preferences system. Removed unused `getSliceBranchName` import.**
## What Happened
Three changes to `auto.ts`:
1. Added imports for `GitServiceImpl` and `GitPreferences` from `git-service.ts`
2. Added module-level `let gitService: GitServiceImpl | null = null` next to the existing `basePath` declaration
3. Added initialization line after the bootstrap block in `startAutoMode()`: `gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {})`
4. Removed `getSliceBranchName` from the worktree.ts import list — it was imported but never used in auto.ts
All 8 remaining worktree.ts imports continue to work through the T01 facade unchanged. Bootstrap git calls and idle detection remain inline per spec.
## Verification
- `npx tsc --noEmit` — clean, no errors
- `npm run test` — 116 pass, 2 pre-existing failures (matches baseline)
- `grep -c 'GitServiceImpl' auto.ts` → 4 (import, type import, variable declaration, instantiation)
- `grep -c 'new GitServiceImpl' auto.ts` → 1 (exactly one instantiation)
- All three test suites pass: worktree.test.ts, worktree-integration.test.ts, git-service.test.ts
- Consumer imports unchanged: state.ts, workspace-index.ts, worktree-command.ts still import from worktree.ts
## Diagnostics
- `grep GitServiceImpl src/resources/extensions/gsd/auto.ts` confirms instance exists
- `gitService` is null before `startAutoMode()` runs — any premature access will surface as TypeError
- Preferences fallback `?? {}` means no crash even if preferences file is missing or malformed
## Deviations
Removed unused `getSliceBranchName` import as noted in the plan (step 3). This is a cleanup, not a behavioral change.
## Known Issues
None.
## Files Created/Modified
- `src/resources/extensions/gsd/auto.ts` — Added GitServiceImpl import, module-level variable, initialization in startAutoMode(), removed unused getSliceBranchName import

View file

@ -0,0 +1,74 @@
---
estimated_steps: 5
estimated_files: 3
---
# T03: Add git preferences to preferences.ts, template, and docs
**Slice:** S02 — Wire GitService into codebase
**Milestone:** M001
## Description
Add `git?: GitPreferences` to the `GSDPreferences` interface with full validation, merge semantics, documentation in the preferences template, and a reference doc entry. This enables all preference-gated git features in S05 (auto_push, merge guards, snapshots) via the existing preferences system.
The `GitPreferences` type already exists in `git-service.ts` — this task imports it and wires it into the preferences infrastructure.
## Steps
1. **Add `git` field to `GSDPreferences` interface:** Import `GitPreferences` from `./git-service.ts`. Add `git?: GitPreferences` to the `GSDPreferences` interface alongside the existing fields.
2. **Add git validation to `validatePreferences()`:** After the existing validation blocks, add a `git` section that validates each sub-field:
- `auto_push`: must be boolean if present
- `push_branches`: must be boolean if present
- `remote`: must be non-empty string if present
- `snapshots`: must be boolean if present
- `pre_merge_check`: must be boolean or the string `"auto"` if present
- `commit_type`: must be one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style` if present
Collect errors for invalid values. Copy valid values to `validated.git`.
3. **Add git merge to `mergePreferences()`:** Add `git: { ...(base.git ?? {}), ...(override.git ?? {}) }` to the merge return object. Override-wins for each field, same as `models` and `auto_supervisor`.
4. **Update preferences template:** Add a `git:` section to `src/resources/extensions/gsd/templates/preferences.md` showing all available fields with sensible defaults (empty/unset). Place it after the existing sections in the frontmatter block.
5. **Update preferences reference doc:** Add a "Git Preferences" section to `src/resources/extensions/gsd/docs/preferences-reference.md` documenting each field, its type, default value, and behavior. Include a YAML example.
## Must-Haves
- [ ] `GSDPreferences` interface includes `git?: GitPreferences`
- [ ] `GitPreferences` imported from `git-service.ts`
- [ ] `validatePreferences()` validates all 6 git sub-fields with type checking
- [ ] Invalid git values produce error messages (not silent drops)
- [ ] `mergePreferences()` merges git with override-wins semantics
- [ ] `templates/preferences.md` has `git:` section with all fields
- [ ] `docs/preferences-reference.md` documents git preferences
- [ ] `npx tsc --noEmit` clean
- [ ] `npm run test` same baseline
## Verification
- `npx tsc --noEmit` — no errors
- `npm run test` — same pass/fail as baseline
- `grep 'git.*GitPreferences' src/resources/extensions/gsd/preferences.ts` — confirms field exists
- `grep -c 'auto_push\|push_branches\|pre_merge_check\|snapshots\|commit_type' src/resources/extensions/gsd/preferences.ts` — at least 5 (validation of each field)
- `grep 'git:' src/resources/extensions/gsd/templates/preferences.md` — confirms template section
- `grep -i 'git preferences\|git:' src/resources/extensions/gsd/docs/preferences-reference.md` — confirms docs section
## Observability Impact
- Signals added/changed: None — this adds schema infrastructure only. Preferences are parsed at load time; invalid values produce error strings in the `validatePreferences` return.
- How a future agent inspects this: Call `loadEffectiveGSDPreferences()` and check `.preferences.git` for parsed values. Validation errors are in the return tuple.
- Failure state exposed: Invalid git preference values are reported as strings in the `errors` array from `validatePreferences()` — callers already log these.
## Inputs
- `src/resources/extensions/gsd/preferences.ts``GSDPreferences` interface, `validatePreferences()`, `mergePreferences()`
- `src/resources/extensions/gsd/git-service.ts``GitPreferences` interface
- `src/resources/extensions/gsd/templates/preferences.md` — existing template
- `src/resources/extensions/gsd/docs/preferences-reference.md` — existing reference doc
## Expected Output
- `src/resources/extensions/gsd/preferences.ts``git?: GitPreferences` in interface, validation logic, merge logic
- `src/resources/extensions/gsd/templates/preferences.md``git:` section added to frontmatter
- `src/resources/extensions/gsd/docs/preferences-reference.md` — git preferences documented with example

View file

@ -62,11 +62,12 @@ import {
ensureSliceBranch,
getCurrentBranch,
getMainBranch,
getSliceBranchName,
parseSliceBranch,
switchToMain,
mergeSliceToMain,
} from "./worktree.ts";
import { GitServiceImpl } from "./git-service.ts";
import type { GitPreferences } from "./git-service.ts";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
import { showNextAction } from "../shared/next-action-ui.js";
@ -124,6 +125,7 @@ let stepMode = false;
let verbose = false;
let cmdCtx: ExtensionCommandContext | null = null;
let basePath = "";
let gitService: GitServiceImpl | null = null;
/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
const unitDispatchCount = new Map<string, number>();
@ -388,6 +390,9 @@ export async function startAuto(
} catch { /* nothing to commit */ }
}
// Initialize GitServiceImpl — basePath is set and git repo confirmed
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
// Check for crash from previous session
const crashLock = readCrashLock(base);
if (crashLock) {

View file

@ -40,6 +40,14 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
- `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10).
- `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30).
- `git`: configures GSD's git behavior. All fields are optional — omit any to use defaults. Keys:
- `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`.
- `push_branches`: boolean — push newly created slice branches to the remote. Default: `false`.
- `remote`: string — git remote name to push to. Default: `"origin"`.
- `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
- `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
- `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
---
## Best Practices
@ -101,3 +109,22 @@ skill_rules:
- find-skills
---
```
---
## Git Preferences Example
```yaml
---
version: 1
git:
auto_push: true
push_branches: true
remote: origin
snapshots: true
pre_merge_check: auto
commit_type: feat
---
```
All git fields are optional. Omit any field to use the default behavior. Project-level preferences override global preferences on a per-field basis.

View file

@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import type { GitPreferences } from "./git-service.ts";
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
@ -51,6 +52,7 @@ export interface GSDPreferences {
uat_dispatch?: boolean;
budget_ceiling?: number;
remote_questions?: RemoteQuestionsConfig;
git?: GitPreferences;
}
export interface LoadedGSDPreferences {
@ -511,6 +513,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
remote_questions: override.remote_questions
? { ...(base.remote_questions ?? {}), ...override.remote_questions }
: base.remote_questions,
git: (base.git || override.git)
? { ...(base.git ?? {}), ...(override.git ?? {}) }
: undefined,
};
}
@ -594,6 +599,50 @@ function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Git Preferences ───────────────────────────────────────────────────
if (preferences.git && typeof preferences.git === "object") {
const git: Record<string, unknown> = {};
const g = preferences.git as Record<string, unknown>;
if (g.auto_push !== undefined) {
if (typeof g.auto_push === "boolean") git.auto_push = g.auto_push;
else errors.push("git.auto_push must be a boolean");
}
if (g.push_branches !== undefined) {
if (typeof g.push_branches === "boolean") git.push_branches = g.push_branches;
else errors.push("git.push_branches must be a boolean");
}
if (g.remote !== undefined) {
if (typeof g.remote === "string" && g.remote.trim() !== "") git.remote = g.remote.trim();
else errors.push("git.remote must be a non-empty string");
}
if (g.snapshots !== undefined) {
if (typeof g.snapshots === "boolean") git.snapshots = g.snapshots;
else errors.push("git.snapshots must be a boolean");
}
if (g.pre_merge_check !== undefined) {
if (typeof g.pre_merge_check === "boolean" || g.pre_merge_check === "auto") {
git.pre_merge_check = g.pre_merge_check;
} else {
errors.push('git.pre_merge_check must be a boolean or "auto"');
}
}
if (g.commit_type !== undefined) {
const validCommitTypes = new Set([
"feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style",
]);
if (typeof g.commit_type === "string" && validCommitTypes.has(g.commit_type)) {
git.commit_type = g.commit_type;
} else {
errors.push(`git.commit_type must be one of: feat, fix, refactor, docs, test, chore, perf, ci, build, style`);
}
}
if (Object.keys(git).length > 0) {
validated.git = git as GitPreferences;
}
}
return { preferences: validated, errors };
}

View file

@ -8,6 +8,13 @@ custom_instructions: []
models: {}
skill_discovery:
auto_supervisor: {}
git:
auto_push:
push_branches:
remote:
snapshots:
pre_merge_check:
commit_type:
---
# GSD Skill Preferences

View file

@ -1,39 +1,46 @@
/**
* GSD Slice Branch Management
* GSD Slice Branch Management Thin Facade
*
* Simple branch-per-slice workflow. No worktrees, no registry.
* Runtime state (metrics, activity, lock, STATE.md) is gitignored
* so branch switches are clean.
*
* All git-mutation functions delegate to GitServiceImpl from git-service.ts.
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain standalone.
*
* Flow:
* 1. ensureSliceBranch() create + checkout slice branch
* 2. agent does work, commits
* 3. mergeSliceToMain() checkout main, squash-merge, delete branch
*/
import { existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { sep } from "node:path";
export interface MergeSliceResult {
branch: string;
mergedCommitMessage: string;
deletedBranch: boolean;
import { GitServiceImpl } from "./git-service.ts";
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
export type { MergeSliceResult } from "./git-service.ts";
// ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────
let cachedService: GitServiceImpl | null = null;
let cachedBasePath: string | null = null;
/**
* Get or create a GitServiceImpl for the given basePath.
* Resets the cache if basePath changes between calls.
* Lazy construction: only instantiated at call-time, never at module-evaluation.
*/
function getService(basePath: string): GitServiceImpl {
if (cachedService === null || cachedBasePath !== basePath) {
cachedService = new GitServiceImpl(basePath, {});
cachedBasePath = basePath;
}
return cachedService;
}
function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string {
try {
return execSync(`git ${args.join(" ")}`, {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
} catch (error) {
if (options.allowFailure) return "";
const message = error instanceof Error ? error.message : String(error);
throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${message}`);
}
}
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
/**
* Detect the active worktree name from the current working directory.
@ -86,6 +93,8 @@ export function parseSliceBranch(branchName: string): {
};
}
// ─── Git-Mutation Functions (delegate to GitServiceImpl) ───────────────────
/**
* Get the "main" branch for GSD slice operations.
*
@ -98,46 +107,11 @@ export function parseSliceBranch(branchName: string): {
* /worktree merge.
*/
export function getMainBranch(basePath: string): string {
// When inside a worktree, slice branches should merge into the worktree's
// own branch (worktree/<name>), not main — main is checked out by the
// parent working tree and git would refuse the checkout.
const wtName = detectWorktreeName(basePath);
if (wtName) {
const wtBranch = `worktree/${wtName}`;
// Verify the branch exists (it should — createWorktree made it)
const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
if (exists) return wtBranch;
// Worktree branch is gone — return current branch rather than falling
// through to main/master which would cause a checkout conflict
return runGit(basePath, ["branch", "--show-current"]);
}
const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
if (symbolic) {
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
if (match) return match[1]!;
}
const mainExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true });
if (mainExists) return "main";
const masterExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true });
if (masterExists) return "master";
return runGit(basePath, ["branch", "--show-current"]);
return getService(basePath).getMainBranch();
}
export function getCurrentBranch(basePath: string): string {
return runGit(basePath, ["branch", "--show-current"]);
}
function branchExists(basePath: string, branch: string): boolean {
try {
runGit(basePath, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
return true;
} catch {
return false;
}
return getService(basePath).getCurrentBranch();
}
/**
@ -150,49 +124,7 @@ function branchExists(basePath: string, branch: string): boolean {
* Returns true if the branch was newly created.
*/
export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
const wtName = detectWorktreeName(basePath);
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
const current = getCurrentBranch(basePath);
if (current === branch) return false;
let created = false;
if (!branchExists(basePath, branch)) {
// Branch from the current branch when it's a normal working branch
// (not itself a slice branch). This ensures the new slice branch
// inherits planning artifacts that may only exist on the working
// branch and haven't been merged to main yet.
// If we're already on a slice branch (e.g. creating S02 while S01
// wasn't merged yet), fall back to main to avoid chaining slice branches.
const mainBranch = getMainBranch(basePath);
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
runGit(basePath, ["branch", branch, base]);
created = true;
} else {
// Check if the branch is already checked out in another worktree
const worktreeList = runGit(basePath, ["worktree", "list", "--porcelain"]);
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
throw new Error(
`Branch "${branch}" is already in use by another worktree. ` +
`Remove that worktree first, or switch it to a different branch.`,
);
}
}
// Auto-commit dirty files before checkout to prevent "would be overwritten" errors.
// This handles cases where doctor, STATE.md rebuild, or agent work left uncommitted changes.
const status = runGit(basePath, ["status", "--short"]);
if (status.trim()) {
runGit(basePath, ["add", "-A"]);
const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
if (staged.trim()) {
runGit(basePath, ["commit", "-m", `"chore: auto-commit before switching to ${branch}"`]);
}
}
runGit(basePath, ["checkout", branch]);
return created;
return getService(basePath).ensureSliceBranch(milestoneId, sliceId);
}
/**
@ -202,31 +134,14 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
export function autoCommitCurrentBranch(
basePath: string, unitType: string, unitId: string,
): string | null {
const status = runGit(basePath, ["status", "--short"]);
if (!status.trim()) return null;
runGit(basePath, ["add", "-A"]);
const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
if (!staged.trim()) return null;
const message = `chore(${unitId}): auto-commit after ${unitType}`;
runGit(basePath, ["commit", "-m", JSON.stringify(message)]);
return message;
return getService(basePath).autoCommit(unitType, unitId);
}
/**
* Switch to main, auto-committing any dirty files on the current branch first.
*/
export function switchToMain(basePath: string): void {
const mainBranch = getMainBranch(basePath);
const current = getCurrentBranch(basePath);
if (current === mainBranch) return;
// Auto-commit if dirty
autoCommitCurrentBranch(basePath, "pre-switch", current);
runGit(basePath, ["checkout", mainBranch]);
getService(basePath).switchToMain();
}
/**
@ -236,37 +151,12 @@ export function switchToMain(basePath: string): void {
*/
export function mergeSliceToMain(
basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,
): MergeSliceResult {
const wtName = detectWorktreeName(basePath);
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
const mainBranch = getMainBranch(basePath);
const current = getCurrentBranch(basePath);
if (current !== mainBranch) {
throw new Error(`Expected to be on ${mainBranch}, found ${current}`);
}
if (!branchExists(basePath, branch)) {
throw new Error(`Slice branch ${branch} does not exist`);
}
const ahead = runGit(basePath, ["rev-list", "--count", `${mainBranch}..${branch}`]);
if (Number(ahead) <= 0) {
throw new Error(`Slice branch ${branch} has no commits ahead of ${mainBranch}`);
}
runGit(basePath, ["merge", "--squash", branch]);
const mergedCommitMessage = `feat(${milestoneId}/${sliceId}): ${sliceTitle}`;
runGit(basePath, ["commit", "-m", JSON.stringify(mergedCommitMessage)]);
runGit(basePath, ["branch", "-D", branch]);
return {
branch,
mergedCommitMessage,
deletedBranch: true,
};
): import("./git-service.ts").MergeSliceResult {
return getService(basePath).mergeSliceToMain(milestoneId, sliceId, sliceTitle);
}
// ─── Query Functions (delegate to GitServiceImpl) ──────────────────────────
/**
* Check if we're currently on a slice branch (not main).
* Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.