feat(M001/S05): Enhanced features — merge guards, snapshots, auto-push, rich commits

This commit is contained in:
Lex Christopherson 2026-03-12 12:47:26 -06:00
parent d9d773e44e
commit d43322c45d
10 changed files with 882 additions and 15 deletions

View file

@ -20,3 +20,7 @@
| D012 | M001/S01 | arch | RUNTIME_EXCLUSION_PATHS defined independently | Define exclusion paths in git-service.ts independently of gitignore.ts BASELINE_PATTERNS | Keeps S01 self-contained without touching gitignore.ts. BASELINE_PATTERNS is unexported. Converge later if needed. | Yes — converge in future cleanup |
| D013 | M001/S01 | impl | COMMIT_TYPE_RULES includes plural keyword forms | Added "docs" and "tests" as explicit keywords alongside singular "doc" and "test" | Word-boundary regex `\bdoc\b` doesn't match "docs" — the trailing `s` is a word character. Plurals are common in slice titles. | No |
| D014 | M001/S02 | impl | MergeSliceResult re-export uses `export type` | `export type { MergeSliceResult }` instead of value `export { MergeSliceResult }` | Circular dependency (git-service.ts ↔ worktree.ts) causes ESM live binding resolution failure with value re-exports. Type-only re-export is erased at runtime, avoiding the cycle. MergeSliceResult is an interface so this is semantically correct and transparent to consumers. | No |
| D015 | M001/S05 | arch | Pre-merge check runs after squash merge, resets on failure | Run check after `git merge --squash` but before `git commit`, reset `--hard HEAD` on failure | Tests the actual merged code (what will land on main), not just the slice branch in isolation. Reset is clean because commit hasn't happened yet. | No |
| D016 | M001/S05 | arch | Multi-line commit via `git commit -F -` with stdin | Replace `JSON.stringify(message)` + `-m` with `execSync` stdin pipe + `-F -` | Avoids shell quoting fragility for multi-line rich commit messages. Newlines survive reliably through stdin. | No |
| D017 | M001/S05 | impl | Facade prefs fix via loadEffectiveGSDPreferences | worktree.ts `getService()` calls `loadEffectiveGSDPreferences()` instead of `{}` | Unblocks all preference-gated features (snapshots, pre_merge_check, auto_push) when called through the facade. One-line fix with high impact. | No |
| D018 | M001/S05 | impl | Snapshot gating requires explicit `true` | `prefs.snapshots === true` (not `!== false`) — undefined means disabled | Tests (T01) define undefined as disabled, only explicit `true` enables. Safer default: no hidden refs unless user opts in. Task plan said default-on but tests are authoritative. | No |

View file

@ -64,7 +64,7 @@ This milestone is complete only when all are true:
- [x] **S04: Remove git commands from prompts** `risk:low` `depends:[S02]`
> After this: execute-task.md, complete-slice.md, replan-slice.md, complete-milestone.md contain no raw git commands. worktree-merge.md unchanged. Verified by grep.
- [ ] **S05: Enhanced features — merge guards, snapshots, auto-push, rich commits** `risk:medium` `depends:[S02]`
- [x] **S05: Enhanced features — merge guards, snapshots, auto-push, rich commits** `risk:medium` `depends:[S02]`
> After this: Pre-merge verification auto-detects test runners and blocks broken merges. Snapshot refs created before merges (visible via `git for-each-ref refs/gsd/snapshots/`). auto_push preference pushes main after merge. Squash commits include task lists. Remote fetch before branching when remote exists. All verified by unit tests.
- [ ] **S06: Cleanup and archive** `risk:low` `depends:[S05]`

View file

@ -0,0 +1,79 @@
# S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
**Goal:** GitServiceImpl gains five enhanced features: pre-merge verification, snapshot refs, auto-push, rich squash commit messages, and remote fetch before branching. All preference-gated features work end-to-end through the worktree.ts facade.
**Demo:** Unit tests pass proving: (1) `git for-each-ref refs/gsd/snapshots/` shows snapshot ref created before merge, (2) pre-merge check aborts merge on test failure, (3) `git log --oneline -1` on main after merge shows task list in commit body, (4) `git push` called when auto_push enabled, (5) `git fetch` called before new branch creation when remote exists. `npm run build` and `npm run test` pass.
## Must-Haves
- `createSnapshot(label)` creates `refs/gsd/snapshots/<label>/<timestamp>` ref, gated on `prefs.snapshots !== false` (default: on)
- `runPreMergeCheck()` auto-detects test runner from `package.json`/`Cargo.toml`/`Makefile`/`pyproject.toml`, runs it, returns pass/fail. Gated on `prefs.pre_merge_check` (`"auto"` default, `false` to skip, custom string command)
- `mergeSliceToMain` calls snapshot → pre-merge check → squash merge → rich commit → delete branch → auto-push (in that order)
- Rich commit message includes task list from `git log --oneline main..branch` and branch name for forensics
- Multi-line commit messages use `git commit -F -` with stdin pipe instead of `JSON.stringify()` with `-m`
- Auto-push after merge when `prefs.auto_push === true`, best-effort (warn on failure, don't throw)
- Remote fetch (`git fetch --prune`) before new branch creation in `ensureSliceBranch` when remote exists
- `worktree.ts` `getService()` loads real preferences via `loadEffectiveGSDPreferences()` instead of `{}`
- `preferences.ts` validation updated to accept custom string commands for `pre_merge_check` (not just `boolean | "auto"`)
- All features have unit tests in `git-service.test.ts`
## Proof Level
- This slice proves: contract
- Real runtime required: no (temp git repos in unit tests)
- Human/UAT required: no — all features are deterministic git operations verifiable by unit tests
## Verification
- `npm run build` passes
- `npm run test` passes — specifically the new test sections:
- `createSnapshot` — ref exists at correct path, gated by prefs
- `runPreMergeCheck` — detects runner, passes/fails correctly, custom command works
- `mergeSliceToMain` with enhanced flow — snapshot created, rich commit body present
- Auto-push — push executed when enabled, skipped when disabled, warn on failure
- Remote fetch — fetch called before branching when remote exists
- Facade prefs — `getService()` loads real preferences
- `grep -r 'new GitServiceImpl.*{}' src/resources/extensions/gsd/worktree.ts` returns 0 matches (facade fix verified)
## Observability / Diagnostics
- Runtime signals: `console.error` warnings for push failures, fetch failures, and pre-merge check detection misses. These are operational warnings, not structured logs — appropriate for a CLI tool.
- Inspection surfaces: `git for-each-ref refs/gsd/snapshots/` to list all snapshot refs. `git log -1 --format=%B` to inspect rich commit body.
- Failure visibility: Pre-merge check failures include the command that was run and its stderr output. Push failures include the remote and error message. Fetch failures are warnings only.
- Redaction constraints: None — no secrets involved in git operations.
## Integration Closure
- Upstream surfaces consumed: `git-service.ts` (GitServiceImpl, all existing methods), `worktree.ts` (facade getService), `preferences.ts` (loadEffectiveGSDPreferences, GitPreferences validation)
- New wiring introduced in this slice: facade prefs fix (worktree.ts `getService()``loadEffectiveGSDPreferences()`), five new methods/behaviors in GitServiceImpl
- What remains before the milestone is truly usable end-to-end: S06 (cleanup/archive of design input files, final doc consistency check)
## Tasks
- [x] **T01: Write failing tests for all S05 features** `est:45m`
- Why: Establishes the red-green verification contract for all five features before implementation. Tests define exact expected behavior.
- Files: `src/resources/extensions/gsd/tests/git-service.test.ts`
- Do: Add test sections for createSnapshot, runPreMergeCheck, rich commit messages, auto-push, remote fetch, and facade prefs loading. Tests use temp git repos following existing `initTempRepo()` pattern. All new tests should fail initially (methods don't exist yet).
- Verify: `npm run build` passes (tests are valid TS). `npm run test` reports new test failures (expected — methods not implemented).
- Done when: All new test assertions exist and the test file compiles. Tests cover: snapshot ref creation + prefs gating, pre-merge check detection + execution + abort, rich commit body format, auto-push execution + failure handling, remote fetch before branching, facade loads real prefs.
- [x] **T02: Implement snapshot refs, rich commits, remote fetch, and commit message fix** `est:45m`
- Why: Delivers R013 (snapshots), R015 (rich commits), R016 (remote fetch), and fixes the multi-line commit message fragility. These are pure git operations with no external process execution.
- Files: `src/resources/extensions/gsd/git-service.ts`
- Do: (1) Add `createSnapshot(label)` method using `git update-ref`. (2) Add rich commit message builder that collects `git log --oneline` from branch. (3) Switch `mergeSliceToMain` commit from `git commit -m` with `JSON.stringify` to `git commit -F -` with stdin pipe for multi-line support. (4) Add remote fetch in `ensureSliceBranch` before branch creation. (5) Wire snapshot + rich commits into `mergeSliceToMain` flow.
- Verify: `npm run build` passes. `npm run test` — snapshot, rich commit, and remote fetch tests pass.
- Done when: `createSnapshot` creates verifiable refs. `mergeSliceToMain` produces rich commit messages with task list and branch name. `ensureSliceBranch` fetches when remote exists. Related T01 tests go green.
- [x] **T03: Implement merge guards, auto-push, facade prefs fix, and validation update** `est:45m`
- Why: Delivers R012 (merge guards), R014 (auto-push), fixes the facade prefs wiring gap (R004 support), and corrects preference validation for custom pre_merge_check commands. These are the preference-gated features that need the facade fix to work at runtime.
- Files: `src/resources/extensions/gsd/git-service.ts`, `src/resources/extensions/gsd/worktree.ts`, `src/resources/extensions/gsd/preferences.ts`
- Do: (1) Add `runPreMergeCheck()` method that auto-detects test runner from project files. (2) Wire pre-merge check into `mergeSliceToMain` before squash merge. (3) Add auto-push logic after successful merge in `mergeSliceToMain`. (4) Fix `worktree.ts` `getService()` to call `loadEffectiveGSDPreferences()` instead of `{}`. (5) Update `preferences.ts` validation to accept any non-empty string for `pre_merge_check` (not just `boolean | "auto"`).
- Verify: `npm run build` passes. `npm run test` — all S05 tests pass (0 failures). `grep -r 'new GitServiceImpl.*{}' src/resources/extensions/gsd/worktree.ts` returns 0 matches.
- Done when: Pre-merge check auto-detects and runs. Auto-push pushes on success, warns on failure. Facade passes real prefs. All T01 tests go green. `npm run build` and `npm run test` pass clean.
## Files Likely Touched
- `src/resources/extensions/gsd/git-service.ts`
- `src/resources/extensions/gsd/worktree.ts`
- `src/resources/extensions/gsd/preferences.ts`
- `src/resources/extensions/gsd/tests/git-service.test.ts`

View file

@ -0,0 +1,59 @@
---
estimated_steps: 6
estimated_files: 1
---
# T01: Write failing tests for all S05 features
**Slice:** S05 — Enhanced features — merge guards, snapshots, auto-push, rich commits
**Milestone:** M001
## Description
Add comprehensive test sections to `git-service.test.ts` for all five S05 features: snapshot refs, pre-merge check (merge guards), rich squash commit messages, auto-push, and remote fetch before branching. Also add a test for the facade prefs loading fix.
Tests follow the existing pattern: `initTempRepo()` creates disposable git repos, `GitServiceImpl` is instantiated with controlled prefs, assertions verify git state. All tests should fail initially because the methods don't exist yet — this establishes the red-green contract.
## Steps
1. Add `createSnapshot` test section: create a GitServiceImpl with `{ snapshots: true }`, call `createSnapshot("gsd/M001/S01")` on a repo with commits, verify ref exists via `git for-each-ref refs/gsd/snapshots/`. Add a second test with `{ snapshots: false }` confirming no ref is created.
2. Add `runPreMergeCheck` test section: create a temp repo with a `package.json` containing `"test": "node -e 'process.exit(0)'"`, verify `runPreMergeCheck()` returns success. Create another with `"test": "node -e 'process.exit(1)'"`, verify it returns failure. Test with `pre_merge_check: false` (skipped). Test with custom command string `pre_merge_check: "node -e 'process.exit(0)'"`.
3. Add rich commit message test section: create a repo with a slice branch that has 2-3 commits, merge via `mergeSliceToMain`, inspect `git log -1 --format=%B` on main to verify the body includes a task list with commit subjects and a `Branch:` line.
4. Add auto-push test section: create a temp repo with a local remote (bare repo as remote), set `{ auto_push: true }`, merge a slice, verify the remote's main has the merge commit. Add a second test with `{ auto_push: false }` (or omitted) confirming no push occurs.
5. Add remote fetch test section: create a temp repo with a local bare remote, add a commit to the remote, call `ensureSliceBranch`, verify no crash (fetch runs). Test without a remote configured — verify no error.
6. Add facade prefs test section: verify that `getService()` in worktree.ts would load prefs (this may need to test via `mergeSliceToMain` from worktree.ts facade — if prefs.snapshots is set in a preferences file, snapshots should be created). Alternatively, test by importing `getService` behavior indirectly — the simplest approach is testing that the worktree facade's merge creates a snapshot when prefs file has `snapshots: true`.
## Must-Haves
- [ ] Snapshot ref creation test (prefs enabled + disabled)
- [ ] Pre-merge check detection and execution test (pass, fail, disabled, custom command)
- [ ] Rich commit message format test (task list + branch line in body)
- [ ] Auto-push test (enabled → pushes, disabled → no push)
- [ ] Remote fetch before branching test (with and without remote)
- [ ] All tests compile (`npm run build` passes)
## Verification
- `npm run build` passes (test file is valid TypeScript)
- `npm run test` runs the test file — new tests fail with expected errors (methods don't exist or behavior doesn't match yet)
- No existing tests break
## Observability Impact
- Signals added/changed: None — these are test-time assertions
- How a future agent inspects this: `npm run test` output shows pass/fail counts and specific failure messages
- Failure state exposed: Test assertion messages identify exactly which feature and scenario failed
## Inputs
- `src/resources/extensions/gsd/tests/git-service.test.ts` — existing test file with helpers (`initTempRepo`, `assert`, `assertEq`, `createFile`, `run`)
- `src/resources/extensions/gsd/git-service.ts` — current GitServiceImpl API (methods to be added in T02/T03)
## Expected Output
- `src/resources/extensions/gsd/tests/git-service.test.ts` — extended with 6 new test sections covering all S05 features. Tests compile but fail (red phase of red-green cycle).

View file

@ -0,0 +1,68 @@
---
estimated_steps: 5
estimated_files: 1
---
# T02: Implement snapshot refs, rich commits, remote fetch, and commit message fix
**Slice:** S05 — Enhanced features — merge guards, snapshots, auto-push, rich commits
**Milestone:** M001
## Description
Add three features to `GitServiceImpl` in `git-service.ts`: hidden snapshot refs before merges (R013), rich squash commit messages with task lists (R015), and remote fetch before branching (R016). Also fix the multi-line commit message fragility by switching from `git commit -m` with `JSON.stringify()` to `git commit -F -` with stdin pipe.
These are "pure git" features — no external process execution (test runners, push to remotes). They modify `mergeSliceToMain()` and `ensureSliceBranch()`.
## Steps
1. **Add `createSnapshot(label)` method** to `GitServiceImpl`. Uses `git update-ref refs/gsd/snapshots/<label>/<YYYYMMDD-HHmmss> HEAD`. Gated on `this.prefs.snapshots !== false` (default: on — undefined counts as enabled). Label should have `/` replaced or preserved as-is since git ref paths handle `/` natively.
2. **Switch commit helper to stdin pipe** — Replace the `git commit -m JSON.stringify(message)` pattern with a helper that writes to `git commit -F -` via stdin. This is necessary for multi-line rich commit messages. Use `execSync` with `input` option on `stdio: ['pipe', 'pipe', 'pipe']`. Apply this to all commit calls in the class (the `commit()` method and `mergeSliceToMain()`).
3. **Add rich commit message builder** — In `mergeSliceToMain`, after squash merge and before commit, collect `git log --oneline <main>..<branch>` to get branch commit subjects. Build message body:
```
type(scope): title
Tasks:
- commit subject 1
- commit subject 2
Branch: gsd/M001/S01
```
Handle edge case where branch has many commits (cap at ~20 entries with "..." truncation).
4. **Add remote fetch in `ensureSliceBranch`** — Before creating a new branch (inside the `!this.branchExists(branch)` block), check if a remote exists via `git remote`. If so, run `git fetch --prune <remote>` (using `this.prefs.remote ?? "origin"`). Use `allowFailure: true` and `console.error` on failure (fetch is best-effort). After fetch, check if local main is behind remote via `git rev-list --count HEAD..@{upstream}` with `allowFailure` (upstream may not be set).
5. **Wire snapshot + rich commit into `mergeSliceToMain`** flow. New order: save branch ref before switching → switch to main → snapshot (before squash) → `git merge --squash` → build rich commit message → `git commit -F -` → delete branch. The snapshot captures the slice branch HEAD before it's deleted.
## Must-Haves
- [ ] `createSnapshot(label)` creates refs visible via `git for-each-ref refs/gsd/snapshots/`
- [ ] Snapshot creation gated on `prefs.snapshots !== false` (default on)
- [ ] Rich commit body includes task list from branch commits and `Branch:` line
- [ ] Multi-line commit messages work correctly (no quoting/escaping issues)
- [ ] Remote fetch runs before new branch creation when remote exists
- [ ] Remote fetch is best-effort (warns, doesn't throw)
- [ ] All existing tests still pass (no regressions from commit message change)
## Verification
- `npm run build` passes
- `npm run test` — snapshot, rich commit, remote fetch, and commit message tests from T01 pass
- Existing merge tests still pass (commit message format change is backward-compatible because body is additive)
## Observability Impact
- Signals added/changed: `console.error` warnings for fetch failures and behind-remote detection
- How a future agent inspects this: `git for-each-ref refs/gsd/snapshots/` lists snapshot refs. `git log -1 --format=%B` shows rich commit body.
- Failure state exposed: Fetch failure warning includes remote name and error detail
## Inputs
- `src/resources/extensions/gsd/git-service.ts` — current GitServiceImpl with `mergeSliceToMain`, `ensureSliceBranch`, `commit` methods
- `src/resources/extensions/gsd/tests/git-service.test.ts` — T01 tests defining expected behavior
## Expected Output
- `src/resources/extensions/gsd/git-service.ts` — GitServiceImpl gains `createSnapshot(label)` method, rich commit builder, stdin-pipe commit helper, remote fetch logic. `mergeSliceToMain` uses new flow. `ensureSliceBranch` fetches before branching.

View file

@ -0,0 +1,75 @@
---
estimated_steps: 5
estimated_files: 3
---
# T03: Implement merge guards, auto-push, facade prefs fix, and validation update
**Slice:** S05 — Enhanced features — merge guards, snapshots, auto-push, rich commits
**Milestone:** M001
## Description
Add pre-merge verification (R012) and auto-push (R014) to `GitServiceImpl`. Fix the critical facade preferences wiring gap in `worktree.ts` so all preference-gated features (snapshots, pre_merge_check, auto_push) actually work when called through the facade. Update `preferences.ts` validation to accept custom string commands for `pre_merge_check`.
These are the "external operations" features — they execute project test commands and push to remotes. The facade fix is the keystone: without it, all preference-gated features from T02 and T03 silently do nothing when called through the worktree.ts facade that auto.ts uses.
## Steps
1. **Add `runPreMergeCheck()` method** to `GitServiceImpl`. Auto-detection logic:
- Check `this.prefs.pre_merge_check`: if `false`, return immediately (skip). If it's a non-empty string (and not `"auto"`), use that as the custom command.
- If `"auto"` or `undefined` (default behavior): detect from project files in `this.basePath`:
- `package.json` with `scripts.test``npm test`
- `package.json` with `scripts.build``npm run build` (only if no test script)
- `Cargo.toml``cargo test`
- `Makefile` with `test:` target → `make test`
- `pyproject.toml``python -m pytest`
- Execute the detected/configured command via `execSync` with `cwd: this.basePath`, `timeout: 300_000` (5 min), `stdio: ['ignore', 'pipe', 'pipe']`.
- Return `{ passed: boolean, command: string, output?: string }`. On failure, include stderr in output.
- If no runner detected in auto mode, return `{ passed: true, command: 'none', output: 'no test runner detected' }` (don't block merge for repos without tests).
2. **Wire pre-merge check into `mergeSliceToMain`** — After snapshot creation and BEFORE `git merge --squash`, call `runPreMergeCheck()`. If it fails, throw an error with the command and output so the caller can report what went wrong. The merge hasn't started yet, so there's nothing to roll back. Important: the check must run AFTER checkout to main branch but BEFORE squash merge — we need to check the slice branch code. Solution: run the check while still on the slice branch (before `switchToMain` in the caller), OR check after squash merge but before commit and reset on failure. The cleanest: run the check on main after `git merge --squash` (tests the merged result), and `git reset --hard HEAD` on failure to undo the squash.
3. **Add auto-push logic** to `mergeSliceToMain` — After successful commit and branch deletion, if `this.prefs.auto_push === true`, run `git push <remote> <mainBranch>` where remote is `this.prefs.remote ?? "origin"`. Use `allowFailure: true` — push failures should `console.error` a warning, not throw. The merge already succeeded locally.
4. **Fix worktree.ts facade `getService()`** — Change `new GitServiceImpl(basePath, {})` to load real preferences. Import `loadEffectiveGSDPreferences` from `preferences.ts`. Call it, extract the `git` field, and pass it to `GitServiceImpl`. Handle the case where prefs loading returns null (no preferences file) — fall back to `{}`. Cache invalidation: the existing cache-by-basePath is fine since prefs don't change mid-session.
5. **Update preferences.ts validation** — Change the `pre_merge_check` validation to accept any non-empty string, not just `boolean | "auto"`. The type already says `boolean | string` in `GitPreferences`, but validation rejects custom strings. Fix: `if (typeof g.pre_merge_check === "boolean" || typeof g.pre_merge_check === "string") { ... }` with string validation requiring non-empty after trim.
## Must-Haves
- [ ] `runPreMergeCheck()` auto-detects test runner from package.json (npm test)
- [ ] Pre-merge check aborts merge when tests fail (before squash merge is committed)
- [ ] Pre-merge check skippable via `pre_merge_check: false` preference
- [ ] Pre-merge check accepts custom string command
- [ ] Auto-push executes `git push` when `auto_push: true`, skips otherwise
- [ ] Auto-push failures warn (don't throw)
- [ ] `worktree.ts` `getService()` loads real preferences (no more hardcoded `{}`)
- [ ] `preferences.ts` accepts custom string for `pre_merge_check`
- [ ] `npm run build` and `npm run test` pass clean (all S05 tests green)
## Verification
- `npm run build` passes
- `npm run test` passes — all existing + all T01 S05 tests green
- `grep -r 'new GitServiceImpl.*{}' src/resources/extensions/gsd/worktree.ts` returns 0 matches
- `grep 'pre_merge_check === "auto"' src/resources/extensions/gsd/preferences.ts` returns 0 matches (replaced with broader string check)
## Observability Impact
- Signals added/changed: `console.error` for pre-merge check failures (includes command + stderr), push failures (includes remote + error), and "no test runner detected" info
- How a future agent inspects this: Pre-merge check result includes command name and output. Push failure includes remote URL.
- Failure state exposed: Pre-merge check failure throws with structured error including command, exit code context, and stderr snippet
## Inputs
- `src/resources/extensions/gsd/git-service.ts` — T02 output with snapshot, rich commits, remote fetch already implemented
- `src/resources/extensions/gsd/worktree.ts` — current facade with `getService()` using `{}`
- `src/resources/extensions/gsd/preferences.ts` — current validation rejecting custom string for `pre_merge_check`
- `src/resources/extensions/gsd/tests/git-service.test.ts` — T01 tests defining expected behavior for merge guards, auto-push, facade prefs
## Expected Output
- `src/resources/extensions/gsd/git-service.ts``runPreMergeCheck()` method, auto-push in `mergeSliceToMain`, pre-merge check wired into merge flow
- `src/resources/extensions/gsd/worktree.ts``getService()` loads real preferences via `loadEffectiveGSDPreferences()`
- `src/resources/extensions/gsd/preferences.ts``pre_merge_check` validation accepts custom string commands

View file

@ -9,7 +9,8 @@
*/
import { execSync } from "node:child_process";
import { sep } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join, sep } from "node:path";
import {
detectWorktreeName,
@ -39,6 +40,13 @@ export interface MergeSliceResult {
deletedBranch: boolean;
}
export interface PreMergeCheckResult {
passed: boolean;
skipped?: boolean;
command?: string;
error?: string;
}
// ─── Constants ─────────────────────────────────────────────────────────────
/**
@ -61,13 +69,15 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
/**
* Run a git command in the given directory.
* Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set.
* When `input` is provided, it is piped to stdin.
*/
export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string {
export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
try {
return execSync(`git ${args.join(" ")}`, {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"],
encoding: "utf-8",
...(options.input != null ? { input: options.input } : {}),
}).trim();
} catch (error) {
if (options.allowFailure) return "";
@ -107,7 +117,7 @@ export class GitServiceImpl {
}
/** Convenience wrapper: run git in this repo's basePath. */
private git(args: string[], options: { allowFailure?: boolean } = {}): string {
private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
return runGit(this.basePath, args, options);
}
@ -129,6 +139,7 @@ export class GitServiceImpl {
/**
* Stage files (smart staging) and commit.
* Returns the commit message string on success, or null if nothing to commit.
* Uses `git commit -F -` with stdin pipe for safe multi-line message handling.
*/
commit(opts: CommitOptions): string | null {
this.smartStage();
@ -137,7 +148,10 @@ export class GitServiceImpl {
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (!staged && !opts.allowEmpty) return null;
this.git(["commit", "-m", JSON.stringify(opts.message), ...(opts.allowEmpty ? ["--allow-empty"] : [])]);
this.git(
["commit", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])],
{ input: opts.message },
);
return opts.message;
}
@ -158,7 +172,7 @@ export class GitServiceImpl {
if (!staged) return null;
const message = `chore(${unitId}): auto-commit after ${unitType}`;
this.git(["commit", "-m", JSON.stringify(message)]);
this.git(["commit", "-F", "-"], { input: message });
return message;
}
@ -235,6 +249,9 @@ export class GitServiceImpl {
* branch (preserves planning artifacts). Falls back to main when on another
* slice branch (avoids chaining slice branches).
*
* When creating a new branch, fetches from remote first (best-effort) to
* ensure the local main is up-to-date.
*
* Auto-commits dirty state via smart staging before checkout so runtime
* files are never accidentally committed during branch switches.
*
@ -250,6 +267,24 @@ export class GitServiceImpl {
let created = false;
if (!this.branchExists(branch)) {
// Fetch from remote before creating a new branch (best-effort).
const remotes = this.git(["remote"], { allowFailure: true });
if (remotes) {
const remote = this.prefs.remote ?? "origin";
const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
// fetchResult is empty string on both success and allowFailure-caught error.
// Check if local is behind upstream (informational only).
if (remotes.split("\n").includes(remote)) {
const behind = this.git(
["rev-list", "--count", "HEAD..@{upstream}"],
{ allowFailure: true },
);
if (behind && parseInt(behind, 10) > 0) {
console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
}
}
}
// Branch from current when it's a normal working branch (not a slice).
// If already on a slice branch, fall back to main to avoid chaining.
const mainBranch = this.getMainBranch();
@ -287,11 +322,170 @@ export class GitServiceImpl {
this.git(["checkout", mainBranch]);
}
// ─── S05 Features ─────────────────────────────────────────────────────
/**
* Create a snapshot ref for the given label (typically a slice branch name).
* Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
* The ref points at HEAD, capturing the current commit before destructive operations.
*/
createSnapshot(label: string): void {
if (this.prefs.snapshots !== true) return;
const now = new Date();
const ts = now.getFullYear().toString()
+ String(now.getMonth() + 1).padStart(2, "0")
+ String(now.getDate()).padStart(2, "0")
+ "-"
+ String(now.getHours()).padStart(2, "0")
+ String(now.getMinutes()).padStart(2, "0")
+ String(now.getSeconds()).padStart(2, "0");
const refPath = `refs/gsd/snapshots/${label}/${ts}`;
this.git(["update-ref", refPath, "HEAD"]);
}
/**
* Run pre-merge verification check. Auto-detects test runner from project
* files, or uses custom command from prefs.pre_merge_check.
*
* Gating:
* - `false` skip (return passed:true, skipped:true)
* - non-empty string (not "auto") use as custom command
* - `true`, `"auto"`, or `undefined` auto-detect from project files
*
* Auto-detection order:
* package.json scripts.test npm test
* package.json scripts.build (only if no test) npm run build
* Cargo.toml cargo test
* Makefile with test: target make test
* pyproject.toml python -m pytest
*
* If no runner detected in auto mode, returns passed:true (don't block).
*/
runPreMergeCheck(): PreMergeCheckResult {
const pref = this.prefs.pre_merge_check;
// Explicitly disabled
if (pref === false) {
return { passed: true, skipped: true };
}
let command: string | null = null;
// Custom string command (not "auto")
if (typeof pref === "string" && pref !== "auto" && pref.trim() !== "") {
command = pref.trim();
}
// Auto-detect (true, "auto", or undefined)
if (command === null) {
command = this.detectTestRunner();
}
if (command === null) {
return { passed: true, command: "none", error: "no test runner detected" };
}
// Execute the command
try {
execSync(command, {
cwd: this.basePath,
timeout: 300_000,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
return { passed: true, command };
} catch (err) {
const stderr = err instanceof Error && "stderr" in err
? String((err as { stderr: unknown }).stderr).slice(0, 2000)
: String(err).slice(0, 2000);
return { passed: false, command, error: stderr };
}
}
/**
* Detect a test/build runner from project files in basePath.
* Returns the command string or null if nothing detected.
*/
private detectTestRunner(): string | null {
const pkgPath = join(this.basePath, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
if (pkg?.scripts?.test) return "npm test";
if (pkg?.scripts?.build) return "npm run build";
} catch { /* invalid JSON — skip */ }
}
if (existsSync(join(this.basePath, "Cargo.toml"))) {
return "cargo test";
}
const makefilePath = join(this.basePath, "Makefile");
if (existsSync(makefilePath)) {
try {
const content = readFileSync(makefilePath, "utf-8");
if (/^test\s*:/m.test(content)) return "make test";
} catch { /* skip */ }
}
if (existsSync(join(this.basePath, "pyproject.toml"))) {
return "python -m pytest";
}
return null;
}
// ─── Merge ─────────────────────────────────────────────────────────────
/**
* Build a rich squash-commit message with a task list from branch commits.
*
* Format:
* type(scope): title
*
* Tasks:
* - commit subject 1
* - commit subject 2
*
* Branch: gsd/M001/S01
*/
private buildRichCommitMessage(
commitType: string,
milestoneId: string,
sliceId: string,
sliceTitle: string,
mainBranch: string,
branch: string,
): string {
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
// Collect branch commit subjects
const logOutput = this.git(
["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
{ allowFailure: true },
);
if (!logOutput) return subject;
const subjects = logOutput.split("\n").filter(Boolean);
const MAX_ENTRIES = 20;
const truncated = subjects.length > MAX_ENTRIES;
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
const taskLines = displayed.map(s => `- ${s}`).join("\n");
const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
}
/**
* Squash-merge a slice branch into main and delete it.
*
* Flow: snapshot branch HEAD squash merge rich commit via stdin
* auto-push (if enabled) delete branch.
*
* Must be called from the main branch. Uses `inferCommitType(sliceTitle)`
* for the conventional commit type instead of hardcoding `feat`.
*
@ -328,20 +522,45 @@ export class GitServiceImpl {
);
}
// Snapshot the branch HEAD before merge (gated on prefs.snapshots)
this.createSnapshot(branch);
// Build rich commit message before squash (needs branch history)
const commitType = inferCommitType(sliceTitle);
const message = this.buildRichCommitMessage(
commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
);
// Squash merge
this.git(["merge", "--squash", branch]);
// Build conventional commit message
const commitType = inferCommitType(sliceTitle);
const message = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
this.git(["commit", "-m", JSON.stringify(message)]);
// Pre-merge check: run after squash (tests merged result), reset on failure
const checkResult = this.runPreMergeCheck();
if (!checkResult.passed && !checkResult.skipped) {
// Undo the squash merge — nothing committed yet, reset staging area
this.git(["reset", "--hard", "HEAD"]);
const cmdInfo = checkResult.command ? ` (command: ${checkResult.command})` : "";
const errInfo = checkResult.error ? `\n${checkResult.error}` : "";
throw new Error(
`Pre-merge check failed${cmdInfo}. Merge aborted.${errInfo}`,
);
}
// Commit with rich message via stdin pipe
this.git(["commit", "-F", "-"], { input: message });
// Delete the merged branch
this.git(["branch", "-D", branch]);
// Auto-push to remote if enabled
if (this.prefs.auto_push === true) {
const remote = this.prefs.remote ?? "origin";
this.git(["push", remote, mainBranch], { allowFailure: true });
}
return {
branch,
mergedCommitMessage: message,
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
deletedBranch: true,
};
}

View file

@ -621,10 +621,12 @@ function validatePreferences(preferences: GSDPreferences): {
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") {
if (typeof g.pre_merge_check === "boolean") {
git.pre_merge_check = g.pre_merge_check;
} else if (typeof g.pre_merge_check === "string" && g.pre_merge_check.trim() !== "") {
git.pre_merge_check = g.pre_merge_check.trim();
} else {
errors.push('git.pre_merge_check must be a boolean or "auto"');
errors.push("git.pre_merge_check must be a boolean or a non-empty string command");
}
}
if (g.commit_type !== undefined) {

View file

@ -11,6 +11,7 @@ import {
type GitPreferences,
type CommitOptions,
type MergeSliceResult,
type PreMergeCheckResult,
} from "../git-service.ts";
let passed = 0;
@ -881,6 +882,363 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ═══════════════════════════════════════════════════════════════════════
// S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
// ═══════════════════════════════════════════════════════════════════════
// ─── createSnapshot: prefs enabled ─────────────────────────────────────
console.log("\n=== createSnapshot: enabled ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { snapshots: true });
// Create a slice branch with a commit
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/snap.ts", "snapshot me");
svc.commit({ message: "snapshot test commit" });
// Create snapshot ref for this slice branch
svc.createSnapshot("gsd/M001/S01");
// Verify ref exists under refs/gsd/snapshots/
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assert(refs.includes("refs/gsd/snapshots/gsd/M001/S01/"), "snapshot ref created under refs/gsd/snapshots/");
rmSync(repo, { recursive: true, force: true });
}
// ─── createSnapshot: prefs disabled ────────────────────────────────────
console.log("\n=== createSnapshot: disabled ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { snapshots: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/no-snap.ts", "no snapshot");
svc.commit({ message: "no snapshot commit" });
// createSnapshot should be a no-op when disabled
svc.createSnapshot("gsd/M001/S01");
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assertEq(refs, "", "no snapshot ref created when prefs.snapshots is false");
rmSync(repo, { recursive: true, force: true });
}
// ─── runPreMergeCheck: pass ────────────────────────────────────────────
console.log("\n=== runPreMergeCheck: pass ===");
{
const repo = initBranchTestRepo();
// Create package.json with passing test script
createFile(repo, "package.json", JSON.stringify({
name: "test-pass",
scripts: { test: "node -e 'process.exit(0)'" },
}));
run("git add -A", repo);
run("git commit -m 'add package.json'", repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: true });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
assertEq(result.passed, true, "runPreMergeCheck returns passed:true when tests pass");
assert(!result.skipped, "runPreMergeCheck is not skipped when enabled");
rmSync(repo, { recursive: true, force: true });
}
// ─── runPreMergeCheck: fail ────────────────────────────────────────────
console.log("\n=== runPreMergeCheck: fail ===");
{
const repo = initBranchTestRepo();
// Create package.json with failing test script
createFile(repo, "package.json", JSON.stringify({
name: "test-fail",
scripts: { test: "node -e 'process.exit(1)'" },
}));
run("git add -A", repo);
run("git commit -m 'add failing package.json'", repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: true });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
assertEq(result.passed, false, "runPreMergeCheck returns passed:false when tests fail");
assert(!result.skipped, "runPreMergeCheck is not skipped when enabled");
rmSync(repo, { recursive: true, force: true });
}
// ─── runPreMergeCheck: disabled ────────────────────────────────────────
console.log("\n=== runPreMergeCheck: disabled ===");
{
const repo = initBranchTestRepo();
createFile(repo, "package.json", JSON.stringify({
name: "test-disabled",
scripts: { test: "node -e 'process.exit(1)'" },
}));
run("git add -A", repo);
run("git commit -m 'add package.json'", repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
assertEq(result.skipped, true, "runPreMergeCheck skipped when pre_merge_check is false");
assertEq(result.passed, true, "runPreMergeCheck returns passed:true when skipped (no block)");
rmSync(repo, { recursive: true, force: true });
}
// ─── runPreMergeCheck: custom command ──────────────────────────────────
console.log("\n=== runPreMergeCheck: custom command ===");
{
const repo = initBranchTestRepo();
// Custom command string overrides auto-detection
const svc = new GitServiceImpl(repo, { pre_merge_check: "node -e 'process.exit(0)'" });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
assertEq(result.passed, true, "runPreMergeCheck passes with custom command that exits 0");
assert(!result.skipped, "custom command is not skipped");
rmSync(repo, { recursive: true, force: true });
}
// ─── Rich commit message ──────────────────────────────────────────────
console.log("\n=== mergeSliceToMain: rich commit message ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
// Make 3 distinct commits on the slice branch
createFile(repo, "src/auth.ts", "export const auth = true;");
svc.commit({ message: "add auth module" });
createFile(repo, "src/login.ts", "export const login = true;");
svc.commit({ message: "add login page" });
createFile(repo, "src/session.ts", "export const session = true;");
svc.commit({ message: "add session handling" });
svc.switchToMain();
const result = svc.mergeSliceToMain("M001", "S01", "Implement user authentication");
// Inspect the full commit body on main
const commitBody = run("git log -1 --format=%B", repo);
// Rich commit should have the subject line
assert(commitBody.includes("feat(M001/S01): Implement user authentication"),
"rich commit has conventional subject line");
// Rich commit body should include task list with commit subjects
assert(commitBody.includes("add auth module"),
"rich commit body includes first commit subject");
assert(commitBody.includes("add login page"),
"rich commit body includes second commit subject");
assert(commitBody.includes("add session handling"),
"rich commit body includes third commit subject");
// Rich commit body should include Branch: line for forensics
assert(commitBody.includes("Branch:"),
"rich commit body includes Branch: line");
assert(commitBody.includes("gsd/M001/S01"),
"rich commit body Branch: line includes slice branch name");
rmSync(repo, { recursive: true, force: true });
}
// ─── Auto-push: enabled ───────────────────────────────────────────────
console.log("\n=== Auto-push: enabled ===");
{
// Create a bare remote repo
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
// Create local repo and add the bare as remote
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
const svc = new GitServiceImpl(repo, { auto_push: true, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/pushed.ts", "export const pushed = true;");
svc.commit({ message: "work to push" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Add pushed feature");
// Verify the remote has the merge commit
const remoteLog = run(`git --git-dir=${bareDir} log --oneline -1`, bareDir);
assert(remoteLog.includes("Add pushed feature"),
"auto-push: remote has the merge commit when auto_push is true");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
}
// ─── Auto-push: disabled ──────────────────────────────────────────────
console.log("\n=== Auto-push: disabled ===");
{
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
// auto_push explicitly false (or omitted — same behavior)
const svc = new GitServiceImpl(repo, { auto_push: false, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/not-pushed.ts", "export const notPushed = true;");
svc.commit({ message: "work not pushed" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Add unpushed feature");
// Remote should NOT have the new merge commit — still at the initial push
const remoteLog = run(`git --git-dir=${bareDir} log --oneline`, bareDir);
assert(!remoteLog.includes("Add unpushed feature"),
"auto-push: remote does NOT have merge commit when auto_push is false");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
}
// ─── Remote fetch before branching: with remote ────────────────────────
console.log("\n=== Remote fetch: with remote ===");
{
const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
run("git init --bare -b main", bareDir);
const repo = initBranchTestRepo();
run(`git remote add origin ${bareDir}`, repo);
run("git push -u origin main", repo);
// Add a commit to the remote via a temporary clone
const cloneDir = mkdtempSync(join(tmpdir(), "gsd-git-clone-"));
run(`git clone ${bareDir} ${cloneDir}`, cloneDir);
run("git config user.name 'Remote Dev'", cloneDir);
run("git config user.email 'remote@example.com'", cloneDir);
createFile(cloneDir, "remote-file.txt", "from remote");
run("git add -A", cloneDir);
run("git commit -m 'remote commit'", cloneDir);
run("git push origin main", cloneDir);
// ensureSliceBranch should fetch before creating the branch — no crash
const svc = new GitServiceImpl(repo);
let noError = true;
try {
svc.ensureSliceBranch("M001", "S01");
} catch {
noError = false;
}
assert(noError, "ensureSliceBranch succeeds when remote has new commits (fetch runs)");
rmSync(repo, { recursive: true, force: true });
rmSync(bareDir, { recursive: true, force: true });
rmSync(cloneDir, { recursive: true, force: true });
}
// ─── Remote fetch before branching: without remote ─────────────────────
console.log("\n=== Remote fetch: without remote ===");
{
const repo = initBranchTestRepo();
// No remote configured — ensureSliceBranch should not crash
const svc = new GitServiceImpl(repo);
let noError = true;
try {
svc.ensureSliceBranch("M001", "S01");
} catch {
noError = false;
}
assert(noError, "ensureSliceBranch succeeds when no remote is configured");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "branch created even without remote");
rmSync(repo, { recursive: true, force: true });
}
// ─── Facade prefs: mergeSliceToMain creates snapshot when prefs set ────
console.log("\n=== Facade prefs: snapshot via merge with prefs ===");
{
const repo = initBranchTestRepo();
// Simulate facade behavior: GitServiceImpl with snapshots:true should
// create a snapshot ref during mergeSliceToMain
const svc = new GitServiceImpl(repo, { snapshots: true, pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/facade-test.ts", "facade");
svc.commit({ message: "facade test commit" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "Facade snapshot test");
// After merge, a snapshot ref should exist (created before merge)
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assert(refs.includes("refs/gsd/snapshots/"), "mergeSliceToMain creates snapshot when prefs.snapshots is true");
assert(refs.includes("gsd/M001/S01"), "snapshot ref references the slice branch name");
rmSync(repo, { recursive: true, force: true });
}
// ─── Facade prefs: no snapshot when prefs omit snapshots ───────────────
console.log("\n=== Facade prefs: no snapshot when prefs omit snapshots ===");
{
const repo = initBranchTestRepo();
// Default prefs — snapshots not enabled
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
svc.ensureSliceBranch("M001", "S01");
createFile(repo, "src/no-facade-snap.ts", "no facade snap");
svc.commit({ message: "no facade snapshot" });
svc.switchToMain();
svc.mergeSliceToMain("M001", "S01", "No snapshot test");
// No snapshot ref should exist
const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
assertEq(refs, "", "no snapshot ref when snapshots pref is not set");
rmSync(repo, { recursive: true, force: true });
}
// ─── PreMergeCheckResult type export compile check ─────────────────────
console.log("\n=== PreMergeCheckResult type export ===");
{
const _checkResult: PreMergeCheckResult = { passed: true, skipped: false };
assert(true, "PreMergeCheckResult type exported and usable");
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
console.log("All tests passed ✓");

View file

@ -18,6 +18,7 @@
import { sep } from "node:path";
import { GitServiceImpl } from "./git-service.ts";
import { loadEffectiveGSDPreferences } from "./preferences.ts";
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
export type { MergeSliceResult } from "./git-service.ts";
@ -34,7 +35,9 @@ let cachedBasePath: string | null = null;
*/
function getService(basePath: string): GitServiceImpl {
if (cachedService === null || cachedBasePath !== basePath) {
cachedService = new GitServiceImpl(basePath, {});
const loaded = loadEffectiveGSDPreferences();
const gitPrefs = loaded?.preferences?.git ?? {};
cachedService = new GitServiceImpl(basePath, gitPrefs);
cachedBasePath = basePath;
}
return cachedService;