feat(M001/S05): Enhanced features — merge guards, snapshots, auto-push, rich commits
This commit is contained in:
parent
d9d773e44e
commit
d43322c45d
10 changed files with 882 additions and 15 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]`
|
||||
|
|
|
|||
79
.gsd/milestones/M001/slices/S05/S05-PLAN.md
Normal file
79
.gsd/milestones/M001/slices/S05/S05-PLAN.md
Normal 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`
|
||||
59
.gsd/milestones/M001/slices/S05/tasks/T01-PLAN.md
Normal file
59
.gsd/milestones/M001/slices/S05/tasks/T01-PLAN.md
Normal 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).
|
||||
68
.gsd/milestones/M001/slices/S05/tasks/T02-PLAN.md
Normal file
68
.gsd/milestones/M001/slices/S05/tasks/T02-PLAN.md
Normal 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.
|
||||
75
.gsd/milestones/M001/slices/S05/tasks/T03-PLAN.md
Normal file
75
.gsd/milestones/M001/slices/S05/tasks/T03-PLAN.md
Normal 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
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ✓");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue