Merge branch 'main' into fix/tui-resource-leaks-and-quality
This commit is contained in:
commit
f844635de0
33 changed files with 1258 additions and 3627 deletions
29
.gitignore
vendored
29
.gitignore
vendored
|
|
@ -1,6 +1,17 @@
|
|||
|
||||
# ── GSD (user project artifacts — never commit) ──
|
||||
.gsd/
|
||||
# ── GSD runtime (not source artifacts — planning files are tracked) ──
|
||||
.gsd/auto.lock
|
||||
.gsd/completed-units.json
|
||||
.gsd/STATE.md
|
||||
.gsd/metrics.json
|
||||
.gsd/gsd.db
|
||||
.gsd/activity/
|
||||
.gsd/runtime/
|
||||
.gsd/worktrees/
|
||||
.gsd/DISCUSSION-MANIFEST.json
|
||||
.gsd/milestones/**/*-CONTINUE.md
|
||||
.gsd/milestones/**/continue.md
|
||||
|
||||
.claude/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
|
|
@ -48,17 +59,3 @@ AGENTS.md
|
|||
.bg-shell/
|
||||
TODOS.md
|
||||
.planning/
|
||||
|
||||
# ── GSD baseline (auto-generated) ──
|
||||
.gsd/
|
||||
|
||||
# ── GSD baseline (auto-generated) ──
|
||||
.gsd/activity/
|
||||
.gsd/runtime/
|
||||
.gsd/worktrees/
|
||||
.gsd/auto.lock
|
||||
.gsd/metrics.json
|
||||
.gsd/STATE.md
|
||||
|
||||
# ── GSD baseline (auto-generated) ──
|
||||
.gsd/completed-units.json
|
||||
|
|
|
|||
157
.plans/startup-performance.md
Normal file
157
.plans/startup-performance.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# GSD Startup Performance Analysis & Optimization Plan
|
||||
|
||||
## Measured Baseline (macOS, Node v25.6.1)
|
||||
|
||||
### `gsd --version` (simplest possible path): **2.2 seconds**
|
||||
|
||||
| Phase | Time | Notes |
|
||||
|-------|------|-------|
|
||||
| Node.js process startup | ~160ms | Unavoidable |
|
||||
| loader.js top-level imports | ~13ms | fs, app-paths, logo |
|
||||
| undici import + proxy setup | ~200ms | EnvHttpProxyAgent |
|
||||
| **@gsd/pi-coding-agent barrel import** | **~970ms** | THE BOTTLENECK |
|
||||
| cli.js other imports | ~3ms | resource-loader, wizard, etc. |
|
||||
| Arg parsing + version print | ~0ms | |
|
||||
| Measured wall time overhead | ~700ms | ESM resolution, gc, etc. |
|
||||
|
||||
### Full interactive startup: **~3.6 seconds** (post-node)
|
||||
|
||||
| Phase | Time | Notes |
|
||||
|-------|------|-------|
|
||||
| @gsd/pi-coding-agent import | ~750ms | (cached from loader measurement) |
|
||||
| ensureManagedTools | ~0ms | No-op after first run |
|
||||
| AuthStorage + env keys | ~3ms | |
|
||||
| ModelRegistry | ~1ms | |
|
||||
| SettingsManager | ~1ms | |
|
||||
| **initResources (cpSync)** | **~128ms** | Copies all extensions/skills/agents on every launch |
|
||||
| **resourceLoader.reload()** | **~2535ms** | jiti-compiles 17+ extensions from TypeScript |
|
||||
|
||||
### Inside @gsd/pi-coding-agent (barrel import breakdown)
|
||||
|
||||
| Sub-module | Time | Notes |
|
||||
|------------|------|-------|
|
||||
| Mistral SDK (@mistralai/mistralai) | 369ms | Loaded even if unused |
|
||||
| Google GenAI SDK (@google/genai) | 186ms | Loaded even if unused |
|
||||
| extensions/index.js (circular → index.js) | 497ms | Pulls in everything |
|
||||
| tools/index.js | 124ms | Tool definitions |
|
||||
| @sinclair/typebox | 64ms | Schema validation |
|
||||
| OpenAI SDK | 52ms | |
|
||||
| Anthropic SDK | 50ms | |
|
||||
|
||||
---
|
||||
|
||||
## Root Causes (Priority Order)
|
||||
|
||||
### 1. Extension JIT compilation via jiti (~2.5s)
|
||||
Every launch compiles 17+ TypeScript extensions to JavaScript using jiti. No caching (`moduleCache: false` is explicitly set). This is the single largest cost.
|
||||
|
||||
### 2. Barrel import of @gsd/pi-coding-agent (~1s)
|
||||
`cli.js` line 1 does a barrel import pulling in ALL exports including all LLM provider SDKs, TUI components, theme system, compaction, blob store, etc.
|
||||
|
||||
### 3. Eager LLM SDK loading (~660ms inside barrel)
|
||||
All provider SDKs are imported at module evaluation time in `pi-ai/index.js`, even though only one provider is typically configured.
|
||||
|
||||
### 4. initResources copies files every launch (~128ms)
|
||||
`cpSync` with `force: true` copies all bundled resources to `~/.gsd/agent/` on every startup, even when nothing changed.
|
||||
|
||||
### 5. undici import (~200ms)
|
||||
Imported in loader.js for proxy support. Not needed for most users.
|
||||
|
||||
---
|
||||
|
||||
## Optimization Plan
|
||||
|
||||
### Phase 1: Quick Wins (est. save ~1-1.5s on --version, ~0.5s interactive)
|
||||
|
||||
#### 1A. Fast-path for `--version` and `--help`
|
||||
Parse argv BEFORE importing cli.js. In loader.js, check for `--version`/`-v` and `--help`/`-h` and exit immediately without loading any dependencies.
|
||||
|
||||
**File**: `src/loader.ts`
|
||||
**Change**: Add arg check before `await import('./cli.js')`
|
||||
**Impact**: `gsd --version` goes from 2.2s → ~0.2s
|
||||
|
||||
#### 1B. Skip initResources when unchanged
|
||||
Compare `managed-resources.json` version against current `GSD_VERSION`. If they match, skip the `cpSync` entirely.
|
||||
|
||||
**File**: `src/resource-loader.ts` → `initResources()`
|
||||
**Change**: Early return if versions match
|
||||
**Impact**: Save ~128ms per launch
|
||||
|
||||
#### 1C. Lazy-load undici
|
||||
Only import undici when HTTP_PROXY/HTTPS_PROXY env vars are actually set.
|
||||
|
||||
**File**: `src/loader.ts`
|
||||
**Change**: Wrap undici import in proxy env check
|
||||
**Impact**: Save ~200ms for most users
|
||||
|
||||
### Phase 2: Lazy Provider Loading (est. save ~600ms interactive)
|
||||
|
||||
#### 2A. Lazy-load LLM provider SDKs
|
||||
Instead of importing all providers at module level in `pi-ai/index.js`, use dynamic `import()` in the provider factory functions. Only load the SDK when a model from that provider is actually requested.
|
||||
|
||||
**Files**: `packages/pi-ai/src/providers/*.ts`
|
||||
**Change**: Move `import { Anthropic } from '@anthropic-ai/sdk'` etc. to dynamic imports inside `complete()` / `stream()` functions
|
||||
**Impact**: Save ~600ms (Mistral 369ms + Google 186ms + extras) for users who only use one provider
|
||||
|
||||
#### 2B. Selective re-exports in pi-ai barrel
|
||||
Instead of `export * from "./providers/mistral.js"` etc., only export the registration function. Provider internals stay private.
|
||||
|
||||
**File**: `packages/pi-ai/src/index.ts`
|
||||
|
||||
### Phase 3: Extension Loading Optimization (est. save ~1.5-2s interactive)
|
||||
|
||||
#### 3A. Enable jiti module caching
|
||||
Remove `moduleCache: false` from the jiti config, or use a persistent cache directory.
|
||||
|
||||
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts`
|
||||
**Change**: Set `moduleCache: true` or configure `cacheDir`
|
||||
**Impact**: Second+ launches save ~1-2s on extension loading
|
||||
|
||||
#### 3B. Pre-compile extensions at build time
|
||||
Instead of JIT-compiling TypeScript extensions at runtime, compile them to JavaScript during `npm run build`. The runtime loader can then just `import()` the .js files directly without jiti.
|
||||
|
||||
**Files**: `package.json` build scripts, `src/resource-loader.ts`, extension loader
|
||||
**Change**: Add build step to compile extensions; loader checks for .js first
|
||||
**Impact**: Eliminate ~2.5s of jiti compilation entirely
|
||||
**Complexity**: HIGH — requires careful handling of extension resolution paths
|
||||
|
||||
#### 3C. Parallel extension loading
|
||||
Currently extensions load sequentially in a `for` loop. Load them in parallel with `Promise.all()`.
|
||||
|
||||
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts` → `loadExtensions()`
|
||||
**Change**: `await Promise.all(paths.map(...))` instead of sequential for-loop
|
||||
**Impact**: Wall time reduction depends on I/O overlap; est. 30-50% faster
|
||||
|
||||
### Phase 4: Bundle Optimization (est. save ~300-500ms)
|
||||
|
||||
#### 4A. Use esbuild/tsup for the main CLI bundle
|
||||
Replace plain `tsc` with a bundler that does tree-shaking. A single-file bundle eliminates ESM resolution overhead and removes unused code.
|
||||
|
||||
**Impact**: Faster module resolution, smaller output, tree-shaking removes unused exports
|
||||
**Complexity**: MEDIUM
|
||||
|
||||
#### 4B. Split pi-coding-agent into entry-point chunks
|
||||
Instead of one barrel export, provide separate entry points for core, interactive, tools.
|
||||
|
||||
**Impact**: cli.js can import only what it needs for each code path
|
||||
**Complexity**: HIGH — changes public API surface
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. **Phase 1A** — Fast-path --version/--help (trivial, huge UX impact)
|
||||
2. **Phase 1C** — Lazy undici (easy, 200ms saved)
|
||||
3. **Phase 1B** — Skip initResources (easy, 128ms saved)
|
||||
4. **Phase 3C** — Parallel extension loading (moderate, ~1s saved)
|
||||
5. **Phase 2A** — Lazy provider SDKs (moderate, ~600ms saved)
|
||||
6. **Phase 3A** — jiti caching (easy, ~1s saved on repeat launches)
|
||||
7. **Phase 3B** — Pre-compile extensions (hard, eliminates jiti entirely)
|
||||
8. **Phase 4A** — Bundle with esbuild (medium, ~300-500ms)
|
||||
|
||||
### Expected Results
|
||||
|
||||
| Scenario | Before | After (Phase 1-3) | After (All) |
|
||||
|----------|--------|-------------------|-------------|
|
||||
| `gsd --version` | 2.2s | **~0.2s** | ~0.2s |
|
||||
| Interactive startup | ~3.8s | **~1.5s** | **~0.8s** |
|
||||
279
docs/ADR-001-branchless-worktree-architecture.md
Normal file
279
docs/ADR-001-branchless-worktree-architecture.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# ADR-001: Branchless Worktree Architecture
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-03-15
|
||||
**Deciders:** Lex Christopherson
|
||||
**Advisors:** Claude Opus 4.6, Gemini 2.5 Pro, GPT-5.4 (Codex)
|
||||
|
||||
## Context
|
||||
|
||||
GSD uses git for isolation during autonomous coding sessions. The current architecture (shipped in M003, v2.13.0) creates a **worktree per milestone** with **slice branches inside each worktree**. Each slice (`S01`, `S02`, ...) gets its own branch (`gsd/M001/S01`) within the worktree, which merges back to the milestone branch (`milestone/M001`) via `--no-ff` when the slice completes. The milestone branch squash-merges to `main` when the milestone completes.
|
||||
|
||||
This architecture replaced a previous "branch-per-slice" model that had severe `.gsd/` merge conflicts. M003 solved the merge conflicts but retained slice branches inside worktrees, inheriting complexity that has produced persistent, user-facing failures.
|
||||
|
||||
### Problems
|
||||
|
||||
**1. Planning artifact invisibility (loop detection failures)**
|
||||
|
||||
When `research-slice` or `plan-slice` dispatches, the agent writes artifacts (e.g., `S02-RESEARCH.md`) on a slice branch. After the agent completes, `handleAgentEnd` switches back to the milestone branch for the next dispatch. The artifact is on the slice branch, not the milestone branch. `verifyExpectedArtifact()` checks the milestone branch, can't find the file, increments the loop counter, retries, same result. After 3 retries → hard stop. After 6 lifetime dispatches → permanent stop. This burns budget and blocks progress.
|
||||
|
||||
Documented in the auto-stop architecture doc as "The Branch-Switching Problem."
|
||||
|
||||
**2. `.gsd/` state clobbering across branches**
|
||||
|
||||
`.gsd/` is gitignored (line 52 of `.gitignore`: `.gsd/`). Planning artifacts (roadmaps, plans, summaries, decisions, requirements) live in `.gsd/milestones/` but are invisible to git. When multiple branches or worktrees operate from the same repo, they share a single `.gsd/` directory on disk. Branch A's M001 roadmap overwrites Branch B's M001 roadmap. GSD reads corrupted state, shows wrong milestone as complete, or enters infinite dispatch loops.
|
||||
|
||||
The codebase has a contradictory workaround: `smartStage()` (git-service.ts:304-352) force-adds `GSD_DURABLE_PATHS` (milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md) despite the `.gitignore`. This means `.gsd/milestones/` IS partially tracked on some branches but the gitignore claims otherwise. The code fights the configuration.
|
||||
|
||||
**3. Merge/conflict code complexity**
|
||||
|
||||
The current slice branch model requires:
|
||||
- `mergeSliceToMilestone()` — 98 lines, `--no-ff` merge with `withMergeHeal` wrapper
|
||||
- `mergeSliceToMain()` — 189 lines, squash-merge with conflict detection/categorization/auto-resolution
|
||||
- `git-self-heal.ts` — 198 lines, 3 recovery functions for merge failures
|
||||
- `fix-merge` dispatch unit — dedicated LLM session to resolve conflicts the auto-resolver can't handle
|
||||
- `smartStage()` — 49 lines of runtime exclusion during staging
|
||||
- Conflict categorization — 80 lines classifying `.gsd/` vs runtime vs code conflicts
|
||||
|
||||
Total: **~582 lines** of merge/branch/conflict code across 3 files, plus the `fix-merge` prompt template and dispatch logic. This code exists solely because of slice branches.
|
||||
|
||||
**4. Dual isolation modes**
|
||||
|
||||
Branch-mode (`git-service.ts:mergeSliceToMain`) and worktree-mode (`auto-worktree.ts:mergeSliceToMilestone`) have parallel implementations with different merge strategies, different conflict handling, and different branch naming. Both paths must be maintained and tested. 11 test files exercise merge/branch/worktree logic.
|
||||
|
||||
**5. Bug history**
|
||||
|
||||
- v2.11.1: URGENT fix for parse cache staleness causing repeated unit dispatch (directly caused by branch switching invalidation timing)
|
||||
- v2.13.1: Windows hotfix for multi-line commit messages in `mergeSliceToMilestone`
|
||||
- 15+ separate bug fixes for `.gsd/` merge conflicts in the pre-M003 era
|
||||
- Persistent user complaints about loop detection failures and state corruption
|
||||
|
||||
## Decision
|
||||
|
||||
**Eliminate slice branches entirely.** All work within a milestone worktree commits sequentially on a single branch (`milestone/<MID>`). No branch creation, no branch switching, no slice merges, no conflict resolution within a worktree.
|
||||
|
||||
Track `.gsd/` planning artifacts in git. Gitignore only runtime/ephemeral state.
|
||||
|
||||
### The Architecture
|
||||
|
||||
```
|
||||
main ──────────────────────────────────────────── main
|
||||
│ ↑
|
||||
└─ worktree (milestone/M001) │
|
||||
│ │
|
||||
commit: feat(M001): context + roadmap │
|
||||
commit: feat(M001/S01): research │
|
||||
commit: feat(M001/S01): plan │
|
||||
commit: feat(M001/S01/T01): impl │
|
||||
commit: feat(M001/S01/T02): impl │
|
||||
commit: feat(M001/S01): summary + UAT │
|
||||
commit: feat(M001/S02): research │
|
||||
commit: ... │
|
||||
commit: feat(M001): milestone complete │
|
||||
│ │
|
||||
└──────────── squash merge ──────────────────┘
|
||||
```
|
||||
|
||||
### Git Primitives Used
|
||||
|
||||
| Primitive | Purpose |
|
||||
|-----------|---------|
|
||||
| **Worktrees** | One per active milestone. Filesystem isolation. |
|
||||
| **Commits** | Granular sequential history of every action. |
|
||||
| **Squash merge** | Clean single commit on `main` per milestone. |
|
||||
| **Branches** | Only `main` and `milestone/<MID>`. Nothing else. |
|
||||
|
||||
### Git Primitives NOT Used
|
||||
|
||||
| Primitive | Why Not |
|
||||
|-----------|---------|
|
||||
| Slice branches | Slices are sequential. Branches add complexity with no rollback benefit. |
|
||||
| `--no-ff` merges | No branches to merge within a worktree. |
|
||||
| Branch switching | Never happens. All work on one branch. |
|
||||
| Conflict resolution | No merges within a worktree means no conflicts within a worktree. |
|
||||
|
||||
### `.gsd/` Tracking Model
|
||||
|
||||
**Tracked in git (travels with the branch):**
|
||||
```
|
||||
.gsd/milestones/ — roadmaps, plans, summaries, research, contexts, task plans/summaries
|
||||
.gsd/PROJECT.md — project overview
|
||||
.gsd/DECISIONS.md — architectural decision register
|
||||
.gsd/REQUIREMENTS.md — requirements register
|
||||
.gsd/QUEUE.md — work queue
|
||||
```
|
||||
|
||||
**Gitignored (ephemeral, runtime, infrastructure):**
|
||||
```
|
||||
.gsd/runtime/ — dispatch records, timeout tracking
|
||||
.gsd/activity/ — JSONL session dumps
|
||||
.gsd/worktrees/ — git worktree working directories
|
||||
.gsd/auto.lock — crash detection sentinel
|
||||
.gsd/metrics.json — token/cost accumulator
|
||||
.gsd/completed-units.json — dispatch idempotency tracker
|
||||
.gsd/STATE.md — derived state cache (rebuilt by deriveState())
|
||||
.gsd/gsd.db — SQLite cache (rebuilt from tracked markdown by importers)
|
||||
.gsd/DISCUSSION-MANIFEST.json — discussion phase tracking
|
||||
.gsd/milestones/**/*-CONTINUE.md — interrupted-work markers
|
||||
.gsd/milestones/**/continue.md — legacy continue markers
|
||||
```
|
||||
|
||||
### `.gitignore` Update
|
||||
|
||||
Replace the current blanket `.gsd/` ignore with explicit runtime-only ignores:
|
||||
|
||||
```gitignore
|
||||
# ── GSD: Runtime / Ephemeral ─────────────────────────────────
|
||||
.gsd/auto.lock
|
||||
.gsd/completed-units.json
|
||||
.gsd/STATE.md
|
||||
.gsd/metrics.json
|
||||
.gsd/gsd.db
|
||||
.gsd/activity/
|
||||
.gsd/runtime/
|
||||
.gsd/worktrees/
|
||||
.gsd/DISCUSSION-MANIFEST.json
|
||||
.gsd/milestones/**/*-CONTINUE.md
|
||||
.gsd/milestones/**/continue.md
|
||||
```
|
||||
|
||||
Planning artifacts (milestones/, PROJECT.md, DECISIONS.md, REQUIREMENTS.md, QUEUE.md) are NOT in `.gitignore` and are tracked normally.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Code Deletion
|
||||
|
||||
| File | Lines Deleted | What's Removed |
|
||||
|------|--------------|----------------|
|
||||
| `auto-worktree.ts` | ~246 | `mergeSliceToMilestone()`, `shouldUseWorktreeIsolation()`, `getMergeToMainMode()`, slice merge guards |
|
||||
| `git-service.ts` | ~250 | `mergeSliceToMain()`, conflict resolution, runtime stripping post-merge, `ensureSliceBranch()`, `switchToMain()` |
|
||||
| `git-self-heal.ts` | ~86 | `abortAndReset()`, `withMergeHeal()` (merge-specific recovery) |
|
||||
| `auto.ts` | ~150 | Merge dispatch guards, `fix-merge` dispatch path, branch-mode routing |
|
||||
| `worktree.ts` | ~40 | `getSliceBranchName()`, `ensureSliceBranch()`, `mergeSliceToMain()` delegates |
|
||||
| **Test files** | ~11 files | `auto-worktree-merge.test.ts`, `auto-worktree-milestone-merge.test.ts`, merge-related test cases |
|
||||
| **Total** | **~770+ lines** | |
|
||||
|
||||
### What `mergeMilestoneToMain()` Becomes
|
||||
|
||||
The function simplifies dramatically:
|
||||
1. Auto-commit any dirty state in worktree
|
||||
2. `chdir` back to main repo root
|
||||
3. `git checkout main`
|
||||
4. `git merge --squash milestone/<MID>`
|
||||
5. `git commit` with milestone summary
|
||||
6. Remove worktree + delete branch
|
||||
|
||||
No conflict categorization. No runtime file stripping. No `.gsd/` special handling. Planning artifacts merge cleanly because they're in `.gsd/milestones/M001/` which doesn't exist on `main` until this merge.
|
||||
|
||||
### What `smartStage()` Becomes
|
||||
|
||||
The force-add of `GSD_DURABLE_PATHS` is no longer needed — planning artifacts are not gitignored, so `git add -A` picks them up naturally. The function reduces to:
|
||||
|
||||
1. `git add -A`
|
||||
2. `git reset HEAD -- <runtime paths>` (unstage runtime files)
|
||||
|
||||
The `_runtimeFilesCleanedUp` one-time migration logic can also be removed.
|
||||
|
||||
### What Happens to `handleAgentEnd()`
|
||||
|
||||
After any unit completes:
|
||||
1. Invalidate caches
|
||||
2. `autoCommitCurrentBranch()` — commits on the one and only branch
|
||||
3. `verifyExpectedArtifact()` — file is always on the current branch (no branch switching)
|
||||
4. Persist completion key
|
||||
|
||||
The "Path A fix" (lines 937-953) becomes the only path. No branch mismatch possible.
|
||||
|
||||
### What Happens to `fix-merge`
|
||||
|
||||
The `fix-merge` dispatch unit type is eliminated. Within a worktree, there are no merges that can conflict. The only merge is milestone→main (squash), and if that conflicts (rare, parallel milestone edge case), it's handled as a one-time resolution at milestone completion — not a dispatch loop.
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
The `shouldUseWorktreeIsolation()` three-tier preference resolution is replaced by a single behavior: worktree isolation is always used. The `git.isolation: "branch"` preference is deprecated.
|
||||
|
||||
Projects with existing `gsd/M001/S01` slice branches can still be read by state derivation, but new work never creates slice branches.
|
||||
|
||||
### Risks
|
||||
|
||||
**1. Parallel milestone code conflicts at squash-merge time**
|
||||
|
||||
If two milestones modify the same source file, the second squash-merge to `main` will conflict. Mitigation: `git fetch origin main && git rebase main` before squash-merge. This is standard practice and rare in single-user workflows.
|
||||
|
||||
**2. Loss of per-slice git history after squash**
|
||||
|
||||
Squash merge collapses all commits into one on `main`. Mitigations:
|
||||
- Commit messages tag slices (`feat(M001/S01/T01):`) — filterable with `git log --grep`
|
||||
- The milestone branch can be preserved (not deleted) if history is needed
|
||||
- Alternative: `merge --no-ff` instead of `--squash` to keep history on `main`
|
||||
|
||||
**3. SQLite DB desync after `git reset`**
|
||||
|
||||
If tracked markdown rolls back via `git reset --hard`, the gitignored `gsd.db` doesn't. Mitigation: the importer layer (M001/S02) rebuilds the DB from markdown on startup. The DB is a cache, markdown is truth.
|
||||
|
||||
**4. Disk space with multiple worktrees**
|
||||
|
||||
Each worktree duplicates the working directory (including `node_modules`). Mitigation: single active milestone at a time (single-user workflow), immediate cleanup after completion.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A. Keep slice branches, fix visibility with immediate mini-merges
|
||||
|
||||
After `research-slice` or `plan-slice`, immediately merge the slice branch back to the milestone branch. This fixes the loop detection bug but retains all merge complexity.
|
||||
|
||||
**Rejected:** Adds another merge path instead of removing the root cause. Still requires conflict resolution, self-healing, branch switching.
|
||||
|
||||
### B. Keep `.gsd/` gitignored, bootstrap from git history for manual worktrees
|
||||
|
||||
When GSD detects an empty `.gsd/` in a worktree, reconstruct state from the branch's git history using `git show <commit>:.gsd/...`.
|
||||
|
||||
**Rejected:** Recovery logic, not architecture. Doesn't fix the fundamental problem of branch-agnostic state. Fails when git history has been rewritten.
|
||||
|
||||
### C. Branch-scoped `.gsd/` directories (`.gsd/branches/<branch-name>/milestones/...`)
|
||||
|
||||
Each branch writes to a namespaced subdirectory within `.gsd/`.
|
||||
|
||||
**Rejected:** Adds complexity instead of removing it. Requires renaming/moving on branch creation, doesn't work with standard git tools (`git checkout` doesn't rename directories).
|
||||
|
||||
## Validation
|
||||
|
||||
This architecture was stress-tested by three independent models:
|
||||
|
||||
**Gemini 2.5 Pro** identified 6 attack vectors. None broke the core model. Recommendations: pre-flight rebase before squash-merge (adopted), heartbeat locks (already exists), DB rebuild on startup (adopted via M001/S02 importers).
|
||||
|
||||
**GPT-5.4 (Codex)** read the full codebase and confirmed the model is sound. Identified that `smartStage()` already force-adds durable paths (validating the tracked-artifact approach) and that `resolveMainWorktreeRoot` in PR #487 is architecturally wrong (adopted — PR to be closed).
|
||||
|
||||
**Codebase analysis** confirmed `.gsd/milestones/` is already partially tracked on `main` despite the `.gitignore`, that `GSD_DURABLE_PATHS` exists as a code-level acknowledgment that planning artifacts should be tracked, and that the README already documents the correct runtime-only gitignore pattern.
|
||||
|
||||
### Codex (GPT-5.4) Dissent — "No Slice Branches Is a Redesign"
|
||||
|
||||
Codex read the full codebase and raised 4 concerns. Each is addressed:
|
||||
|
||||
**Concern 1: "Crash after slice done but before integration — today the runtime detects orphaned slice branches and merges them."**
|
||||
|
||||
Rebuttal: In the branchless model, there is no integration step to crash between. Slice work is committed directly on the milestone branch. On restart, `deriveState()` reads the branch state as-is. The orphaned-branch recovery path exists solely because of slice branches — removing branches removes the failure mode it recovers from.
|
||||
|
||||
**Concern 2: "Concurrent edits to shared root docs (PROJECT.md, DECISIONS.md) from two terminals."**
|
||||
|
||||
Rebuttal: Valid edge case. If `/gsd queue` edits `DECISIONS.md` on `main` while auto-mode edits it in a worktree, there's a content conflict at squash-merge time. This is a standard git content conflict — no different from two developers editing the same file. Handled by normal merge resolution. Not caused by or solved by slice branches.
|
||||
|
||||
**Concern 3: "Slice→milestone merges provide continuous integration. Removing them pushes conflict discovery to the end."**
|
||||
|
||||
Rebuttal: In a single-user sequential workflow, there is nothing to integrate against within a worktree. Each slice builds on the previous one. The only conflict source is `main` diverging (e.g., another milestone merging first), which slice→milestone merges don't catch anyway — they merge within the worktree, not against `main`. Pre-flight rebase before squash-merge catches this more directly.
|
||||
|
||||
**Concern 4: "Replace slice branches with another explicit slice-boundary primitive. Don't just delete them."**
|
||||
|
||||
Response: Accepted in spirit. Commits with conventional tags (`feat(M001/S01):`, `feat(M001/S01/T01):`) serve as the slice boundary primitive. `git log --grep="M001/S01"` isolates a slice's history. `git revert` targets specific commits. Git tags (`gsd/M001/S01-complete`) can mark slice completion if needed. The boundary primitive is commit metadata, not branches.
|
||||
|
||||
## Action Items
|
||||
|
||||
1. Close PR #487 (`resolveMainWorktreeRoot`) — contradicts this architecture
|
||||
2. Implement as a GSD milestone with phases:
|
||||
- Update `.gitignore` and force-add existing planning artifacts
|
||||
- Remove slice branch creation/switching/merging code
|
||||
- Simplify `mergeMilestoneToMain()` and `smartStage()`
|
||||
- Remove `fix-merge` dispatch unit
|
||||
- Remove branch-mode isolation (`git.isolation: "branch"`)
|
||||
- Update/delete 11 test files
|
||||
- Update README suggested gitignore
|
||||
- Migration path for existing projects with slice branches
|
||||
383
docs/PRD-branchless-worktree-architecture.md
Normal file
383
docs/PRD-branchless-worktree-architecture.md
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
# PRD: Branchless Worktree Architecture
|
||||
|
||||
**Author:** Lex Christopherson
|
||||
**Date:** 2026-03-15
|
||||
**ADR:** [ADR-001-branchless-worktree-architecture.md](./ADR-001-branchless-worktree-architecture.md)
|
||||
**Priority:** Critical — blocks reliable auto-mode operation
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
GSD's auto-mode is unreliable. Users experience:
|
||||
|
||||
1. **Infinite loop detection failures** — the agent writes planning artifacts on slice branches that become invisible after branch switching, causing `verifyExpectedArtifact()` to fail repeatedly. Auto-mode burns budget retrying the same unit 3-6 times before hard-stopping. This is the #1 user complaint.
|
||||
|
||||
2. **State corruption across branches** — `.gsd/` planning artifacts (roadmaps, plans, decisions) are gitignored but branch-specific. Multiple branches sharing a single `.gsd/` directory clobber each other's state. Users see wrong milestones marked complete, wrong roadmaps loaded, and auto-mode starting from the wrong phase.
|
||||
|
||||
3. **Excessive complexity** — 770+ lines of merge, conflict resolution, branch switching, and self-healing code exist solely to manage slice branches inside worktrees. This code has required 15+ bug fixes across versions and remains the primary source of auto-mode failures.
|
||||
|
||||
These problems are architectural. They cannot be fixed by patching individual symptoms.
|
||||
|
||||
## Vision
|
||||
|
||||
Auto-mode uses git worktrees for isolation and sequential commits for history. No branch switching. No merge conflicts within a worktree. Planning artifacts are tracked in git and travel with the branch. The git layer is so simple it can't break.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| Criterion | Measurement |
|
||||
|-----------|-------------|
|
||||
| Zero loop detection failures from branch visibility | No `verifyExpectedArtifact()` failures caused by branch mismatch in 50 consecutive auto-mode runs |
|
||||
| Zero `.gsd/` state corruption | Manual worktrees created via `git worktree add` have correct `.gsd/` state without any GSD-specific initialization |
|
||||
| Code deletion | Net removal of ≥500 lines of merge/conflict/branch-switching code |
|
||||
| Test simplification | Removal or simplification of ≥6 merge-specific test files |
|
||||
| Backwards compatibility | Existing projects with `gsd/M001/S01` slice branches continue to work (read-only; new work uses new model) |
|
||||
| No new git primitives | The implementation uses only: worktrees, commits, squash-merge. No new branch types, merge strategies, or conflict resolution. |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Parallel slice execution within a single worktree (if needed later, use separate worktrees)
|
||||
- Changing how milestones relate to `main` (squash-merge stays)
|
||||
- Modifying the dispatch unit types or state machine (except removing `fix-merge`)
|
||||
- Changing the worktree-manager.ts manual worktree API (`/worktree` command)
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Branch Model (M003, v2.13.0)
|
||||
|
||||
```
|
||||
main
|
||||
└─ milestone/M001 (worktree at .gsd/worktrees/M001/)
|
||||
├─ gsd/M001/S01 (slice branch — code + .gsd/ artifacts)
|
||||
│ └── merge --no-ff → milestone/M001
|
||||
├─ gsd/M001/S02
|
||||
│ └── merge --no-ff → milestone/M001
|
||||
└── squash merge → main
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Agent writes file → on slice branch → handleAgentEnd → auto-commit on slice branch
|
||||
→ switch to milestone branch → verifyExpectedArtifact → FILE NOT FOUND (it's on slice branch)
|
||||
→ loop counter++ → retry → same result → HARD STOP
|
||||
```
|
||||
|
||||
### Code Involved
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `auto-worktree.ts` | 512 | Worktree lifecycle + slice→milestone merge |
|
||||
| `git-service.ts` | 915 | Branch creation, switching, merge with conflict resolution |
|
||||
| `git-self-heal.ts` | 198 | Merge failure recovery |
|
||||
| `auto.ts` | ~150 lines | Merge dispatch guards, fix-merge routing, branch-mode vs worktree-mode branching |
|
||||
| `worktree.ts` | ~40 lines | Slice branch delegates |
|
||||
| 11 test files | ~2000 lines | Merge/branch/worktree test coverage |
|
||||
|
||||
### `.gsd/` Tracking (Current — Contradictory)
|
||||
|
||||
- `.gitignore` line 52: `.gsd/` — ignores everything
|
||||
- `smartStage()` lines 338-349: force-adds `GSD_DURABLE_PATHS` — tracks milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md
|
||||
- Result: `.gsd/milestones/` is partially tracked on some branches, fully ignored on others. The code fights the config.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Branch Model
|
||||
|
||||
```
|
||||
main
|
||||
└─ milestone/M001 (worktree at .gsd/worktrees/M001/)
|
||||
│
|
||||
commit: feat(M001): context + roadmap
|
||||
commit: feat(M001/S01): research
|
||||
commit: feat(M001/S01): plan
|
||||
commit: feat(M001/S01/T01): implement auth service
|
||||
commit: feat(M001/S01/T02): implement auth tests
|
||||
commit: feat(M001/S01): summary + UAT
|
||||
commit: docs(M001): reassess roadmap after S01
|
||||
commit: feat(M001/S02): research
|
||||
commit: feat(M001/S02): plan
|
||||
commit: ...
|
||||
commit: feat(M001): milestone complete
|
||||
│
|
||||
└── squash merge → main
|
||||
```
|
||||
|
||||
One branch. Sequential commits. No merges within the worktree.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Agent writes file → on milestone branch → handleAgentEnd → auto-commit on milestone branch
|
||||
→ verifyExpectedArtifact → FILE FOUND (same branch) → persist completion → next dispatch
|
||||
```
|
||||
|
||||
### `.gsd/` Tracking (Proposed — Coherent)
|
||||
|
||||
**Tracked (travels with branch):**
|
||||
```
|
||||
.gsd/milestones/**/*.md (except CONTINUE markers)
|
||||
.gsd/milestones/**/*.json (META.json integration records)
|
||||
.gsd/PROJECT.md
|
||||
.gsd/DECISIONS.md
|
||||
.gsd/REQUIREMENTS.md
|
||||
.gsd/QUEUE.md
|
||||
```
|
||||
|
||||
**Gitignored (ephemeral):**
|
||||
```
|
||||
.gsd/auto.lock
|
||||
.gsd/completed-units.json
|
||||
.gsd/STATE.md
|
||||
.gsd/metrics.json
|
||||
.gsd/gsd.db
|
||||
.gsd/activity/
|
||||
.gsd/runtime/
|
||||
.gsd/worktrees/
|
||||
.gsd/DISCUSSION-MANIFEST.json
|
||||
.gsd/milestones/**/*-CONTINUE.md
|
||||
.gsd/milestones/**/continue.md
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
| Problem | How It's Solved |
|
||||
|---------|----------------|
|
||||
| Artifact invisibility after branch switch | No branch switching. Artifacts commit on the one branch. |
|
||||
| `.gsd/` state clobbering | Artifacts tracked in git. Each branch carries its own `.gsd/`. `git worktree add` and `git checkout` give correct state. |
|
||||
| Merge conflict complexity | No merges within a worktree. Only merge is milestone→main (squash). |
|
||||
| Manual worktree initialization | Tracked artifacts are checked out with the branch. No GSD-specific bootstrap needed. |
|
||||
| Dual isolation mode maintenance | Single mode: worktree. Branch-mode (`git.isolation: "branch"`) deprecated. |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: `.gitignore` + Tracking Fix
|
||||
|
||||
**Goal:** Planning artifacts are tracked in git. `.gitignore` reflects reality.
|
||||
|
||||
1. Update `.gitignore`:
|
||||
- Remove blanket `.gsd/` ignore
|
||||
- Add explicit runtime-only ignores (see proposed list above)
|
||||
|
||||
2. Force-add existing planning artifacts on current branch:
|
||||
```
|
||||
git add --force .gsd/milestones/ .gsd/PROJECT.md .gsd/DECISIONS.md .gsd/REQUIREMENTS.md .gsd/QUEUE.md
|
||||
```
|
||||
|
||||
3. Ensure runtime files are NOT tracked:
|
||||
```
|
||||
git rm --cached -r .gsd/runtime/ .gsd/activity/ .gsd/STATE.md .gsd/metrics.json .gsd/completed-units.json .gsd/auto.lock
|
||||
```
|
||||
|
||||
4. Update README suggested `.gitignore` section
|
||||
|
||||
5. Remove `smartStage()` force-add of `GSD_DURABLE_PATHS` — no longer needed since `.gitignore` doesn't block them
|
||||
|
||||
**Verification:** `git status` shows planning artifacts tracked, runtime files untracked. `git worktree add` on a new worktree has correct `.gsd/milestones/` state.
|
||||
|
||||
### Phase 2: Remove Slice Branch Creation + Switching
|
||||
|
||||
**Goal:** No code creates, switches to, or references slice branches for new work.
|
||||
|
||||
1. Remove `ensureSliceBranch()` from `git-service.ts` (lines 485-544)
|
||||
2. Remove `switchToMain()` from `git-service.ts` (lines 549-563)
|
||||
3. Remove `getSliceBranchName()` from `worktree.ts` (lines 94-98)
|
||||
4. Remove `isOnSliceBranch()` and `getActiveSliceBranch()` from `worktree.ts`
|
||||
5. Update `auto.ts` dispatch paths — remove branch creation before `execute-task`
|
||||
6. Update `handleAgentEnd` — remove branch-switching logic post-dispatch
|
||||
|
||||
**Verification:** Auto-mode runs a full slice (research → plan → execute → complete) without creating any branches. All commits land on `milestone/<MID>`.
|
||||
|
||||
### Phase 3: Remove Slice Merge Code
|
||||
|
||||
**Goal:** All slice→milestone and slice→main merge code is deleted.
|
||||
|
||||
1. Remove `mergeSliceToMilestone()` from `auto-worktree.ts` (lines 253-350)
|
||||
2. Remove `mergeSliceToMain()` from `git-service.ts` (lines 705-893)
|
||||
3. Remove merge dispatch guards from `auto.ts` (lines 1635-1679)
|
||||
4. Remove `fix-merge` dispatch unit type from `auto.ts`
|
||||
5. Remove `buildPromptForFixMerge()` from `auto.ts`
|
||||
6. Remove `withMergeHeal()` from `git-self-heal.ts` (lines 99-136)
|
||||
7. Remove `abortAndReset()` from `git-self-heal.ts` (lines 37-84) — or simplify to crash-recovery-only
|
||||
8. Remove `shouldUseWorktreeIsolation()` preference resolution — worktree is the only mode
|
||||
9. Remove `getMergeToMainMode()` — milestone merge is the only mode
|
||||
10. Deprecate `git.isolation: "branch"` and `git.merge_to_main: "slice"` preferences
|
||||
|
||||
**Verification:** `git grep mergeSliceToMilestone` returns zero results. `git grep mergeSliceToMain` returns zero results. `git grep fix-merge` returns zero results (outside of changelog/docs).
|
||||
|
||||
### Phase 4: Simplify `mergeMilestoneToMain()`
|
||||
|
||||
**Goal:** Milestone→main merge is clean and minimal.
|
||||
|
||||
The function becomes:
|
||||
1. Auto-commit any dirty state in worktree
|
||||
2. `process.chdir(originalBasePath)` — back to main repo
|
||||
3. `git checkout main`
|
||||
4. `git merge --squash milestone/<MID>`
|
||||
5. Build commit message with milestone summary + slice manifest
|
||||
6. `git commit`
|
||||
7. Optional: `git push`
|
||||
8. `removeWorktree()` + `git branch -D milestone/<MID>`
|
||||
|
||||
No conflict categorization. No runtime file stripping (runtime files are gitignored, not in the merge). No `.gsd/` special handling.
|
||||
|
||||
If squash-merge conflicts (parallel milestone edge case): stop auto-mode with clear error, user resolves manually or GSD dispatches a one-time resolution session.
|
||||
|
||||
**Verification:** Complete a full milestone in auto-mode. `main` receives one squash commit with all code and planning artifacts.
|
||||
|
||||
### Phase 5: Test Cleanup
|
||||
|
||||
**Goal:** Test suite reflects the simplified architecture.
|
||||
|
||||
1. Delete or rewrite:
|
||||
- `auto-worktree-merge.test.ts` — tests slice→milestone merge (deleted)
|
||||
- `auto-worktree-milestone-merge.test.ts` — rewrite for simplified milestone→main
|
||||
- `worktree-e2e.test.ts` — rewrite for branchless flow
|
||||
- `worktree-integration.test.ts` — rewrite for branchless flow
|
||||
- Merge-related test cases in `git-service.test.ts`
|
||||
|
||||
2. Add new tests:
|
||||
- Branchless worktree lifecycle: create → commit → commit → squash-merge → cleanup
|
||||
- `.gsd/` tracking: planning artifacts tracked, runtime files ignored
|
||||
- Manual worktree: `git worktree add` has correct `.gsd/` state
|
||||
- Crash recovery: dirty state on milestone branch, restart, auto-commit, continue
|
||||
|
||||
3. Remove merge-specific doctor checks or simplify:
|
||||
- `corrupt_merge_state` — keep (still relevant for milestone→main)
|
||||
- `orphaned_auto_worktree` — keep
|
||||
- `stale_milestone_branch` — keep
|
||||
- `tracked_runtime_files` — keep
|
||||
|
||||
**Verification:** `npm run test` passes. No test references `mergeSliceToMilestone`, `mergeSliceToMain`, or `ensureSliceBranch`.
|
||||
|
||||
### Phase 6: Migration + Backwards Compatibility
|
||||
|
||||
**Goal:** Existing projects with slice branches continue to work.
|
||||
|
||||
1. State derivation (`deriveState()`) continues to read `gsd/M001/S01` branch naming for legacy detection
|
||||
2. On first run after upgrade:
|
||||
- Detect existing slice branches
|
||||
- Notify user: "GSD no longer creates slice branches. Existing branches are preserved but new work commits directly to the milestone branch."
|
||||
- No forced migration — legacy branches are read-only context
|
||||
3. Doctor check: `legacy_slice_branches` — informational, not auto-fix
|
||||
4. Update `shouldUseWorktreeIsolation()` preference handling:
|
||||
- `git.isolation: "worktree"` → default behavior (only option)
|
||||
- `git.isolation: "branch"` → warning, treated as worktree
|
||||
- Remove preference UI for isolation mode
|
||||
|
||||
**Verification:** Open a project with existing `gsd/M001/S01` branches. GSD reads state correctly, new work commits on milestone branch without slice branches.
|
||||
|
||||
## Stress Test Results
|
||||
|
||||
Validated by three independent models:
|
||||
|
||||
### Gemini 2.5 Pro — 6 Attack Vectors
|
||||
|
||||
| Attack | Severity | Mitigation |
|
||||
|--------|----------|------------|
|
||||
| Parallel milestone code conflict at squash-merge | Medium | `git rebase main` before squash. Rare in single-user. |
|
||||
| SQLite desync after `git reset --hard` | Low | DB rebuilt from tracked markdown on startup (M001/S02 importers). |
|
||||
| Ghost lock after SIGKILL | Low | Existing heartbeat lock detection handles this. |
|
||||
| Squash merge loses bisect granularity | Low | Commit messages tag slices. Branch preservable if needed. |
|
||||
| Disk space with multiple worktrees | Low | Single active milestone at a time. Immediate cleanup. |
|
||||
| Plan-action atomicity gap (crash between write and commit) | Low | `handleAgentEnd` auto-commits. Sequential model simplifies recovery. |
|
||||
|
||||
### GPT-5.4 (Codex) — Codebase-Informed Analysis
|
||||
|
||||
- Confirmed `smartStage()` force-add already implements tracked-artifact intent
|
||||
- Confirmed `resolveMainWorktreeRoot` (PR #487) contradicts this architecture
|
||||
- Confirmed `.gsd/milestones/` partially tracked on `main` despite `.gitignore`
|
||||
- Verdict: **Model is sound. Removes only accidental complexity.**
|
||||
|
||||
### GPT-5.4 (Codex) — Dissenting Opinion
|
||||
|
||||
Codex agreed on tracked artifacts and worktree-per-milestone, but pushed back on removing slice branches, calling it "a redesign, not a simplification." Specific concerns:
|
||||
|
||||
| Concern | Rebuttal |
|
||||
|---------|----------|
|
||||
| Crash recovery for orphaned slice branches disappears | The failure mode (orphaned branch needing merge) is caused by slice branches. Removing branches removes the failure. Sequential commits on one branch need no orphan recovery. |
|
||||
| Concurrent edits to shared root docs (DECISIONS.md) from two terminals | Standard content conflict at squash-merge time. Not caused by or solved by slice branches. |
|
||||
| Continuous integration via slice→milestone merges | In sequential single-user work, there's nothing to integrate against within the worktree. Pre-flight rebase before squash-merge is more direct. |
|
||||
| Need a replacement slice-boundary primitive | Accepted: conventional commit tags (`feat(M001/S01):`) + optional git tags (`gsd/M001/S01-complete`) serve as boundaries. |
|
||||
|
||||
Codex's analysis confirms the tracked-artifact approach but recommends treating branchless as a deliberate redesign with explicit replacement primitives, not a casual deletion.
|
||||
|
||||
### Edge Case: Two Milestones Touching Same Source Files
|
||||
|
||||
Scenario: M001 and M002 both modify `src/auth.ts`. M001 squash-merges first.
|
||||
|
||||
Resolution: Before M002 squash-merges, rebase onto updated `main`:
|
||||
```
|
||||
cd .gsd/worktrees/M002
|
||||
git fetch origin main
|
||||
git rebase main
|
||||
# Resolve any conflicts (code-only, never .gsd/)
|
||||
# Then squash-merge
|
||||
```
|
||||
|
||||
This is standard git workflow. GSD can automate the rebase step as a pre-merge check.
|
||||
|
||||
### Edge Case: Agent Crash Mid-Commit
|
||||
|
||||
Scenario: Power loss during `git commit` on the milestone branch.
|
||||
|
||||
Resolution: Git's internal journaling protects the object store. On restart:
|
||||
- If commit completed: state is consistent
|
||||
- If commit didn't complete: working directory has uncommitted changes, `handleAgentEnd` auto-commits on next dispatch
|
||||
- No branch to be "stuck between" — single branch means no split-brain state
|
||||
|
||||
### Edge Case: User Edits Main While Worktree Active
|
||||
|
||||
Scenario: User makes manual commits on `main` while M001 worktree is active.
|
||||
|
||||
Resolution: Worktree is on `milestone/M001` branch, independent of `main`. Manual `main` commits don't affect the worktree. At squash-merge time, `git merge --squash` handles the divergence normally. If there's a conflict, it's resolved once.
|
||||
|
||||
## Metrics
|
||||
|
||||
### Before (Current)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Merge/conflict/branch code | 770+ lines across 4 files |
|
||||
| Merge-related test files | 11 files |
|
||||
| Branch types | 4 (main, milestone/*, gsd/*/*, worktree/*) |
|
||||
| Merge strategies | 3 (--no-ff, --squash, conflict resolution) |
|
||||
| Dispatch unit types with merge logic | 2 (complete-slice, fix-merge) |
|
||||
| Isolation modes | 2 (branch, worktree) |
|
||||
| Doctor git checks | 4 |
|
||||
|
||||
### After (Proposed)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Merge/conflict/branch code | ~50 lines (simplified `mergeMilestoneToMain` only) |
|
||||
| Merge-related test files | 3-4 files (rewritten) |
|
||||
| Branch types | 2 (main, milestone/*) |
|
||||
| Merge strategies | 1 (--squash) |
|
||||
| Dispatch unit types with merge logic | 0 |
|
||||
| Isolation modes | 1 (worktree) |
|
||||
| Doctor git checks | 3-4 (simplified) |
|
||||
|
||||
### Net Impact
|
||||
|
||||
- **~720 lines deleted** (net, after simplified replacements)
|
||||
- **~7 test files deleted or consolidated**
|
||||
- **2 branch types eliminated**
|
||||
- **2 merge strategies eliminated**
|
||||
- **1 dispatch unit type eliminated** (fix-merge)
|
||||
- **1 isolation mode eliminated** (branch)
|
||||
- **0 merge conflicts possible within a worktree**
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **M001 (Memory Database):** The SQLite database (`gsd.db`) must remain gitignored. The M001/S02 importer layer rebuilds it from tracked markdown. This PRD's `.gitignore` update explicitly ignores `gsd.db`.
|
||||
|
||||
- **PR #487:** Must be closed. The `resolveMainWorktreeRoot` approach (sharing `.gsd/` across worktrees) contradicts tracked-artifact architecture.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Squash vs `--no-ff` for milestone→main merge?** Squash gives clean history on `main` but loses bisect granularity. `--no-ff` preserves granular commits but clutters `main`. Current proposal: squash (matching existing behavior), with option to preserve milestone branch for debugging.
|
||||
|
||||
2. **Should `worktrees/` move outside `.gsd/`?** Having worktrees inside `.gsd/` creates a nesting-doll pattern (worktree contains `.gsd/` which is inside `.gsd/worktrees/`). Relocating to `.gsd-worktrees/` or `~/.gsd/worktrees/<repo-hash>/` is cleaner but changes the filesystem layout. Recommendation: defer, address separately if it causes issues.
|
||||
|
||||
3. **Pre-flight rebase automation?** Before milestone→main squash-merge, should GSD automatically `git rebase main`? Gemini recommends yes. Risk: rebase can fail with conflicts, adding a code path. Recommendation: implement as a doctor check ("milestone branch is behind main by N commits") with manual resolution, automate later if needed.
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
import {
|
||||
type GenerateContentConfig,
|
||||
type GenerateContentParameters,
|
||||
// Lazy-loaded: Google GenAI SDK (~186ms) is imported on first use, not at startup.
|
||||
// This avoids penalizing users who don't use Google models.
|
||||
import type {
|
||||
GenerateContentConfig,
|
||||
GenerateContentParameters,
|
||||
GoogleGenAI,
|
||||
type ThinkingConfig,
|
||||
ThinkingConfig,
|
||||
} from "@google/genai";
|
||||
|
||||
let _GoogleGenAIClass: typeof GoogleGenAI | undefined;
|
||||
async function getGoogleGenAIClass(): Promise<typeof GoogleGenAI> {
|
||||
if (!_GoogleGenAIClass) {
|
||||
const mod = await import("@google/genai");
|
||||
_GoogleGenAIClass = mod.GoogleGenAI;
|
||||
}
|
||||
return _GoogleGenAIClass;
|
||||
}
|
||||
import { getEnvApiKey } from "../env-api-keys.js";
|
||||
import { calculateCost } from "../models.js";
|
||||
import type {
|
||||
|
|
@ -73,7 +84,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>
|
|||
|
||||
try {
|
||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
||||
const client = createClient(model, apiKey, options?.headers);
|
||||
const client = await createClient(model, apiKey, options?.headers);
|
||||
let params = buildParams(model, context, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
|
|
@ -308,11 +319,11 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt
|
|||
} satisfies GoogleOptions);
|
||||
};
|
||||
|
||||
function createClient(
|
||||
async function createClient(
|
||||
model: Model<"google-generative-ai">,
|
||||
apiKey?: string,
|
||||
optionsHeaders?: Record<string, string>,
|
||||
): GoogleGenAI {
|
||||
): Promise<GoogleGenAI> {
|
||||
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
|
||||
if (model.baseUrl) {
|
||||
httpOptions.baseUrl = model.baseUrl;
|
||||
|
|
@ -322,7 +333,8 @@ function createClient(
|
|||
httpOptions.headers = { ...model.headers, ...optionsHeaders };
|
||||
}
|
||||
|
||||
return new GoogleGenAI({
|
||||
const GoogleGenAIClass = await getGoogleGenAIClass();
|
||||
return new GoogleGenAIClass({
|
||||
apiKey,
|
||||
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Mistral } from "@mistralai/mistralai";
|
||||
// Lazy-loaded: Mistral SDK (~369ms) is imported on first use, not at startup.
|
||||
// This avoids penalizing users who don't use Mistral models.
|
||||
import type { Mistral } from "@mistralai/mistralai";
|
||||
import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js";
|
||||
import type {
|
||||
ChatCompletionStreamRequest,
|
||||
|
|
@ -7,6 +9,15 @@ import type {
|
|||
ContentChunk,
|
||||
FunctionTool,
|
||||
} from "@mistralai/mistralai/models/components/index.js";
|
||||
|
||||
let _MistralClass: typeof Mistral | undefined;
|
||||
async function getMistralClass(): Promise<typeof Mistral> {
|
||||
if (!_MistralClass) {
|
||||
const mod = await import("@mistralai/mistralai");
|
||||
_MistralClass = mod.Mistral;
|
||||
}
|
||||
return _MistralClass;
|
||||
}
|
||||
import { getEnvApiKey } from "../env-api-keys.js";
|
||||
import { calculateCost } from "../models.js";
|
||||
import type {
|
||||
|
|
@ -61,7 +72,8 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
|
|||
}
|
||||
|
||||
// Intentionally per-request: avoids shared SDK mutable state across concurrent consumers.
|
||||
const mistral = new Mistral({
|
||||
const MistralSDK = await getMistralClass();
|
||||
const mistral = new MistralSDK({
|
||||
apiKey,
|
||||
serverURL: model.baseUrl,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -369,22 +369,26 @@ export async function loadExtensionFromFactory(
|
|||
|
||||
/**
|
||||
* Load extensions from paths.
|
||||
*
|
||||
* Extensions are loaded in parallel to reduce wall-clock time (~30-50% faster
|
||||
* than sequential loading for I/O-bound jiti compilation).
|
||||
*/
|
||||
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||
const extensions: Extension[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
const runtime = createExtensionRuntime();
|
||||
|
||||
for (const extPath of paths) {
|
||||
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
|
||||
const results = await Promise.all(
|
||||
paths.map((extPath) => loadExtension(extPath, cwd, resolvedEventBus, runtime)),
|
||||
);
|
||||
|
||||
const extensions: Extension[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const { extension, error } = results[i];
|
||||
if (error) {
|
||||
errors.push({ path: extPath, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
errors.push({ path: paths[i], error });
|
||||
} else if (extension) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,52 @@
|
|||
#!/usr/bin/env node
|
||||
// GSD Startup Loader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, resolve, join, delimiter } from 'path'
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs'
|
||||
|
||||
// Fast-path: handle --version/-v and --help/-h before importing any heavy
|
||||
// dependencies. This avoids loading the entire pi-coding-agent barrel import
|
||||
// (~1s) just to print a version string.
|
||||
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const args = process.argv.slice(2)
|
||||
const firstArg = args[0]
|
||||
|
||||
if (firstArg === '--version' || firstArg === '-v') {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
|
||||
process.stdout.write((pkg.version || '0.0.0') + '\n')
|
||||
} catch {
|
||||
process.stdout.write('0.0.0\n')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (firstArg === '--help' || firstArg === '-h') {
|
||||
let version = '0.0.0'
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
|
||||
version = pkg.version || version
|
||||
} catch { /* ignore */ }
|
||||
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
|
||||
process.stdout.write('Usage: gsd [options] [message...]\n\n')
|
||||
process.stdout.write('Options:\n')
|
||||
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
|
||||
process.stdout.write(' --print, -p Single-shot print mode\n')
|
||||
process.stdout.write(' --continue, -c Resume the most recent session\n')
|
||||
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> Load additional extension\n')
|
||||
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
|
||||
process.stdout.write(' --list-models [search] List available models and exit\n')
|
||||
process.stdout.write(' --version, -v Print version and exit\n')
|
||||
process.stdout.write(' --help, -h Print this help and exit\n')
|
||||
process.stdout.write('\nSubcommands:\n')
|
||||
process.stdout.write(' config Re-run the setup wizard\n')
|
||||
process.stdout.write(' update Update GSD to the latest version\n')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
import { agentDir, appRoot } from './app-paths.js'
|
||||
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
|
||||
import { renderLogo } from './logo.js'
|
||||
|
|
@ -46,7 +91,6 @@ process.env.GSD_CODING_AGENT_DIR = agentDir
|
|||
// Without this, extensions (e.g. browser-tools) can't resolve dependencies like
|
||||
// `playwright` because jiti resolves modules from pi-coding-agent's location, not gsd's.
|
||||
// Prepending gsd's node_modules to NODE_PATH fixes this for all extensions.
|
||||
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const gsdNodeModules = join(gsdRoot, 'node_modules')
|
||||
process.env.NODE_PATH = [gsdNodeModules, process.env.NODE_PATH]
|
||||
.filter(Boolean)
|
||||
|
|
@ -72,9 +116,8 @@ process.env.GSD_BIN_PATH = process.argv[1]
|
|||
// GSD_WORKFLOW_PATH — absolute path to bundled GSD-WORKFLOW.md, used by patched gsd extension
|
||||
// when dispatching workflow prompts. Prefers dist/resources/ (stable, set at build time)
|
||||
// over src/resources/ (live working tree) — see resource-loader.ts for rationale.
|
||||
const loaderPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const distRes = join(loaderPackageRoot, 'dist', 'resources')
|
||||
const srcRes = join(loaderPackageRoot, 'src', 'resources')
|
||||
const distRes = join(gsdRoot, 'dist', 'resources')
|
||||
const srcRes = join(gsdRoot, 'src', 'resources')
|
||||
const resourcesDir = existsSync(distRes) ? distRes : srcRes
|
||||
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
|
||||
|
||||
|
|
@ -116,8 +159,11 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discove
|
|||
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
|
||||
// pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we
|
||||
// must set it here before any SDK clients are created.
|
||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent())
|
||||
// Lazy-load undici (~200ms) only when proxy env vars are actually set.
|
||||
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) {
|
||||
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici')
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent())
|
||||
}
|
||||
|
||||
// Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
|
||||
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.
|
||||
|
|
|
|||
|
|
@ -126,21 +126,29 @@ export function getNewerManagedResourceVersion(agentDir: string, currentVersion:
|
|||
/**
|
||||
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
|
||||
*
|
||||
* - extensions/ → ~/.gsd/agent/extensions/ (always overwrite — ensures updates ship on next launch)
|
||||
* - agents/ → ~/.gsd/agent/agents/ (always overwrite)
|
||||
* - skills/ → ~/.gsd/agent/skills/ (always overwrite)
|
||||
* - extensions/ → ~/.gsd/agent/extensions/ (overwrite when version changes)
|
||||
* - agents/ → ~/.gsd/agent/agents/ (overwrite when version changes)
|
||||
* - skills/ → ~/.gsd/agent/skills/ (overwrite when version changes)
|
||||
* - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
|
||||
*
|
||||
* Always-overwrite ensures `npm update -g @glittercowboy/gsd` takes effect immediately.
|
||||
* User customizations should go in ~/.gsd/agent/extensions/ subdirs with unique names,
|
||||
* not by editing the gsd-managed files.
|
||||
* Skips the copy when the managed-resources.json version matches the current
|
||||
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
|
||||
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
|
||||
* copy runs once to land the new resources.
|
||||
*
|
||||
* Inspectable: `ls ~/.gsd/agent/extensions/`
|
||||
*/
|
||||
export function initResources(agentDir: string): void {
|
||||
mkdirSync(agentDir, { recursive: true })
|
||||
|
||||
// Sync extensions — always overwrite so updates land on next launch
|
||||
// Skip resource sync when versions match — saves ~128ms of cpSync per launch
|
||||
const currentVersion = getBundledGsdVersion()
|
||||
const managedVersion = readManagedResourceVersion(agentDir)
|
||||
if (managedVersion && managedVersion === currentVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync extensions — overwrite so updates land on next launch
|
||||
const destExtensions = join(agentDir, 'extensions')
|
||||
cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true })
|
||||
|
||||
|
|
@ -151,7 +159,7 @@ export function initResources(agentDir: string): void {
|
|||
cpSync(srcAgents, destAgents, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Sync skills — always overwrite so updates land on next launch
|
||||
// Sync skills — overwrite so updates land on next launch
|
||||
const destSkills = join(agentDir, 'skills')
|
||||
const srcSkills = join(resourcesDir, 'skills')
|
||||
if (existsSync(srcSkills)) {
|
||||
|
|
|
|||
|
|
@ -14,20 +14,9 @@ import {
|
|||
removeWorktree,
|
||||
worktreePath,
|
||||
} from "./worktree-manager.js";
|
||||
import {
|
||||
detectWorktreeName,
|
||||
getSliceBranchName,
|
||||
} from "./worktree.js";
|
||||
import {
|
||||
MergeConflictError,
|
||||
inferCommitType,
|
||||
} from "./git-service.js";
|
||||
import type { MergeSliceResult } from "./git-service.js";
|
||||
import { recoverCheckout, withMergeHeal } from "./git-self-heal.js";
|
||||
import {
|
||||
nativeBranchExists,
|
||||
nativeCommitCountBetween,
|
||||
} from "./native-git-bridge.js";
|
||||
import { parseRoadmap } from "./files.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
|
||||
|
|
@ -36,48 +25,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|||
/** Original project root before chdir into auto-worktree. */
|
||||
let originalBase: string | null = null;
|
||||
|
||||
// ─── Isolation Resolver ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine whether auto-mode should use worktree isolation.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Explicit git.isolation preference -> return (isolation === "worktree")
|
||||
* 2. Legacy detection: if gsd branches exist -> return false (branch mode)
|
||||
* 3. Default: return true (worktree mode for new projects)
|
||||
*/
|
||||
export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean {
|
||||
const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git;
|
||||
if (prefs?.isolation) {
|
||||
return prefs.isolation === "worktree";
|
||||
}
|
||||
|
||||
// Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
|
||||
try {
|
||||
// Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
|
||||
// causing the pattern to match literally instead of as a glob.
|
||||
const output = execSync("git branch --list gsd/*/*", {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
if (output) return false; // Legacy branch-per-slice project
|
||||
} catch {
|
||||
// If git command fails, default to worktree
|
||||
}
|
||||
|
||||
return true; // New project default
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the merge_to_main preference value.
|
||||
* Returns "milestone" (default) or "slice".
|
||||
*/
|
||||
export function getMergeToMainMode(): "milestone" | "slice" {
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
||||
return prefs?.merge_to_main ?? "milestone";
|
||||
}
|
||||
|
||||
// ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
|
||||
|
||||
function resolveGitHeadPath(dir: string): string | null {
|
||||
|
|
@ -238,117 +185,6 @@ export function getAutoWorktreeOriginalBase(): string | null {
|
|||
return originalBase;
|
||||
}
|
||||
|
||||
// ─── Merge Slice -> Milestone ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge a completed slice branch into the milestone branch via `--no-ff`.
|
||||
*
|
||||
* Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
|
||||
* git), so there are zero `.gsd/` conflict resolution concerns. No runtime
|
||||
* exclusion untracking, no `--theirs` checkout, no snapshot creation.
|
||||
*
|
||||
* On conflict: throws MergeConflictError with conflicted file list.
|
||||
* On success: deletes the slice branch and returns MergeSliceResult.
|
||||
*/
|
||||
export function mergeSliceToMilestone(
|
||||
basePath: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
sliceTitle: string,
|
||||
): MergeSliceResult {
|
||||
if (!isInAutoWorktree(basePath)) {
|
||||
throw new Error("mergeSliceToMilestone called outside auto-worktree");
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const milestoneBranch = autoWorktreeBranch(milestoneId);
|
||||
const worktreeName = detectWorktreeName(cwd);
|
||||
const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
|
||||
|
||||
// Verify slice branch exists
|
||||
if (!nativeBranchExists(cwd, sliceBranch)) {
|
||||
throw new Error(`Slice branch "${sliceBranch}" does not exist`);
|
||||
}
|
||||
|
||||
// Verify slice has commits ahead of milestone branch
|
||||
const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
|
||||
if (commitCount === 0) {
|
||||
throw new Error(
|
||||
`Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Checkout milestone branch (with self-healing reset)
|
||||
recoverCheckout(cwd, milestoneBranch);
|
||||
|
||||
// Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
|
||||
const commitType = inferCommitType(sliceTitle);
|
||||
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
||||
|
||||
let message = subject;
|
||||
try {
|
||||
const logOutput = execSync(
|
||||
`git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
|
||||
{ cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
||||
).trim();
|
||||
|
||||
if (logOutput) {
|
||||
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`
|
||||
: "";
|
||||
message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to subject-only message
|
||||
}
|
||||
|
||||
// Merge --no-ff (with self-healing retry for transient failures)
|
||||
try {
|
||||
withMergeHeal(cwd, () => {
|
||||
execFileSync("git", ["merge", "--no-ff", "-m", message, sliceBranch], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof MergeConflictError) {
|
||||
// Re-throw with correct branch context
|
||||
throw new MergeConflictError(
|
||||
err.conflictedFiles,
|
||||
err.strategy,
|
||||
sliceBranch,
|
||||
milestoneBranch,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Delete slice branch
|
||||
let deletedBranch = false;
|
||||
try {
|
||||
execSync(`git branch -d ${sliceBranch}`, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
deletedBranch = true;
|
||||
} catch {
|
||||
// Branch deletion is best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
branch: sliceBranch,
|
||||
mergedCommitMessage: message,
|
||||
deletedBranch,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Merge Milestone -> Main ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -416,8 +252,12 @@ export function mergeMilestoneToMain(
|
|||
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
|
||||
const mainBranch = prefs.main_branch || "main";
|
||||
|
||||
// 5. Checkout main (with self-healing reset)
|
||||
recoverCheckout(originalBasePath_, mainBranch);
|
||||
// 5. Checkout main
|
||||
execSync(`git checkout ${mainBranch}`, {
|
||||
cwd: originalBasePath_,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
// 6. Build rich commit message
|
||||
const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
|
||||
|
|
@ -429,26 +269,29 @@ export function mergeMilestoneToMain(
|
|||
}
|
||||
const commitMessage = subject + body;
|
||||
|
||||
// 7. Squash merge (with self-healing retry for transient failures)
|
||||
// 7. Squash merge
|
||||
try {
|
||||
withMergeHeal(originalBasePath_, () => {
|
||||
execSync(`git merge --squash ${milestoneBranch}`, {
|
||||
cwd: originalBasePath_,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
execSync(`git merge --squash ${milestoneBranch}`, {
|
||||
cwd: originalBasePath_,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof MergeConflictError) {
|
||||
// Re-throw with correct branch context
|
||||
throw new MergeConflictError(
|
||||
err.conflictedFiles,
|
||||
err.strategy,
|
||||
milestoneBranch,
|
||||
mainBranch,
|
||||
);
|
||||
} catch (mergeErr) {
|
||||
// Check for real conflicts
|
||||
try {
|
||||
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: originalBasePath_,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
if (conflictOutput) {
|
||||
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
|
||||
throw new MergeConflictError(conflictedFiles, "squash", milestoneBranch, mainBranch);
|
||||
}
|
||||
} catch (diffErr) {
|
||||
if (diffErr instanceof MergeConflictError) throw diffErr;
|
||||
}
|
||||
// Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
|
||||
// No conflicts detected — possibly "already up to date", fall through to commit
|
||||
}
|
||||
|
||||
// 8. Commit (handle nothing-to-commit gracefully)
|
||||
|
|
|
|||
|
|
@ -74,17 +74,13 @@ import { execSync, execFileSync } from "node:child_process";
|
|||
import {
|
||||
autoCommitCurrentBranch,
|
||||
captureIntegrationBranch,
|
||||
ensureSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
MergeConflictError,
|
||||
parseSliceBranch,
|
||||
setActiveMilestoneId,
|
||||
switchToMain,
|
||||
mergeSliceToMain,
|
||||
} from "./worktree.js";
|
||||
import { GitServiceImpl, runGit } from "./git-service.js";
|
||||
import { nativeCommitCountBetween } from "./native-git-bridge.js";
|
||||
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
|
||||
import { formatGitError } from "./git-self-heal.js";
|
||||
import {
|
||||
|
|
@ -94,10 +90,7 @@ import {
|
|||
isInAutoWorktree,
|
||||
getAutoWorktreePath,
|
||||
getAutoWorktreeOriginalBase,
|
||||
mergeSliceToMilestone,
|
||||
mergeMilestoneToMain,
|
||||
shouldUseWorktreeIsolation,
|
||||
getMergeToMainMode,
|
||||
} from "./auto-worktree.js";
|
||||
import type { GitPreferences } from "./git-service.js";
|
||||
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
||||
|
|
@ -485,119 +478,6 @@ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Prom
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup check: scan for orphaned completed slice branches and merge them.
|
||||
*
|
||||
* An orphaned completed slice branch is a `gsd/MID/SID` branch where the slice
|
||||
* is marked done in the roadmap (on that branch) but hasn't been squash-merged
|
||||
* to main yet. This happens when `complete-slice` succeeds and commits on the
|
||||
* slice branch, but the subsequent merge to main is interrupted (crash, timeout,
|
||||
* Ctrl+C, merge conflict that wasn't auto-resolved).
|
||||
*
|
||||
* Without this check, GSD gets stuck in an infinite loop: `deriveState()` on
|
||||
* main sees no slice artifacts → wants research-slice → idempotency key removed
|
||||
* (artifact not on main) → ensurePreconditions switches branch → merge guard
|
||||
* merges → re-derives → repeats.
|
||||
*/
|
||||
async function mergeOrphanedSliceBranches(
|
||||
base: string,
|
||||
ctx: Pick<ExtensionContext, "ui">,
|
||||
): Promise<void> {
|
||||
// List all local gsd/<MID>/<SID> branches (non-worktree pattern).
|
||||
// Use execFileSync (not runGit/execSync) to avoid shell glob-expanding gsd/*/*
|
||||
// and to avoid shell syntax errors from %(refname:short) on /bin/sh.
|
||||
let branchListRaw = "";
|
||||
try {
|
||||
branchListRaw = execFileSync(
|
||||
"git",
|
||||
["branch", "--list", "gsd/*/*", "--format=%(refname:short)"],
|
||||
{ cwd: base, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
||||
).trim();
|
||||
} catch {
|
||||
return; // no slice branches or git unavailable
|
||||
}
|
||||
if (!branchListRaw) return;
|
||||
|
||||
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
for (const branch of branches) {
|
||||
const parsed = parseSliceBranch(branch);
|
||||
// Skip worktree-namespaced branches — those are managed by the worktree
|
||||
// manager and should not be merged by the main-tree auto-mode.
|
||||
if (!parsed || parsed.worktreeName) continue;
|
||||
|
||||
const { milestoneId, sliceId } = parsed;
|
||||
|
||||
// Ensure Git operations for this branch use the correct milestone context.
|
||||
setActiveMilestoneId(base, milestoneId);
|
||||
|
||||
// Skip if already merged (no commits ahead of main)
|
||||
const mainBranch = getMainBranch(base);
|
||||
const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
|
||||
if (aheadCount === 0) continue;
|
||||
|
||||
// Read the roadmap from the slice branch to check if the slice is done.
|
||||
// relMilestoneFile resolves the actual directory name on disk (handles
|
||||
// milestone directories with title suffixes like "M007 Payment System").
|
||||
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
|
||||
let roadmapContent: string | undefined;
|
||||
try {
|
||||
roadmapContent = execFileSync(
|
||||
"git",
|
||||
["-C", base, "show", `${branch}:${roadmapRelPath}`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
} catch {
|
||||
roadmapContent = undefined;
|
||||
}
|
||||
if (!roadmapContent) continue;
|
||||
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
const sliceEntry = roadmap.slices.find(s => s.id === sliceId);
|
||||
if (!sliceEntry?.done) continue;
|
||||
|
||||
// Orphaned completed branch detected — merge it to main now.
|
||||
ctx.ui.notify(
|
||||
`Orphaned completed slice branch detected: ${branch}. Merging to main before dispatch...`,
|
||||
"info",
|
||||
);
|
||||
try {
|
||||
let mergeResult;
|
||||
if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") {
|
||||
mergeResult = mergeSliceToMilestone(
|
||||
base, milestoneId, sliceId, sliceEntry.title || sliceId,
|
||||
);
|
||||
} else {
|
||||
switchToMain(base);
|
||||
mergeResult = mergeSliceToMain(
|
||||
base, milestoneId, sliceId, sliceEntry.title || sliceId,
|
||||
);
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof MergeConflictError) {
|
||||
// Abort and reset the incomplete merge so auto-mode can still start cleanly.
|
||||
runGit(base, ["merge", "--abort"], { allowFailure: true });
|
||||
runGit(base, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
||||
ctx.ui.notify(
|
||||
`Orphaned branch ${branch} has merge conflicts — resolve manually and restart.\nConflicts in: ${error.conflictedFiles.join(", ")}`,
|
||||
"error",
|
||||
);
|
||||
// Stop processing further branches after a conflict to avoid
|
||||
// leaving the repo in a partially-merged state.
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Failed to merge orphaned branch ${branch}: ${message}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAuto(
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
|
|
@ -625,7 +505,7 @@ export async function startAuto(
|
|||
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
|
||||
|
||||
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
|
||||
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) {
|
||||
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath)) {
|
||||
try {
|
||||
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
|
||||
if (existingWtPath) {
|
||||
|
|
@ -673,7 +553,7 @@ export async function startAuto(
|
|||
return;
|
||||
}
|
||||
|
||||
// Ensure git repo exists — GSD needs it for branch-per-slice
|
||||
// Ensure git repo exists — GSD needs it for worktree isolation
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
|
||||
} catch {
|
||||
|
|
@ -789,7 +669,7 @@ export async function startAuto(
|
|||
// ── Auto-worktree: create or enter worktree for the active milestone ──
|
||||
// Store the original project root before any chdir so we can restore on stop.
|
||||
originalBasePath = base;
|
||||
if (currentMilestoneId && shouldUseWorktreeIsolation(base)) {
|
||||
if (currentMilestoneId) {
|
||||
try {
|
||||
const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
|
||||
if (existingWtPath) {
|
||||
|
|
@ -855,12 +735,6 @@ export async function startAuto(
|
|||
);
|
||||
}
|
||||
|
||||
// Merge any orphaned completed slice branches before dispatching.
|
||||
// Orphaned branches arise when complete-slice commits on the slice branch
|
||||
// but the merge to main is interrupted (crash, timeout, Ctrl+C).
|
||||
// Without this check, GSD enters an infinite "Skipping ... Advancing" loop.
|
||||
await mergeOrphanedSliceBranches(base, ctx);
|
||||
|
||||
// Self-heal: clear stale runtime records where artifacts already exist
|
||||
await selfHealRuntimeRecords(base, ctx);
|
||||
|
||||
|
|
@ -939,17 +813,37 @@ export async function handleAgentEnd(
|
|||
// produced its expected artifact. If so, persist the completion key now so the
|
||||
// idempotency check at the top of dispatchNextUnit() skips it — even if
|
||||
// deriveState() still returns this unit as active (e.g. branch mismatch).
|
||||
try {
|
||||
if (verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath)) {
|
||||
const completionKey = `${currentUnit.type}/${currentUnit.id}`;
|
||||
if (!completedKeySet.has(completionKey)) {
|
||||
persistCompletedKey(basePath, completionKey);
|
||||
completedKeySet.add(completionKey);
|
||||
//
|
||||
// IMPORTANT: For non-hook units, defer persistence until after the hook check.
|
||||
// If a post-unit hook requests a retry, we need to remove the completion key
|
||||
// so dispatchNextUnit re-dispatches the trigger unit.
|
||||
let triggerArtifactVerified = false;
|
||||
if (!currentUnit.type.startsWith("hook/")) {
|
||||
try {
|
||||
triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
||||
if (triggerArtifactVerified) {
|
||||
const completionKey = `${currentUnit.type}/${currentUnit.id}`;
|
||||
if (!completedKeySet.has(completionKey)) {
|
||||
persistCompletedKey(basePath, completionKey);
|
||||
completedKeySet.add(completionKey);
|
||||
}
|
||||
invalidateStateCache();
|
||||
}
|
||||
invalidateStateCache();
|
||||
} catch {
|
||||
// Non-fatal — worst case we fall through to normal dispatch which has its own checks
|
||||
}
|
||||
} else {
|
||||
// Hook unit completed — finalize its runtime record and clear it
|
||||
try {
|
||||
writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
|
||||
phase: "finalized",
|
||||
progressCount: 1,
|
||||
lastProgressKind: "hook-completed",
|
||||
});
|
||||
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — worst case we fall through to normal dispatch which has its own checks
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1005,6 +899,31 @@ export async function handleAgentEnd(
|
|||
writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
|
||||
// Persist hook state so cycle counts survive crashes
|
||||
persistHookState(basePath);
|
||||
|
||||
// Start supervision timers for hook units — hooks can get stuck just
|
||||
// like normal units, and without a watchdog auto-mode would hang forever.
|
||||
clearUnitTimeout();
|
||||
const supervisor = resolveAutoSupervisorConfig();
|
||||
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
||||
unitTimeoutHandle = setTimeout(async () => {
|
||||
unitTimeoutHandle = null;
|
||||
if (!active) return;
|
||||
if (currentUnit) {
|
||||
writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, {
|
||||
phase: "timeout",
|
||||
timeoutAt: Date.now(),
|
||||
});
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`,
|
||||
"warning",
|
||||
);
|
||||
resetHookState();
|
||||
await pauseAuto(ctx, pi);
|
||||
}, hookHardTimeoutMs);
|
||||
|
||||
// Guard against race with timeout/pause before sending
|
||||
if (!active) return;
|
||||
pi.sendMessage(
|
||||
{ customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
|
||||
{ triggerTurn: true },
|
||||
|
|
@ -1016,6 +935,11 @@ export async function handleAgentEnd(
|
|||
if (isRetryPending()) {
|
||||
const trigger = consumeRetryTrigger();
|
||||
if (trigger) {
|
||||
// Remove the trigger unit's completion key so dispatchNextUnit
|
||||
// will re-dispatch it instead of skipping it as already-complete.
|
||||
const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
|
||||
completedKeySet.delete(triggerKey);
|
||||
removePersistedKey(basePath, triggerKey);
|
||||
ctx.ui.notify(
|
||||
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
|
||||
"info",
|
||||
|
|
@ -1180,7 +1104,6 @@ function unitVerb(unitType: string): string {
|
|||
case "replan-slice": return "replanning";
|
||||
case "reassess-roadmap": return "reassessing";
|
||||
case "run-uat": return "running UAT";
|
||||
case "fix-merge": return "resolving conflicts";
|
||||
default: return unitType;
|
||||
}
|
||||
}
|
||||
|
|
@ -1197,7 +1120,6 @@ function unitPhaseLabel(unitType: string): string {
|
|||
case "replan-slice": return "REPLAN";
|
||||
case "reassess-roadmap": return "REASSESS";
|
||||
case "run-uat": return "UAT";
|
||||
case "fix-merge": return "MERGE-FIX";
|
||||
default: return unitType.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -1221,7 +1143,6 @@ function peekNext(unitType: string, state: GSDState): string {
|
|||
case "replan-slice": return `re-execute ${sid}`;
|
||||
case "reassess-roadmap": return "advance to next slice";
|
||||
case "run-uat": return "reassess roadmap";
|
||||
case "fix-merge": return "continue merge";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1551,9 +1472,9 @@ async function dispatchNextUnit(
|
|||
return;
|
||||
}
|
||||
|
||||
// ── Mid-merge safety check: detect leftover state from a prior fix-merge session ──
|
||||
// If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously.
|
||||
// Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted → reset + stop).
|
||||
// ── Mid-merge safety check: detect leftover merge state from a prior session ──
|
||||
// If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
|
||||
// If resolved: finalize the commit. If still conflicted: abort and reset.
|
||||
{
|
||||
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
|
||||
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
|
||||
|
|
@ -1562,178 +1483,37 @@ async function dispatchNextUnit(
|
|||
if (hasMergeHead || hasSquashMsg) {
|
||||
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
||||
if (!unmerged || !unmerged.trim()) {
|
||||
// fix-merge succeeded — finalize the commit if needed (squash or normal merge)
|
||||
if (hasMergeHead || hasSquashMsg) {
|
||||
try {
|
||||
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
|
||||
const mode = hasMergeHead ? "merge" : "squash commit";
|
||||
ctx.ui.notify(`Fix-merge session succeeded — finalized ${mode}.`, "info");
|
||||
} catch {
|
||||
// Commit may already exist; non-fatal
|
||||
}
|
||||
// All conflicts resolved — finalize the merge/squash commit
|
||||
try {
|
||||
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
|
||||
const mode = hasMergeHead ? "merge" : "squash commit";
|
||||
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
|
||||
} catch {
|
||||
// Commit may already exist; non-fatal
|
||||
}
|
||||
// Re-derive state from the now-merged working tree
|
||||
invalidateStateCache();
|
||||
clearParseCache();
|
||||
clearPathCache();
|
||||
state = await deriveState(basePath);
|
||||
mid = state.activeMilestone?.id;
|
||||
midTitle = state.activeMilestone?.title;
|
||||
} else {
|
||||
// fix-merge failed — still has unresolved conflicts, abort merge/squash, reset and stop
|
||||
// Still conflicted — abort and reset
|
||||
if (hasMergeHead) {
|
||||
// Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared
|
||||
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
|
||||
} else if (hasSquashMsg) {
|
||||
// Squash-in-progress without MERGE_HEAD: remove stale squash metadata
|
||||
try {
|
||||
unlinkSync(squashMsgPath);
|
||||
} catch {
|
||||
// Best-effort cleanup; ignore failures
|
||||
}
|
||||
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
|
||||
}
|
||||
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
||||
ctx.ui.notify(
|
||||
"Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.",
|
||||
"error",
|
||||
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
|
||||
"warning",
|
||||
);
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
invalidateStateCache();
|
||||
clearParseCache();
|
||||
clearPathCache();
|
||||
state = await deriveState(basePath);
|
||||
mid = state.activeMilestone?.id;
|
||||
midTitle = state.activeMilestone?.title;
|
||||
}
|
||||
}
|
||||
|
||||
// ── General merge guard: merge completed slice branches before advancing ──
|
||||
// If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
|
||||
// merge to main before dispatching the next unit. This handles:
|
||||
// - Normal complete-slice → merge → reassess flow
|
||||
// - LLM writes summary during task execution, skipping complete-slice
|
||||
// - Doctor post-hook marks everything done, skipping complete-slice
|
||||
// - complete-milestone runs on a slice branch (last slice bypass)
|
||||
{
|
||||
const currentBranch = getCurrentBranch(basePath);
|
||||
const parsedBranch = parseSliceBranch(currentBranch);
|
||||
if (parsedBranch) {
|
||||
const branchMid = parsedBranch.milestoneId;
|
||||
const branchSid = parsedBranch.sliceId;
|
||||
// Check if this slice is marked done in the roadmap
|
||||
const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
|
||||
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
||||
if (roadmapContent) {
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
|
||||
if (sliceEntry?.done) {
|
||||
try {
|
||||
const sliceTitleForMerge = sliceEntry.title || branchSid;
|
||||
let mergeResult;
|
||||
if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") {
|
||||
mergeResult = mergeSliceToMilestone(
|
||||
basePath, branchMid, branchSid, sliceTitleForMerge,
|
||||
);
|
||||
} else {
|
||||
switchToMain(basePath);
|
||||
mergeResult = mergeSliceToMain(
|
||||
basePath, branchMid, branchSid, sliceTitleForMerge,
|
||||
);
|
||||
}
|
||||
const targetBranch = getMainBranch(basePath);
|
||||
ctx.ui.notify(
|
||||
`Merged ${mergeResult.branch} → ${targetBranch}.`,
|
||||
"info",
|
||||
);
|
||||
// Re-derive state from main so downstream logic sees merged state
|
||||
invalidateStateCache();
|
||||
clearParseCache();
|
||||
clearPathCache();
|
||||
state = await deriveState(basePath);
|
||||
mid = state.activeMilestone?.id;
|
||||
midTitle = state.activeMilestone?.title;
|
||||
} catch (error) {
|
||||
// MergeConflictError: dispatch a fix-merge session to resolve conflicts
|
||||
if (error instanceof MergeConflictError) {
|
||||
const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
|
||||
const fixMergePrompt = buildFixMergePrompt(error);
|
||||
ctx.ui.notify(
|
||||
`Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
|
||||
"warning",
|
||||
);
|
||||
|
||||
// Close out the previously active unit before overwriting currentUnit.
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(
|
||||
ctx,
|
||||
currentUnit.type,
|
||||
currentUnit.id,
|
||||
currentUnit.startedAt,
|
||||
modelId,
|
||||
);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
|
||||
// Dispatch fix-merge as the next unit (early-dispatch-and-return)
|
||||
const fixMergeUnitType = "fix-merge";
|
||||
currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
|
||||
writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
timeoutAt: null,
|
||||
lastProgressAt: currentUnit.startedAt,
|
||||
progressCount: 0,
|
||||
lastProgressKind: "dispatch",
|
||||
});
|
||||
updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
|
||||
const result = await cmdCtx!.newSession();
|
||||
if (result.cancelled) {
|
||||
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
||||
await stopAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||
writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
|
||||
pi.sendMessage(
|
||||
{ customType: "gsd-auto", content: fixMergePrompt, display: verbose },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-conflict errors: reset and stop
|
||||
const message = formatGitError(error instanceof Error ? error : String(error));
|
||||
try {
|
||||
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
|
||||
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
|
||||
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
||||
ctx.ui.notify(
|
||||
`Cleaned up conflicted merge state after failed squash-merge.`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
} catch { /* best-effort cleanup */ }
|
||||
|
||||
ctx.ui.notify(
|
||||
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
||||
"error",
|
||||
);
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After merge, mid/midTitle may have been re-derived and could be undefined
|
||||
// After merge guard removal (branchless architecture), mid/midTitle could be undefined
|
||||
if (!mid || !midTitle) {
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
|
|
@ -1763,7 +1543,7 @@ async function dispatchNextUnit(
|
|||
} catch { /* non-fatal */ }
|
||||
|
||||
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
|
||||
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") {
|
||||
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
|
||||
try {
|
||||
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
|
||||
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
||||
|
|
@ -1852,7 +1632,7 @@ async function dispatchNextUnit(
|
|||
|
||||
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
|
||||
// If the current phase is "summarizing", complete-slice is responsible for
|
||||
// mergeSliceToMain. Reassessment must wait until the merge is done.
|
||||
// complete-slice must run before reassessment.
|
||||
if (state.phase === "summarizing") {
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
|
|
@ -2207,12 +1987,19 @@ async function dispatchNextUnit(
|
|||
// Only mark the previous unit as completed if:
|
||||
// 1. We're not about to re-dispatch the same unit (retry scenario)
|
||||
// 2. The expected artifact actually exists on disk
|
||||
// For hook units, skip artifact verification — hooks don't produce standard
|
||||
// artifacts and their runtime records were already finalized in handleAgentEnd.
|
||||
const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
|
||||
const incomingKey = `${unitType}/${unitId}`;
|
||||
const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
||||
const isHookUnit = currentUnit.type.startsWith("hook/");
|
||||
const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
||||
if (closeoutKey !== incomingKey && artifactVerified) {
|
||||
persistCompletedKey(basePath, closeoutKey);
|
||||
completedKeySet.add(closeoutKey);
|
||||
if (!isHookUnit) {
|
||||
// Only persist completion keys for real units — hook keys are
|
||||
// ephemeral and should not pollute the idempotency set.
|
||||
persistCompletedKey(basePath, closeoutKey);
|
||||
completedKeySet.add(closeoutKey);
|
||||
}
|
||||
|
||||
completedUnits.push({
|
||||
type: currentUnit.type,
|
||||
|
|
@ -3136,45 +2923,6 @@ async function buildReassessRoadmapPrompt(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt for the fix-merge LLM session that resolves merge conflicts.
|
||||
*/
|
||||
function buildFixMergePrompt(err: MergeConflictError): string {
|
||||
const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
|
||||
const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
|
||||
|
||||
return [
|
||||
`# Fix Merge Conflicts`,
|
||||
``,
|
||||
`A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
|
||||
``,
|
||||
fileList,
|
||||
``,
|
||||
`## Instructions`,
|
||||
``,
|
||||
`1. Read each conflicted file listed above`,
|
||||
`2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
|
||||
`3. Stage the resolved files with \`git add <file>\``,
|
||||
`4. Commit the resolution:`,
|
||||
err.strategy === "squash"
|
||||
? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
|
||||
: ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
|
||||
``,
|
||||
`## Rules`,
|
||||
``,
|
||||
`- Do NOT run \`git merge --abort\` or \`git reset\``,
|
||||
`- Do NOT modify any files other than the conflicted ones listed above`,
|
||||
`- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
|
||||
``,
|
||||
`## Verification`,
|
||||
``,
|
||||
`After committing, verify:`,
|
||||
`1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
|
||||
`2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
|
||||
`3. \`git status\` shows a clean working tree`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
|
||||
if (!content) {
|
||||
return [
|
||||
|
|
@ -3342,10 +3090,6 @@ function ensurePreconditions(
|
|||
}
|
||||
}
|
||||
|
||||
if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
|
||||
const sid = parts[1]!;
|
||||
ensureSliceBranch(base, mid, sid);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diagnostics ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -3752,8 +3496,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
|
|||
const dir = resolveMilestonePath(base, mid);
|
||||
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
||||
}
|
||||
case "fix-merge":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -3772,14 +3514,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
|
|||
// Clear stale directory listing cache so artifact checks see fresh disk state (#431)
|
||||
clearPathCache();
|
||||
|
||||
// fix-merge has no file artifact — verify by checking git state
|
||||
if (unitType === "fix-merge") {
|
||||
const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
||||
if (unmerged && unmerged.trim()) return false;
|
||||
if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
|
||||
if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
|
||||
return true;
|
||||
}
|
||||
// Hook units have no standard artifact — always pass. Their lifecycle
|
||||
// is managed by the hook engine, not the artifact verification system.
|
||||
if (unitType.startsWith("hook/")) return true;
|
||||
|
||||
|
||||
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
||||
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
|
||||
|
|
@ -3887,8 +3625,6 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string
|
|||
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
|
||||
case "complete-milestone":
|
||||
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
|
||||
case "fix-merge":
|
||||
return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ export type DoctorIssueCode =
|
|||
| "orphaned_auto_worktree"
|
||||
| "stale_milestone_branch"
|
||||
| "corrupt_merge_state"
|
||||
| "tracked_runtime_files";
|
||||
| "tracked_runtime_files"
|
||||
| "legacy_slice_branches";
|
||||
|
||||
export interface DoctorIssue {
|
||||
severity: DoctorSeverity;
|
||||
|
|
@ -642,6 +643,28 @@ async function checkGitHealth(
|
|||
} catch {
|
||||
// git ls-files failed — skip
|
||||
}
|
||||
|
||||
// ── Legacy slice branches ──────────────────────────────────────────────
|
||||
try {
|
||||
const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
if (sliceBranches) {
|
||||
const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "legacy_slice_branches",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture). Delete with: git branch -D ${branchList.join(" ")}`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// git branch list failed — skip
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
|
||||
|
|
|
|||
|
|
@ -83,77 +83,6 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
|
|||
return { cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a merge operation with self-healing retry logic.
|
||||
*
|
||||
* Calls `mergeFn()`. On failure:
|
||||
* - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
|
||||
* as MergeConflictError immediately — no retry for real code conflicts.
|
||||
* - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
|
||||
* - On second failure, throws the error.
|
||||
*
|
||||
* @param cwd - Working directory for git operations
|
||||
* @param mergeFn - Synchronous function that performs the merge
|
||||
* @returns The return value of `mergeFn()`
|
||||
*/
|
||||
export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
|
||||
try {
|
||||
return mergeFn();
|
||||
} catch (firstError) {
|
||||
// Check for real code conflicts — escalate immediately, no retry
|
||||
try {
|
||||
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
if (conflictOutput.length > 0) {
|
||||
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
|
||||
// If the original error is already a MergeConflictError, re-throw as-is
|
||||
if (firstError instanceof MergeConflictError) {
|
||||
throw firstError;
|
||||
}
|
||||
throw new MergeConflictError(
|
||||
conflictedFiles,
|
||||
"merge",
|
||||
"unknown",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
} catch (diffErr) {
|
||||
// If diffErr is a MergeConflictError we just created/re-threw, propagate it
|
||||
if (diffErr instanceof MergeConflictError) throw diffErr;
|
||||
// Otherwise git diff itself failed — proceed with retry
|
||||
}
|
||||
|
||||
// No real conflict detected — try abort+reset+retry once
|
||||
abortAndReset(cwd);
|
||||
|
||||
// Retry
|
||||
return mergeFn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a failed checkout by resetting first, then checking out.
|
||||
*
|
||||
* Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
|
||||
* If checkout still fails after reset, throws with context.
|
||||
*/
|
||||
export function recoverCheckout(cwd: string, targetBranch: string): void {
|
||||
execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
|
||||
|
||||
try {
|
||||
execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Known git error patterns mapped to user-friendly messages. */
|
||||
const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,16 +14,13 @@ import { join, sep } from "node:path";
|
|||
|
||||
import {
|
||||
detectWorktreeName,
|
||||
getSliceBranchName,
|
||||
SLICE_BRANCH_RE,
|
||||
} from "./worktree.js";
|
||||
import {
|
||||
nativeGetCurrentBranch,
|
||||
nativeDetectMainBranch,
|
||||
nativeBranchExists,
|
||||
nativeHasMergeConflicts,
|
||||
nativeHasChanges,
|
||||
nativeCommitCountBetween,
|
||||
} from "./native-git-bridge.js";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -37,8 +34,6 @@ export interface GitPreferences {
|
|||
commit_type?: string;
|
||||
main_branch?: string;
|
||||
merge_strategy?: "squash" | "merge";
|
||||
isolation?: "worktree" | "branch";
|
||||
merge_to_main?: "milestone" | "slice";
|
||||
}
|
||||
|
||||
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
|
||||
|
|
@ -48,12 +43,6 @@ export interface CommitOptions {
|
|||
allowEmpty?: boolean;
|
||||
}
|
||||
|
||||
export interface MergeSliceResult {
|
||||
branch: string;
|
||||
mergedCommitMessage: string;
|
||||
deletedBranch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a slice merge hits code conflicts in non-.gsd files.
|
||||
* The working tree is left in a conflicted state (no reset) so the
|
||||
|
|
@ -106,22 +95,8 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
|
|||
".gsd/metrics.json",
|
||||
".gsd/completed-units.json",
|
||||
".gsd/STATE.md",
|
||||
];
|
||||
|
||||
/**
|
||||
* GSD planning artifact paths that must be force-added even when .gsd/
|
||||
* is in .gitignore. These are durable planning files that the agent writes
|
||||
* and that must survive squash-merges to main.
|
||||
*
|
||||
* `git add --force` is a no-op when the path doesn't exist or has no
|
||||
* changes, so this list is safe to apply unconditionally.
|
||||
*/
|
||||
const GSD_DURABLE_PATHS: readonly string[] = [
|
||||
".gsd/milestones/",
|
||||
".gsd/DECISIONS.md",
|
||||
".gsd/QUEUE.md",
|
||||
".gsd/PROJECT.md",
|
||||
".gsd/REQUIREMENTS.md",
|
||||
".gsd/gsd.db",
|
||||
".gsd/DISCUSSION-MANIFEST.json",
|
||||
];
|
||||
|
||||
// ─── Integration Branch Metadata ───────────────────────────────────────────
|
||||
|
|
@ -157,13 +132,11 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
|
|||
* Persist the integration branch for a milestone.
|
||||
*
|
||||
* Called when auto-mode starts on a milestone. Records the branch the user
|
||||
* was on at that point, so that slice branches merge back to it instead of
|
||||
* the repo's default branch. Idempotent when the branch matches; updates
|
||||
* the record when the user starts from a different branch.
|
||||
* was on at that point, so the milestone worktree merges back to the correct
|
||||
* branch. Idempotent when the branch matches; updates the record when the
|
||||
* user starts from a different branch.
|
||||
*
|
||||
* The file is committed immediately so it survives branch switches — the
|
||||
* pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and
|
||||
* uncommitted `.gsd/` files are discarded during checkout.
|
||||
* The file is committed immediately so the metadata is persisted in git.
|
||||
*/
|
||||
export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
|
||||
// Don't record slice branches as the integration target
|
||||
|
|
@ -190,12 +163,9 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
|
|||
existing.integrationBranch = branch;
|
||||
writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
||||
|
||||
// Commit immediately — .gsd/ files are discarded during branch switches
|
||||
// (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs
|
||||
// git checkout -- .gsd/ to prevent checkout conflicts). Without this
|
||||
// commit, the metadata would be lost on the first branch switch.
|
||||
// Commit immediately so the metadata is persisted in git.
|
||||
try {
|
||||
runGit(basePath, ["add", "--force", metaFile]);
|
||||
runGit(basePath, ["add", metaFile]);
|
||||
runGit(basePath, ["commit", "--no-verify", "-F", "-"], {
|
||||
input: `chore(${milestoneId}): record integration branch`,
|
||||
});
|
||||
|
|
@ -332,20 +302,6 @@ export class GitServiceImpl {
|
|||
// error handling is needed per-path.
|
||||
this.git(["add", "-A"]);
|
||||
|
||||
// Force-add GSD planning artifacts that live under .gsd/ but may be
|
||||
// blocked by a .gsd/ gitignore pattern. `git add -A` respects .gitignore,
|
||||
// so new files (CONTEXT.md, SUMMARY.md, PLAN.md, etc.) in gitignored
|
||||
// directories are silently skipped. Without this force-add, planning
|
||||
// artifacts are never committed — they exist on disk but not in git.
|
||||
// Squash-merges then delete them on main because they appear as "removed
|
||||
// relative to main" during the merge.
|
||||
//
|
||||
// Only force-add durable planning paths — runtime paths are excluded
|
||||
// by the reset step below.
|
||||
for (const durablePath of GSD_DURABLE_PATHS) {
|
||||
this.git(["add", "--force", "--", durablePath], { allowFailure: true });
|
||||
}
|
||||
|
||||
for (const exclusion of allExclusions) {
|
||||
this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
|
||||
}
|
||||
|
|
@ -446,140 +402,8 @@ export class GitServiceImpl {
|
|||
}
|
||||
|
||||
/** True if currently on a GSD slice branch. */
|
||||
isOnSliceBranch(): boolean {
|
||||
const current = this.getCurrentBranch();
|
||||
return SLICE_BRANCH_RE.test(current);
|
||||
}
|
||||
|
||||
/** Returns the slice branch name if on one, null otherwise. */
|
||||
getActiveSliceBranch(): string | null {
|
||||
try {
|
||||
const current = this.getCurrentBranch();
|
||||
return SLICE_BRANCH_RE.test(current) ? current : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Branch Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a local branch exists. Native libgit2 when available, execSync fallback.
|
||||
*/
|
||||
private branchExists(branch: string): boolean {
|
||||
return nativeBranchExists(this.basePath, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the slice branch exists and is checked out.
|
||||
*
|
||||
* Creates the branch from the current working branch if it's not a slice
|
||||
* branch (preserves planning artifacts). Falls back to the integration
|
||||
* branch when on another slice branch (avoids chaining slice branches).
|
||||
*
|
||||
* Auto-commits dirty state via smart staging before checkout so runtime
|
||||
* files are never accidentally committed during branch switches.
|
||||
*
|
||||
* Returns true if the branch was newly created.
|
||||
*/
|
||||
ensureSliceBranch(milestoneId: string, sliceId: string): boolean {
|
||||
const wtName = detectWorktreeName(this.basePath);
|
||||
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
||||
const current = this.getCurrentBranch();
|
||||
|
||||
if (current === branch) return false;
|
||||
|
||||
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 });
|
||||
if (fetchResult === "" && remotes.split("\n").includes(remote)) {
|
||||
// Check if local is behind upstream (informational only)
|
||||
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 the integration branch to avoid chaining.
|
||||
const mainBranch = this.getMainBranch();
|
||||
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
|
||||
this.git(["branch", branch, base]);
|
||||
created = true;
|
||||
} else {
|
||||
// Branch exists — check it's not checked out in another worktree
|
||||
const worktreeList = this.git(["worktree", "list", "--porcelain"]);
|
||||
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
|
||||
throw new Error(
|
||||
`Branch "${branch}" is already in use by another worktree. ` +
|
||||
`Remove that worktree first, or switch it to a different branch.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-commit dirty state via smart staging before checkout.
|
||||
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
||||
this.autoCommit("pre-switch", current, [".gsd/"]);
|
||||
|
||||
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
|
||||
// Two-step approach handles both tracked and untracked runtime files:
|
||||
// 1. `checkout --` reverts tracked .gsd/ files to their HEAD versions.
|
||||
// 2. `clean -fdx` removes untracked runtime files that the target branch has
|
||||
// tracked — e.g., when a prior cleanup commit removed STATE.md from the
|
||||
// current branch's HEAD but the target branch still has it committed.
|
||||
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
|
||||
this.discardUntrackedRuntimeFiles();
|
||||
|
||||
this.git(["checkout", branch]);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the integration branch, auto-committing dirty state via smart staging first.
|
||||
*/
|
||||
switchToMain(): void {
|
||||
const mainBranch = this.getMainBranch();
|
||||
const current = this.getCurrentBranch();
|
||||
if (current === mainBranch) return;
|
||||
|
||||
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
||||
this.autoCommit("pre-switch", current, [".gsd/"]);
|
||||
|
||||
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
|
||||
// Two-step approach handles both tracked and untracked runtime files.
|
||||
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
|
||||
this.discardUntrackedRuntimeFiles();
|
||||
|
||||
this.git(["checkout", mainBranch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove untracked runtime files from the working tree.
|
||||
*
|
||||
* Complements `git checkout -- .gsd/` (which only handles tracked files).
|
||||
* Runtime files can end up untracked after a cleanup commit removes them
|
||||
* from the current branch's HEAD — but the target branch may still have
|
||||
* them committed. Without this step, `git checkout` fails with:
|
||||
* "The following untracked working tree files would be overwritten by checkout"
|
||||
*
|
||||
* `git clean -fdx` is safe here because:
|
||||
* - Only removes *untracked* files (tracked files are untouched)
|
||||
* - Targets only the specific runtime paths listed in RUNTIME_EXCLUSION_PATHS
|
||||
* - These files are always regenerated by GSD on the next run
|
||||
*/
|
||||
private discardUntrackedRuntimeFiles(): void {
|
||||
this.git(["clean", "-fdx", "--", ...RUNTIME_EXCLUSION_PATHS], { allowFailure: true });
|
||||
}
|
||||
|
||||
// ─── S05 Features ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -644,253 +468,6 @@ export class GitServiceImpl {
|
|||
|
||||
// ─── 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 the integration branch and delete it.
|
||||
*
|
||||
* The integration branch is resolved by getMainBranch() — this may be
|
||||
* `main`, a feature branch, or a worktree branch depending on context.
|
||||
*
|
||||
* Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
|
||||
* auto-push (if enabled) → delete branch.
|
||||
*
|
||||
* Must be called from the integration branch. Uses `inferCommitType(sliceTitle)`
|
||||
* for the conventional commit type instead of hardcoding `feat`.
|
||||
*
|
||||
* Throws when:
|
||||
* - Not currently on the integration branch
|
||||
* - The slice branch does not exist
|
||||
* - The slice branch has no commits ahead of the integration branch
|
||||
*/
|
||||
mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult {
|
||||
const mainBranch = this.getMainBranch();
|
||||
const current = this.getCurrentBranch();
|
||||
|
||||
if (current !== mainBranch) {
|
||||
throw new Error(
|
||||
`mergeSliceToMain must be called from the main branch ("${mainBranch}"), ` +
|
||||
`but currently on "${current}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const wtName = detectWorktreeName(this.basePath);
|
||||
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
||||
|
||||
if (!this.branchExists(branch)) {
|
||||
throw new Error(
|
||||
`Slice branch "${branch}" does not exist. Nothing to merge.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check commits ahead — native libgit2 revwalk when available
|
||||
const aheadCount = nativeCommitCountBetween(this.basePath, mainBranch, branch);
|
||||
if (aheadCount === 0) {
|
||||
throw new Error(
|
||||
`Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot the branch HEAD before merge (gated on prefs)
|
||||
// We need to save the ref while the branch still exists
|
||||
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,
|
||||
);
|
||||
|
||||
// Pull latest main before merging to avoid conflicts from remote changes
|
||||
this.git(["pull", "--rebase", "origin", mainBranch], { allowFailure: true });
|
||||
|
||||
// Untrack runtime files that may have been manually committed (e.g. via `gsd queue`)
|
||||
// to prevent merge conflicts on files that belong in .gitignore (#189)
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
|
||||
}
|
||||
const untrackDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
||||
if (untrackDiff && untrackDiff.trim()) {
|
||||
this.git(["commit", "--no-verify", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true });
|
||||
}
|
||||
|
||||
// Merge slice branch — strategy is configurable via git.merge_strategy
|
||||
// preference. Default: "squash" (preserves existing behavior).
|
||||
// "merge" uses --no-ff which is more resilient to conflicts from
|
||||
// long-lived branches or frequently-changing .gsd/* artifacts.
|
||||
const strategy = this.prefs.merge_strategy ?? "squash";
|
||||
const mergeArgs = strategy === "merge"
|
||||
? ["merge", "--no-ff", "-m", message, branch]
|
||||
: ["merge", "--squash", branch];
|
||||
|
||||
try {
|
||||
this.git(mergeArgs);
|
||||
} catch (mergeError) {
|
||||
// Check if conflicts can be auto-resolved (#189, #218)
|
||||
//
|
||||
// ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
|
||||
// The conflict resolution logic below applies ONLY when git.isolation = "branch".
|
||||
// In worktree isolation mode, each milestone works in its own worktree directory
|
||||
// so merge conflicts between slice branches and main are handled differently
|
||||
// (worktree teardown merges via worktree-manager). This block is never reached
|
||||
// in worktree mode because mergeSliceToMain is only called from the branch-mode
|
||||
// code path. If you're modifying this logic, verify the isolation mode first.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
||||
if (conflicted) {
|
||||
const conflictedFiles = conflicted.split("\n").filter(Boolean);
|
||||
const isRuntimeConflict = (f: string) =>
|
||||
RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, "")));
|
||||
|
||||
const runtimeConflicts = conflictedFiles.filter(isRuntimeConflict);
|
||||
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/") && !isRuntimeConflict(f));
|
||||
const otherConflicts = conflictedFiles.filter(
|
||||
f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"),
|
||||
);
|
||||
|
||||
let resolvedAny = false;
|
||||
|
||||
if (runtimeConflicts.length > 0) {
|
||||
// Runtime conflicts: take theirs and remove from index
|
||||
for (const f of runtimeConflicts) {
|
||||
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
|
||||
this.git(["rm", "--cached", "--ignore-unmatch", f], { allowFailure: true });
|
||||
}
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
if (gsdConflicts.length > 0) {
|
||||
// Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
|
||||
// The slice branch has the authoritative .gsd/ state since the LLM just finished
|
||||
// updating these artifacts during complete-slice. Take theirs (the slice branch).
|
||||
for (const f of gsdConflicts) {
|
||||
this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
|
||||
}
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
if (resolvedAny) {
|
||||
this.git(["add", "-A"], { allowFailure: true });
|
||||
|
||||
// Re-check remaining conflicts after auto-resolving runtime and .gsd/ files
|
||||
const remaining = this.git(["diff", "--name-only", "--diff-filter=U"], {
|
||||
allowFailure: true,
|
||||
});
|
||||
if (remaining) {
|
||||
const remainingFiles = remaining
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter(f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"));
|
||||
|
||||
if (remainingFiles.length > 0) {
|
||||
// Non-runtime, non-.gsd/ conflicts: leave working tree in conflicted state and throw
|
||||
// MergeConflictError so the caller can dispatch a fix-merge session.
|
||||
throw new MergeConflictError(remainingFiles, strategy, branch, mainBranch);
|
||||
}
|
||||
}
|
||||
// No remaining non-runtime, non-.gsd/ conflicts — let the merge proceed
|
||||
} else {
|
||||
// No runtime or .gsd/ conflicts to auto-resolve; throw with original conflicted files
|
||||
// so the caller can dispatch a fix-merge session.
|
||||
throw new MergeConflictError(otherConflicts.length ? otherConflicts : conflictedFiles, strategy, branch, mainBranch);
|
||||
}
|
||||
} else {
|
||||
// No conflicted files detected but merge still failed — reset and throw
|
||||
this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
|
||||
const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
|
||||
throw new Error(
|
||||
`${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed. ` +
|
||||
`Working tree has been reset to a clean state. ` +
|
||||
`Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
|
||||
`Original error: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip runtime files from the merge result before committing (#302).
|
||||
// This replaces the old approach of checking out the slice branch to
|
||||
// untrack runtime files pre-merge, which failed when the working tree
|
||||
// had uncommitted .gsd/ changes that blocked the checkout.
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
|
||||
}
|
||||
|
||||
if (strategy === "squash") {
|
||||
// After stripping runtime files, there may be nothing left to commit.
|
||||
// This happens when the only changes in the slice were runtime artifacts.
|
||||
const stagedDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
||||
if (stagedDiff?.trim()) {
|
||||
this.git(["commit", "--no-verify", "-F", "-"], { input: message });
|
||||
} else {
|
||||
// Nothing to commit — clean up the squash-merge state
|
||||
this.git(["reset", "HEAD"], { allowFailure: true });
|
||||
}
|
||||
} else {
|
||||
// --no-ff already committed; amend to include runtime file removal
|
||||
const runtimeDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
|
||||
if (runtimeDiff?.trim()) {
|
||||
this.git(["commit", "--amend", "--no-edit", "--no-verify"]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
const pushResult = this.git(["push", remote, mainBranch], { allowFailure: true });
|
||||
if (pushResult === "") {
|
||||
// push succeeded (empty stdout is normal) or failed silently
|
||||
// Verify by checking if remote is reachable — the allowFailure handles errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch,
|
||||
mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
|
||||
deletedBranch: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ import { execSync } from "node:child_process";
|
|||
* Patterns that are always correct regardless of project type.
|
||||
* No one ever wants these tracked.
|
||||
*/
|
||||
const BASELINE_PATTERNS = [
|
||||
// ── GSD runtime (not source artifacts) ──
|
||||
const GSD_RUNTIME_PATTERNS = [
|
||||
".gsd/activity/",
|
||||
".gsd/runtime/",
|
||||
".gsd/worktrees/",
|
||||
|
|
@ -23,7 +22,15 @@ const BASELINE_PATTERNS = [
|
|||
".gsd/metrics.json",
|
||||
".gsd/completed-units.json",
|
||||
".gsd/STATE.md",
|
||||
".gsd/gsd.db",
|
||||
".gsd/DISCUSSION-MANIFEST.json",
|
||||
".gsd/milestones/**/*-CONTINUE.md",
|
||||
".gsd/milestones/**/continue.md",
|
||||
] as const;
|
||||
|
||||
const BASELINE_PATTERNS = [
|
||||
// ── GSD runtime (not source artifacts — planning files are tracked) ──
|
||||
...GSD_RUNTIME_PATTERNS,
|
||||
|
||||
// ── OS junk ──
|
||||
".DS_Store",
|
||||
|
|
@ -117,8 +124,7 @@ export function ensureGitignore(basePath: string): boolean {
|
|||
* Only removes from the index (`--cached`), never from disk. Idempotent.
|
||||
*/
|
||||
export function untrackRuntimeFiles(basePath: string): void {
|
||||
// The GSD runtime paths are the first 7 entries in BASELINE_PATTERNS
|
||||
const runtimePaths = BASELINE_PATTERNS.slice(0, 7);
|
||||
const runtimePaths = GSD_RUNTIME_PATTERNS;
|
||||
|
||||
for (const pattern of runtimePaths) {
|
||||
// Use -r for directory patterns (trailing slash), strip the slash for the command
|
||||
|
|
|
|||
|
|
@ -702,7 +702,7 @@ export async function showSmartEntry(
|
|||
): Promise<void> {
|
||||
const stepMode = options?.step;
|
||||
|
||||
// ── Ensure git repo exists — GSD needs it for branch-per-slice ──────
|
||||
// ── Ensure git repo exists — GSD needs it for worktree isolation ──────
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -296,6 +296,9 @@ export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, re
|
|||
if (validated.errors.length > 0) {
|
||||
lines.push("- Validation: some preference values were ignored because they were invalid.");
|
||||
}
|
||||
for (const warning of validated.warnings) {
|
||||
lines.push(`- Deprecation: ${warning}`);
|
||||
}
|
||||
|
||||
preferences = validated.preferences;
|
||||
|
||||
|
|
@ -641,8 +644,10 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
export function validatePreferences(preferences: GSDPreferences): {
|
||||
preferences: GSDPreferences;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const validated: GSDPreferences = {};
|
||||
|
||||
if (preferences.version !== undefined) {
|
||||
|
|
@ -729,7 +734,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
const knownUnitTypes = new Set([
|
||||
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
||||
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
||||
"run-uat", "fix-merge", "complete-milestone",
|
||||
"run-uat", "complete-milestone",
|
||||
]);
|
||||
for (const hook of preferences.post_unit_hooks) {
|
||||
if (!hook || typeof hook !== "object") {
|
||||
|
|
@ -795,7 +800,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
const knownUnitTypes = new Set([
|
||||
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
||||
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
||||
"run-uat", "fix-merge", "complete-milestone",
|
||||
"run-uat", "complete-milestone",
|
||||
]);
|
||||
const validActions = new Set(["modify", "skip", "replace"]);
|
||||
for (const hook of preferences.pre_dispatch_hooks) {
|
||||
|
|
@ -909,21 +914,13 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)");
|
||||
}
|
||||
}
|
||||
// Deprecated: isolation and merge_to_main are ignored (branchless architecture).
|
||||
// Emit warnings so users know to remove them from preferences.
|
||||
if (g.isolation !== undefined) {
|
||||
const validIsolation = new Set(["worktree", "branch"]);
|
||||
if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
|
||||
git.isolation = g.isolation as "worktree" | "branch";
|
||||
} else {
|
||||
errors.push("git.isolation must be one of: worktree, branch");
|
||||
}
|
||||
warnings.push("git.isolation is deprecated — worktree isolation is now always enabled. Remove this setting.");
|
||||
}
|
||||
if (g.merge_to_main !== undefined) {
|
||||
const validMergeToMain = new Set(["milestone", "slice"]);
|
||||
if (typeof g.merge_to_main === "string" && validMergeToMain.has(g.merge_to_main)) {
|
||||
git.merge_to_main = g.merge_to_main as "milestone" | "slice";
|
||||
} else {
|
||||
errors.push("git.merge_to_main must be one of: milestone, slice");
|
||||
}
|
||||
warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting.");
|
||||
}
|
||||
|
||||
if (Object.keys(git).length > 0) {
|
||||
|
|
@ -931,7 +928,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
|
||||
return { preferences: validated, errors };
|
||||
return { preferences: validated, errors, warnings };
|
||||
}
|
||||
|
||||
function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
resolveGsdRootFile,
|
||||
gsdRoot,
|
||||
} from './paths.js';
|
||||
import { getActiveSliceBranch } from './worktree.js';
|
||||
|
||||
import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
|
||||
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
|
||||
|
||||
|
|
@ -438,8 +438,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
};
|
||||
}
|
||||
|
||||
const activeBranch = getActiveSliceBranch(basePath);
|
||||
|
||||
// Check if the slice has a plan
|
||||
const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
|
||||
const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null;
|
||||
|
|
@ -453,7 +451,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
|
||||
activeBranch: activeBranch ?? undefined,
|
||||
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
|
|
@ -480,7 +478,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`,
|
||||
activeBranch: activeBranch ?? undefined,
|
||||
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
|
|
@ -501,7 +499,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
|
||||
activeBranch: activeBranch ?? undefined,
|
||||
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
|
|
@ -547,7 +545,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
recentDecisions: [],
|
||||
blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`],
|
||||
nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`,
|
||||
activeBranch: activeBranch ?? undefined,
|
||||
|
||||
activeWorkspace: undefined,
|
||||
registry,
|
||||
requirements,
|
||||
|
|
@ -578,7 +576,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
nextAction: hasInterrupted
|
||||
? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.`
|
||||
: `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`,
|
||||
activeBranch: activeBranch ?? undefined,
|
||||
registry,
|
||||
requirements,
|
||||
progress: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
**Active Slice:** {{sliceId}}: {{sliceTitle}}
|
||||
**Active Task:** {{taskId}}: {{taskTitle}}
|
||||
**Phase:** {{phase}}
|
||||
**Slice Branch:** {{activeBranch}}
|
||||
**Active Workspace:** {{activeWorkspace}}
|
||||
**Next Action:** {{nextAction}}
|
||||
**Last Updated:** {{date}}
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
/**
|
||||
* auto-worktree-merge.test.ts — Integration tests for mergeSliceToMilestone.
|
||||
*
|
||||
* Covers: --no-ff merge topology, rich commit messages, slice branch deletion,
|
||||
* zero-commit error, real code conflicts, .gsd/ non-conflict in worktree mode.
|
||||
* All tests use real git operations in temp repos.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
createAutoWorktree,
|
||||
teardownAutoWorktree,
|
||||
mergeSliceToMilestone,
|
||||
} from "../auto-worktree.ts";
|
||||
import { MergeConflictError } from "../git-service.ts";
|
||||
import { getSliceBranchName } from "../worktree.ts";
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
function run(cmd: string, cwd: string): string {
|
||||
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function createTempRepo(): string {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-merge-test-")));
|
||||
run("git init", dir);
|
||||
run("git config user.email test@test.com", dir);
|
||||
run("git config user.name Test", dir);
|
||||
writeFileSync(join(dir, "README.md"), "# test\n");
|
||||
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
||||
writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
|
||||
run("git add .", dir);
|
||||
run("git commit -m init", dir);
|
||||
run("git branch -M main", dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a slice branch in the worktree, add commits, return branch name. */
|
||||
function setupSliceBranch(
|
||||
wtPath: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
commits: Array<{ file: string; content: string; message: string }>,
|
||||
): string {
|
||||
// Detect worktree name for branch naming
|
||||
const normalizedPath = wtPath.replaceAll("\\", "/");
|
||||
const marker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(marker);
|
||||
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
|
||||
const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
|
||||
|
||||
run(`git checkout -b ${sliceBranch}`, wtPath);
|
||||
for (const c of commits) {
|
||||
writeFileSync(join(wtPath, c.file), c.content);
|
||||
run("git add .", wtPath);
|
||||
run(`git commit -m "${c.message}"`, wtPath);
|
||||
}
|
||||
return sliceBranch;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const savedCwd = process.cwd();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function freshRepo(): string {
|
||||
const d = createTempRepo();
|
||||
tempDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
try {
|
||||
// ─── Test 1: Single slice --no-ff merge ────────────────────────────
|
||||
console.log("\n=== single slice --no-ff merge ===");
|
||||
{
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M003");
|
||||
|
||||
const sliceBranch = setupSliceBranch(wtPath, "M003", "S01", [
|
||||
{ file: "a.ts", content: "const a = 1;\n", message: "add a.ts" },
|
||||
{ file: "b.ts", content: "const b = 2;\n", message: "add b.ts" },
|
||||
{ file: "c.ts", content: "const c = 3;\n", message: "add c.ts" },
|
||||
]);
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
|
||||
const result = mergeSliceToMilestone(repo, "M003", "S01", "Add core files");
|
||||
|
||||
// Verify we're back on milestone branch
|
||||
const branch = run("git branch --show-current", wtPath);
|
||||
assertEq(branch, "milestone/M003", "back on milestone branch after merge");
|
||||
|
||||
// Verify merge topology via git log --graph
|
||||
const log = run("git log --oneline --graph", wtPath);
|
||||
assertTrue(log.includes("* "), "merge commit visible in graph (asterisk with two parents)");
|
||||
assertTrue(log.includes("add a.ts"), "slice commit 'add a.ts' visible");
|
||||
assertTrue(log.includes("add b.ts"), "slice commit 'add b.ts' visible");
|
||||
assertTrue(log.includes("add c.ts"), "slice commit 'add c.ts' visible");
|
||||
|
||||
// Verify commit message format
|
||||
assertMatch(result.mergedCommitMessage, /feat\(M003\/S01\)/, "commit message has conventional format");
|
||||
assertTrue(result.mergedCommitMessage.includes("Add core files"), "commit message includes slice title");
|
||||
|
||||
// Verify slice branch deleted
|
||||
assertTrue(result.deletedBranch, "slice branch deleted");
|
||||
const branches = run("git branch", wtPath);
|
||||
assertTrue(!branches.includes(sliceBranch), "slice branch no longer in git branch list");
|
||||
|
||||
teardownAutoWorktree(repo, "M003");
|
||||
}
|
||||
|
||||
// ─── Test 2: Two sequential slices ─────────────────────────────────
|
||||
console.log("\n=== two sequential slices ===");
|
||||
{
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M003");
|
||||
|
||||
// Slice S01
|
||||
setupSliceBranch(wtPath, "M003", "S01", [
|
||||
{ file: "s1.ts", content: "export const s1 = 1;\n", message: "s1 work" },
|
||||
]);
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
mergeSliceToMilestone(repo, "M003", "S01", "First slice");
|
||||
|
||||
// Slice S02
|
||||
setupSliceBranch(wtPath, "M003", "S02", [
|
||||
{ file: "s2.ts", content: "export const s2 = 2;\n", message: "s2 work" },
|
||||
]);
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
mergeSliceToMilestone(repo, "M003", "S02", "Second slice");
|
||||
|
||||
// Verify two merge boundaries
|
||||
const log = run("git log --oneline --graph", wtPath);
|
||||
const mergeLines = log.split("\n").filter(l => l.includes("* "));
|
||||
assertTrue(mergeLines.length >= 2, "two distinct merge commits in graph");
|
||||
assertTrue(log.includes("s1 work"), "S01 commit visible");
|
||||
assertTrue(log.includes("s2 work"), "S02 commit visible");
|
||||
|
||||
teardownAutoWorktree(repo, "M003");
|
||||
}
|
||||
|
||||
// ─── Test 3: Zero commits throws ───────────────────────────────────
|
||||
console.log("\n=== zero commits throws ===");
|
||||
{
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M003");
|
||||
|
||||
// Create slice branch with no commits ahead
|
||||
const normalizedPath = wtPath.replaceAll("\\", "/");
|
||||
const marker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(marker);
|
||||
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
|
||||
const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
|
||||
run(`git checkout -b ${sliceBranch}`, wtPath);
|
||||
// No commits — immediately try to merge
|
||||
run(`git checkout milestone/M003`, wtPath);
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
mergeSliceToMilestone(repo, "M003", "S01", "Empty slice");
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
assertTrue(
|
||||
err instanceof Error && err.message.includes("no commits ahead"),
|
||||
"error message mentions no commits ahead",
|
||||
);
|
||||
}
|
||||
assertTrue(threw, "mergeSliceToMilestone throws on zero commits");
|
||||
|
||||
teardownAutoWorktree(repo, "M003");
|
||||
}
|
||||
|
||||
// ─── Test 4: Real code conflict throws MergeConflictError ──────────
|
||||
console.log("\n=== real code conflict throws MergeConflictError ===");
|
||||
{
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M003");
|
||||
|
||||
// Add a file on milestone branch
|
||||
writeFileSync(join(wtPath, "shared.ts"), "// version 1\n");
|
||||
run("git add .", wtPath);
|
||||
run('git commit -m "add shared.ts"', wtPath);
|
||||
|
||||
// Create slice branch, modify same file differently
|
||||
const normalizedPath = wtPath.replaceAll("\\", "/");
|
||||
const marker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(marker);
|
||||
const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
|
||||
const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
|
||||
run(`git checkout -b ${sliceBranch}`, wtPath);
|
||||
writeFileSync(join(wtPath, "shared.ts"), "// slice version\nexport const x = 1;\n");
|
||||
run("git add .", wtPath);
|
||||
run('git commit -m "slice edit shared.ts"', wtPath);
|
||||
|
||||
// Modify same file on milestone branch
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
writeFileSync(join(wtPath, "shared.ts"), "// milestone version\nexport const y = 2;\n");
|
||||
run("git add .", wtPath);
|
||||
run('git commit -m "milestone edit shared.ts"', wtPath);
|
||||
|
||||
// Go back to milestone branch for merge call
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
|
||||
let caught: MergeConflictError | null = null;
|
||||
try {
|
||||
mergeSliceToMilestone(repo, "M003", "S01", "Conflicting slice");
|
||||
} catch (err) {
|
||||
if (err instanceof MergeConflictError) {
|
||||
caught = err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(caught !== null, "MergeConflictError thrown on conflict");
|
||||
if (caught) {
|
||||
assertTrue(caught.conflictedFiles.includes("shared.ts"), "conflictedFiles includes shared.ts");
|
||||
assertEq(caught.strategy, "merge", "strategy is merge");
|
||||
assertTrue(caught.branch.includes("S01"), "branch includes S01");
|
||||
}
|
||||
|
||||
// Clean up conflict state before teardown
|
||||
run("git merge --abort || true", wtPath);
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
teardownAutoWorktree(repo, "M003");
|
||||
}
|
||||
|
||||
// ─── Test 5: .gsd/ changes don't conflict ─────────────────────────
|
||||
console.log("\n=== .gsd/ changes don't conflict ===");
|
||||
{
|
||||
const repo = freshRepo();
|
||||
const wtPath = createAutoWorktree(repo, "M003");
|
||||
|
||||
// The .gsd/ directory in worktrees is local — it's not shared via git
|
||||
// between the main repo and the worktree. So modifications to .gsd/
|
||||
// files in both branches shouldn't cause conflicts because .gsd/ is
|
||||
// in the main repo's tree but the worktree has its own working copy.
|
||||
//
|
||||
// In the worktree, .gsd/ IS tracked (inherited from main). But since
|
||||
// slice branches diverge from milestone branch, .gsd/ changes on both
|
||||
// can conflict. The key insight: in real auto-mode, .gsd/ changes only
|
||||
// happen on the milestone branch (planning artifacts), not on slice
|
||||
// branches (which only have code changes). So we test that code-only
|
||||
// slice commits merge cleanly even when milestone has .gsd/ changes.
|
||||
|
||||
// Add a .gsd/ change on milestone branch
|
||||
writeFileSync(join(wtPath, ".gsd", "STATE.md"), "# Updated State\nactive: M003\n");
|
||||
run("git add .", wtPath);
|
||||
run('git commit -m "update .gsd/STATE.md on milestone"', wtPath);
|
||||
|
||||
// Create slice branch with code-only changes
|
||||
setupSliceBranch(wtPath, "M003", "S01", [
|
||||
{ file: "feature.ts", content: "export const feature = true;\n", message: "add feature" },
|
||||
]);
|
||||
run("git checkout milestone/M003", wtPath);
|
||||
|
||||
// Merge should succeed — no .gsd/ conflict since slice didn't touch .gsd/
|
||||
const result = mergeSliceToMilestone(repo, "M003", "S01", "Feature slice");
|
||||
assertTrue(result.branch.includes("S01"), ".gsd/ no-conflict merge succeeded");
|
||||
assertTrue(result.deletedBranch, "slice branch deleted after .gsd/-safe merge");
|
||||
|
||||
// Verify feature file exists after merge
|
||||
assertTrue(existsSync(join(wtPath, "feature.ts")), "feature.ts present after merge");
|
||||
|
||||
teardownAutoWorktree(repo, "M003");
|
||||
}
|
||||
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
for (const d of tempDirs) {
|
||||
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -14,7 +14,6 @@ import { execSync } from "node:child_process";
|
|||
import {
|
||||
createAutoWorktree,
|
||||
mergeMilestoneToMain,
|
||||
mergeSliceToMilestone,
|
||||
getAutoWorktreeOriginalBase,
|
||||
} from "../auto-worktree.ts";
|
||||
import { getSliceBranchName } from "../worktree.ts";
|
||||
|
|
@ -71,7 +70,9 @@ function addSliceToMilestone(
|
|||
run(`git commit -m "${c.message}"`, wtPath);
|
||||
}
|
||||
run(`git checkout milestone/${milestoneId}`, wtPath);
|
||||
mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
|
||||
run(`git merge --no-ff ${sliceBranch} -m "feat(${milestoneId}/${sliceId}): ${sliceTitle}"`, wtPath);
|
||||
// Clean up the slice branch
|
||||
run(`git branch -d ${sliceBranch}`, wtPath);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -6,17 +6,14 @@
|
|||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rmSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
abortAndReset,
|
||||
withMergeHeal,
|
||||
recoverCheckout,
|
||||
formatGitError,
|
||||
MergeConflictError,
|
||||
} from "../git-self-heal.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
|
@ -105,107 +102,6 @@ console.log("── abortAndReset ──");
|
|||
}
|
||||
}
|
||||
|
||||
// ─── withMergeHeal ───────────────────────────────────────────────────
|
||||
|
||||
console.log("── withMergeHeal ──");
|
||||
|
||||
// Test: transient failure succeeds on retry
|
||||
{
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
let callCount = 0;
|
||||
const result = withMergeHeal(dir, () => {
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error("transient git error");
|
||||
return "success";
|
||||
});
|
||||
|
||||
assert.strictEqual(result, "success", "should return mergeFn result on retry");
|
||||
assert.strictEqual(callCount, 2, "should have called mergeFn twice");
|
||||
|
||||
console.log(" ✓ transient failure succeeds on retry");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Test: real conflict escalates immediately (no retry)
|
||||
{
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
// Set up a real merge conflict
|
||||
execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
|
||||
writeFileSync(join(dir, "conflict.txt"), "branch A\n");
|
||||
execSync("git add -A && git commit -m \"branch A\"", { cwd: dir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
|
||||
writeFileSync(join(dir, "conflict.txt"), "branch B\n");
|
||||
execSync("git add -A && git commit -m \"branch B\"", { cwd: dir, stdio: "pipe" });
|
||||
|
||||
let callCount = 0;
|
||||
try {
|
||||
withMergeHeal(dir, () => {
|
||||
callCount++;
|
||||
// Actually perform the conflicting merge
|
||||
execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" });
|
||||
});
|
||||
assert.fail("should have thrown MergeConflictError");
|
||||
} catch (err) {
|
||||
assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`);
|
||||
assert.strictEqual(callCount, 1, "should NOT retry on real conflict");
|
||||
}
|
||||
|
||||
console.log(" ✓ real conflict escalates immediately without retry");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── recoverCheckout ─────────────────────────────────────────────────
|
||||
|
||||
console.log("── recoverCheckout ──");
|
||||
|
||||
// Test: dirty index recovery
|
||||
{
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
// Create a branch to checkout to
|
||||
execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: dir, stdio: "pipe" });
|
||||
|
||||
// Dirty the index
|
||||
writeFileSync(join(dir, "README.md"), "dirty changes\n");
|
||||
execSync("git add README.md", { cwd: dir, stdio: "pipe" });
|
||||
|
||||
// Normal checkout would complain about dirty index
|
||||
recoverCheckout(dir, "target-branch");
|
||||
|
||||
const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim();
|
||||
assert.strictEqual(branch, "target-branch", "should be on target branch after recovery");
|
||||
|
||||
console.log(" ✓ recovers checkout with dirty index");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Test: non-existent branch throws with context
|
||||
{
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
try {
|
||||
recoverCheckout(dir, "nonexistent-branch");
|
||||
assert.fail("should have thrown");
|
||||
} catch (err) {
|
||||
assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error");
|
||||
assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name");
|
||||
}
|
||||
|
||||
console.log(" ✓ throws with context for non-existent branch");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── formatGitError ──────────────────────────────────────────────────
|
||||
|
||||
console.log("── formatGitError ──");
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
writeIntegrationBranch,
|
||||
type GitPreferences,
|
||||
type CommitOptions,
|
||||
type MergeSliceResult,
|
||||
type PreMergeCheckResult,
|
||||
} from "../git-service.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
|
@ -195,8 +194,8 @@ async function main(): Promise<void> {
|
|||
|
||||
assertEq(
|
||||
RUNTIME_EXCLUSION_PATHS.length,
|
||||
7,
|
||||
"exactly 7 runtime exclusion paths"
|
||||
9,
|
||||
"exactly 9 runtime exclusion paths"
|
||||
);
|
||||
|
||||
const expectedPaths = [
|
||||
|
|
@ -207,6 +206,8 @@ async function main(): Promise<void> {
|
|||
".gsd/metrics.json",
|
||||
".gsd/completed-units.json",
|
||||
".gsd/STATE.md",
|
||||
".gsd/gsd.db",
|
||||
".gsd/DISCUSSION-MANIFEST.json",
|
||||
];
|
||||
|
||||
assertEq(
|
||||
|
|
@ -261,10 +262,8 @@ async function main(): Promise<void> {
|
|||
// These are compile-time checks — if we got here, the types import fine
|
||||
const _prefs: GitPreferences = { auto_push: true, remote: "origin" };
|
||||
const _opts: CommitOptions = { message: "test" };
|
||||
const _result: MergeSliceResult = { branch: "main", mergedCommitMessage: "msg", deletedBranch: false };
|
||||
assertTrue(true, "GitPreferences type exported and usable");
|
||||
assertTrue(true, "CommitOptions type exported and usable");
|
||||
assertTrue(true, "MergeSliceResult type exported and usable");
|
||||
|
||||
// Cleanup T01 temp dir
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
|
@ -534,7 +533,7 @@ async function main(): Promise<void> {
|
|||
return dir;
|
||||
}
|
||||
|
||||
// ─── getCurrentBranch / isOnSliceBranch / getActiveSliceBranch ─────────
|
||||
// ─── getCurrentBranch ────────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Branch queries ===");
|
||||
|
||||
|
|
@ -542,21 +541,13 @@ async function main(): Promise<void> {
|
|||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// On main
|
||||
assertEq(svc.getCurrentBranch(), "main", "getCurrentBranch returns main on main branch");
|
||||
assertEq(svc.isOnSliceBranch(), false, "isOnSliceBranch returns false on main");
|
||||
assertEq(svc.getActiveSliceBranch(), null, "getActiveSliceBranch returns null on main");
|
||||
|
||||
// Create and checkout a slice branch manually
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "getCurrentBranch returns slice branch name");
|
||||
assertEq(svc.isOnSliceBranch(), true, "isOnSliceBranch returns true on slice branch");
|
||||
assertEq(svc.getActiveSliceBranch(), "gsd/M001/S01", "getActiveSliceBranch returns branch name on slice branch");
|
||||
|
||||
// Non-slice feature branch
|
||||
run("git checkout -b feature/foo", repo);
|
||||
assertEq(svc.isOnSliceBranch(), false, "isOnSliceBranch returns false on non-slice branch");
|
||||
assertEq(svc.getActiveSliceBranch(), null, "getActiveSliceBranch returns null on non-slice branch");
|
||||
assertEq(svc.getCurrentBranch(), "feature/foo", "getCurrentBranch returns feature branch name");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -591,486 +582,8 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: creates and checks out ────────────────────────
|
||||
|
||||
console.log("\n=== ensureSliceBranch ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
const created = svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(created, true, "ensureSliceBranch returns true on first call (branch created)");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "ensureSliceBranch checks out the slice branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: idempotent ────────────────────────────────────
|
||||
|
||||
console.log("\n=== ensureSliceBranch: idempotent ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
const secondCall = svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(secondCall, false, "ensureSliceBranch returns false when already on the branch");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "still on slice branch after idempotent call");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: from non-main working branch inherits artifacts ──
|
||||
|
||||
console.log("\n=== ensureSliceBranch: from non-main inherits artifacts ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create a feature branch with planning artifacts
|
||||
run("git checkout -b developer", repo);
|
||||
createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "# Roadmap");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "add roadmap"', repo);
|
||||
|
||||
// ensureSliceBranch from this non-main, non-slice branch
|
||||
const created = svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(created, true, "branch created from non-main working branch");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out to slice branch");
|
||||
|
||||
// The roadmap from developer branch should be present
|
||||
const logOutput = run("git log --oneline", repo);
|
||||
assertTrue(logOutput.includes("add roadmap"), "slice branch inherits artifacts from working branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: from another slice branch falls back to main ──
|
||||
|
||||
console.log("\n=== ensureSliceBranch: from slice branch falls back to main ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create file only on main
|
||||
createFile(repo, "main-only.txt", "from main");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "main-only file"', repo);
|
||||
|
||||
// Create and check out S01
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
// Add a file only on S01
|
||||
createFile(repo, "s01-only.txt", "from s01");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "S01 work"', repo);
|
||||
|
||||
// Now create S02 from S01 — should fall back to main
|
||||
const created = svc.ensureSliceBranch("M001", "S02");
|
||||
assertEq(created, true, "S02 branch created from S01 (fell back to main)");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S02", "on S02 branch");
|
||||
|
||||
// S02 should NOT have the S01-only file (it branched from main)
|
||||
const showFiles = run("git ls-files", repo);
|
||||
assertTrue(!showFiles.includes("s01-only.txt"), "S02 does not have S01-only files (branched from main)");
|
||||
assertTrue(showFiles.includes("main-only.txt"), "S02 has main files");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: auto-commits dirty files via smart staging ────
|
||||
|
||||
console.log("\n=== ensureSliceBranch: auto-commits with smart staging ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create dirty files: both real and runtime
|
||||
createFile(repo, "src/feature.ts", "export const y = 2;");
|
||||
createFile(repo, ".gsd/activity/session.jsonl", "session data");
|
||||
createFile(repo, ".gsd/STATE.md", "# Current State");
|
||||
createFile(repo, ".gsd/metrics.json", '{"tasks":1}');
|
||||
|
||||
// ensureSliceBranch should auto-commit before checkout
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
|
||||
// The auto-commit on main should have src/feature.ts but NOT runtime files
|
||||
run("git checkout main", repo);
|
||||
const showStat = run("git show --stat --format= HEAD", repo);
|
||||
assertTrue(showStat.includes("src/feature.ts"), "auto-commit includes real files");
|
||||
assertTrue(!showStat.includes(".gsd/activity"), "auto-commit excludes .gsd/activity/ (smart staging)");
|
||||
assertTrue(!showStat.includes("STATE.md"), "auto-commit excludes .gsd/STATE.md (smart staging)");
|
||||
assertTrue(!showStat.includes("metrics.json"), "auto-commit excludes .gsd/metrics.json (smart staging)");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: tracked STATE.md + dirty (regression: "local changes overwritten") ─
|
||||
//
|
||||
// Reproduces: "error: Your local changes to the following files would be overwritten
|
||||
// by checkout: .gsd/STATE.md" that occurred in gsd auto when STATE.md was historically
|
||||
// committed to the repo (before it was added to .gitignore).
|
||||
|
||||
console.log("\n=== ensureSliceBranch: tracked STATE.md + dirty (checkout conflict regression) ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Simulate historical state: STATE.md was committed before gitignore was configured
|
||||
createFile(repo, ".gsd/STATE.md", "# State v1");
|
||||
run("git add -f .gsd/STATE.md", repo);
|
||||
run('git commit -m "add state (pre-gitignore)"', repo);
|
||||
|
||||
// STATE.md gets modified during runtime (dirty)
|
||||
createFile(repo, ".gsd/STATE.md", "# State v2 (modified at runtime)");
|
||||
|
||||
// ensureSliceBranch must not fail with "local changes would be overwritten"
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch despite tracked+dirty STATE.md");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureSliceBranch: untracked STATE.md blocks checkout (regression: cleanup-commit edge case) ─
|
||||
//
|
||||
// Reproduces: "The following untracked working tree files would be overwritten by checkout:
|
||||
// .gsd/STATE.md" when the smartStage cleanup commit removes STATE.md from the current
|
||||
// branch's HEAD but the target branch was already created from the old HEAD (so it still
|
||||
// has STATE.md tracked). Without discardUntrackedRuntimeFiles(), the untracked STATE.md
|
||||
// on disk would block the checkout.
|
||||
|
||||
console.log("\n=== ensureSliceBranch: untracked runtime files blocked by target branch (cleanup-commit edge case) ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
|
||||
// Simulate: STATE.md is tracked in main's HEAD (historical state)
|
||||
createFile(repo, ".gsd/STATE.md", "# State original");
|
||||
run("git add -f .gsd/STATE.md", repo);
|
||||
run('git commit -m "initial with tracked STATE.md"', repo);
|
||||
|
||||
// Simulate what smartStage one-time cleanup does: remove STATE.md from index and commit.
|
||||
// This leaves STATE.md on disk but removes it from main's HEAD.
|
||||
run("git rm --cached .gsd/STATE.md", repo);
|
||||
run('git commit -m "chore: untrack runtime files"', repo);
|
||||
|
||||
// STATE.md exists on disk (modified) but is now untracked in main's HEAD
|
||||
createFile(repo, ".gsd/STATE.md", "# State modified after cleanup");
|
||||
|
||||
// Create slice branch — this is what ensureSliceBranch does internally but we
|
||||
// simulate a GitServiceImpl that has already done the cleanup commit.
|
||||
// The slice branch is created from the OLD HEAD (before cleanup commit) so it HAS
|
||||
// STATE.md tracked. Without discardUntrackedRuntimeFiles(), the checkout would fail.
|
||||
run("git branch gsd/M001/S01 HEAD~1", repo); // branch from HEAD~1 = the commit that had STATE.md
|
||||
|
||||
// Now use GitServiceImpl to switch to the already-existing slice branch
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// ensureSliceBranch must succeed despite the untracked STATE.md on disk
|
||||
// conflicting with the tracked STATE.md in the target branch
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch (untracked runtime file removed before checkout)");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── switchToMain: tracked STATE.md + dirty (regression) ─────────────
|
||||
|
||||
console.log("\n=== switchToMain: tracked STATE.md + dirty (checkout conflict regression) ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Track STATE.md on main (historical pre-gitignore state)
|
||||
createFile(repo, ".gsd/STATE.md", "# State on main");
|
||||
run("git add -f .gsd/STATE.md", repo);
|
||||
run('git commit -m "add state (pre-gitignore)"', repo);
|
||||
|
||||
// Create slice branch (inherits STATE.md from main)
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch before switchToMain");
|
||||
|
||||
// Modify STATE.md on slice branch (runtime update)
|
||||
createFile(repo, ".gsd/STATE.md", "# State updated on slice branch");
|
||||
|
||||
// switchToMain must not fail with "local changes would be overwritten"
|
||||
svc.switchToMain();
|
||||
assertEq(svc.getCurrentBranch(), svc.getMainBranch(), "back on main after switchToMain despite tracked+dirty STATE.md");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── switchToMain ─────────────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== switchToMain ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Switch to a slice branch first
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch before switchToMain");
|
||||
|
||||
// Create dirty files
|
||||
createFile(repo, "src/work.ts", "work in progress");
|
||||
createFile(repo, ".gsd/activity/log.jsonl", "activity log");
|
||||
createFile(repo, ".gsd/runtime/state.json", '{"running":true}');
|
||||
|
||||
svc.switchToMain();
|
||||
assertEq(svc.getCurrentBranch(), "main", "switchToMain switches to main");
|
||||
|
||||
// Verify the auto-commit on the slice branch used smart staging
|
||||
const sliceLog = run("git log gsd/M001/S01 --oneline -1", repo);
|
||||
assertTrue(sliceLog.includes("pre-switch"), "auto-commit message includes pre-switch");
|
||||
|
||||
// Check that the auto-commit on the slice branch excluded runtime files
|
||||
const showStat = run("git log gsd/M001/S01 -1 --format= --stat", repo);
|
||||
assertTrue(showStat.includes("src/work.ts"), "switchToMain auto-commit includes real files");
|
||||
assertTrue(!showStat.includes(".gsd/activity"), "switchToMain auto-commit excludes .gsd/activity/");
|
||||
assertTrue(!showStat.includes(".gsd/runtime"), "switchToMain auto-commit excludes .gsd/runtime/");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── switchToMain: idempotent when already on main ─────────────────────
|
||||
|
||||
console.log("\n=== switchToMain: idempotent ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
assertEq(svc.getCurrentBranch(), "main", "already on main");
|
||||
svc.switchToMain(); // Should not throw
|
||||
assertEq(svc.getCurrentBranch(), "main", "still on main after idempotent switchToMain");
|
||||
|
||||
// Verify no extra commits were created
|
||||
const logCount = run("git rev-list --count HEAD", repo);
|
||||
assertEq(logCount, "1", "no extra commits from idempotent switchToMain");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: full lifecycle with feat ─────────────────────────
|
||||
|
||||
console.log("\n=== mergeSliceToMain: full lifecycle ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create and switch to slice branch
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch for merge test");
|
||||
|
||||
// Do work on the slice branch
|
||||
createFile(repo, "src/feature.ts", "export const feature = true;");
|
||||
svc.commit({ message: "add feature module" });
|
||||
|
||||
// Switch to main and merge
|
||||
svc.switchToMain();
|
||||
const result = svc.mergeSliceToMain("M001", "S01", "Implement user authentication");
|
||||
|
||||
assertEq(result.mergedCommitMessage, "feat(M001/S01): Implement user authentication", "merge commit message uses feat type");
|
||||
assertEq(result.deletedBranch, true, "branch was deleted");
|
||||
assertEq(result.branch, "gsd/M001/S01", "result includes branch name");
|
||||
|
||||
// Verify commit is on main
|
||||
const log = run("git log --oneline -1", repo);
|
||||
assertTrue(log.includes("feat(M001/S01): Implement user authentication"), "merge commit visible in git log");
|
||||
|
||||
// Verify the file is on main
|
||||
const files = run("git ls-files", repo);
|
||||
assertTrue(files.includes("src/feature.ts"), "merged file exists on main");
|
||||
|
||||
// Verify slice branch is deleted
|
||||
const branches = run("git branch", repo);
|
||||
assertTrue(!branches.includes("gsd/M001/S01"), "slice branch deleted after merge");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: fix type ───────────────────────────────────────
|
||||
|
||||
console.log("\n=== mergeSliceToMain: fix type ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
svc.ensureSliceBranch("M001", "S02");
|
||||
createFile(repo, "src/bugfix.ts", "// fixed");
|
||||
svc.commit({ message: "fix the bug" });
|
||||
|
||||
svc.switchToMain();
|
||||
const result = svc.mergeSliceToMain("M001", "S02", "Fix broken config");
|
||||
|
||||
assertTrue(result.mergedCommitMessage.startsWith("fix("), "merge commit starts with fix(");
|
||||
assertEq(result.mergedCommitMessage, "fix(M001/S02): Fix broken config", "fix merge commit message correct");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: docs type ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== mergeSliceToMain: docs type ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
svc.ensureSliceBranch("M001", "S03");
|
||||
createFile(repo, "docs/guide.md", "# Guide");
|
||||
svc.commit({ message: "write docs" });
|
||||
|
||||
svc.switchToMain();
|
||||
const result = svc.mergeSliceToMain("M001", "S03", "Docs update");
|
||||
|
||||
assertTrue(result.mergedCommitMessage.startsWith("docs("), "merge commit starts with docs(");
|
||||
assertEq(result.mergedCommitMessage, "docs(M001/S03): Docs update", "docs merge commit message correct");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: refactor type ──────────────────────────────────
|
||||
|
||||
console.log("\n=== mergeSliceToMain: refactor type ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
svc.ensureSliceBranch("M001", "S04");
|
||||
createFile(repo, "src/refactored.ts", "// cleaner");
|
||||
svc.commit({ message: "restructure modules" });
|
||||
|
||||
svc.switchToMain();
|
||||
const result = svc.mergeSliceToMain("M001", "S04", "Refactor state management");
|
||||
|
||||
assertTrue(result.mergedCommitMessage.startsWith("refactor("), "merge commit starts with refactor(");
|
||||
assertEq(result.mergedCommitMessage, "refactor(M001/S04): Refactor state management", "refactor merge commit message correct");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: error — not on main ────────────────────────────
|
||||
|
||||
console.log("\n=== mergeSliceToMain: error cases ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create a slice branch with a commit
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
createFile(repo, "src/work.ts", "work");
|
||||
svc.commit({ message: "slice work" });
|
||||
|
||||
// Try to merge while still on the slice branch
|
||||
let threw = false;
|
||||
try {
|
||||
svc.mergeSliceToMain("M001", "S01", "Some feature");
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
const msg = (e as Error).message;
|
||||
assertTrue(msg.includes("must be called from the main branch"), "error mentions main branch requirement");
|
||||
assertTrue(msg.includes("gsd/M001/S01"), "error includes current branch name");
|
||||
}
|
||||
assertTrue(threw, "mergeSliceToMain throws when not on main");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: error — branch doesn't exist ───────────────────
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
svc.mergeSliceToMain("M001", "S99", "Nonexistent");
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
const msg = (e as Error).message;
|
||||
assertTrue(msg.includes("does not exist"), "error mentions branch does not exist");
|
||||
assertTrue(msg.includes("gsd/M001/S99"), "error includes missing branch name");
|
||||
}
|
||||
assertTrue(threw, "mergeSliceToMain throws when branch doesn't exist");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: error — no commits ahead ───────────────────────
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create slice branch but don't add any commits
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
// Switch back to main without committing anything on the slice branch
|
||||
svc.switchToMain();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
svc.mergeSliceToMain("M001", "S01", "Empty slice");
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
const msg = (e as Error).message;
|
||||
assertTrue(msg.includes("no commits ahead"), "error mentions no commits ahead");
|
||||
assertTrue(msg.includes("gsd/M001/S01"), "error includes branch name");
|
||||
}
|
||||
assertTrue(threw, "mergeSliceToMain throws when no commits ahead");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── mergeSliceToMain: auto-resolve .gsd/ planning artifact conflicts ──
|
||||
|
||||
console.log("\n=== mergeSliceToMain: auto-resolve .gsd/ planning conflicts ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo);
|
||||
|
||||
// Create a .gsd/ planning artifact on main (simulates reassess-roadmap)
|
||||
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "add decisions on main"', repo);
|
||||
|
||||
// Create slice branch and modify the same .gsd/ file differently
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n- D002: New decision from slice\n");
|
||||
createFile(repo, "src/feature.ts", "export const x = 1;");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "slice work with .gsd/ changes"', repo);
|
||||
|
||||
// Back on main, modify the same .gsd/ file to create a conflict
|
||||
svc.switchToMain();
|
||||
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Updated decision on main\n");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "update decisions on main"', repo);
|
||||
|
||||
// Merge should auto-resolve .gsd/ conflicts by taking theirs (slice branch)
|
||||
const result = svc.mergeSliceToMain("M001", "S01", "Feature with .gsd/ conflicts");
|
||||
assertEq(result.deletedBranch, true, ".gsd/ conflict auto-resolved: branch deleted");
|
||||
|
||||
// Verify the merge succeeded and src file is present
|
||||
assertTrue(existsSync(join(repo, "src/feature.ts")), ".gsd/ conflict auto-resolved: src file merged");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
|
||||
// S05: Enhanced features — snapshots, pre-merge checks
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── createSnapshot: prefs enabled ─────────────────────────────────────
|
||||
|
|
@ -1081,12 +594,12 @@ async function main(): Promise<void> {
|
|||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo, { snapshots: true });
|
||||
|
||||
// Create a slice branch with a commit
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
// Create a branch with a commit
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
createFile(repo, "src/snap.ts", "snapshot me");
|
||||
svc.commit({ message: "snapshot test commit" });
|
||||
|
||||
// Create snapshot ref for this slice branch
|
||||
// Create snapshot ref for this branch
|
||||
svc.createSnapshot("gsd/M001/S01");
|
||||
|
||||
// Verify ref exists under refs/gsd/snapshots/
|
||||
|
|
@ -1104,7 +617,7 @@ async function main(): Promise<void> {
|
|||
const repo = initBranchTestRepo();
|
||||
const svc = new GitServiceImpl(repo, { snapshots: false });
|
||||
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
createFile(repo, "src/no-snap.ts", "no snapshot");
|
||||
svc.commit({ message: "no snapshot commit" });
|
||||
|
||||
|
|
@ -1201,222 +714,6 @@ async function main(): Promise<void> {
|
|||
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
|
||||
assertTrue(commitBody.includes("feat(M001/S01): Implement user authentication"),
|
||||
"rich commit has conventional subject line");
|
||||
|
||||
// Rich commit body should include task list with commit subjects
|
||||
assertTrue(commitBody.includes("add auth module"),
|
||||
"rich commit body includes first commit subject");
|
||||
assertTrue(commitBody.includes("add login page"),
|
||||
"rich commit body includes second commit subject");
|
||||
assertTrue(commitBody.includes("add session handling"),
|
||||
"rich commit body includes third commit subject");
|
||||
|
||||
// Rich commit body should include Branch: line for forensics
|
||||
assertTrue(commitBody.includes("Branch:"),
|
||||
"rich commit body includes Branch: line");
|
||||
assertTrue(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);
|
||||
assertTrue(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);
|
||||
assertTrue(!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;
|
||||
}
|
||||
assertTrue(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;
|
||||
}
|
||||
assertTrue(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);
|
||||
assertTrue(refs.includes("refs/gsd/snapshots/"), "mergeSliceToMain creates snapshot when prefs.snapshots is true");
|
||||
assertTrue(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 });
|
||||
}
|
||||
|
||||
// ─── VALID_BRANCH_NAME regex ──────────────────────────────────────────
|
||||
|
||||
console.log("\n=== VALID_BRANCH_NAME regex ===");
|
||||
|
|
@ -1628,62 +925,6 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── End-to-end: feature branch workflow ──────────────────────────────
|
||||
|
||||
console.log("\n=== End-to-end: feature branch workflow ===");
|
||||
|
||||
{
|
||||
const repo = initBranchTestRepo();
|
||||
|
||||
// Simulate: user creates feature branch and starts GSD
|
||||
run("git checkout -b f-123-new-thing", repo);
|
||||
createFile(repo, "setup.txt", "initial setup");
|
||||
run("git add -A", repo);
|
||||
run('git commit -m "initial feature setup"', repo);
|
||||
|
||||
// Record integration branch (this is what auto.ts does at startup)
|
||||
writeIntegrationBranch(repo, "M001", "f-123-new-thing");
|
||||
|
||||
// Create GitServiceImpl with milestone set
|
||||
const svc = new GitServiceImpl(repo);
|
||||
svc.setMilestoneId("M001");
|
||||
|
||||
// Verify getMainBranch returns the feature branch, not "main"
|
||||
assertEq(svc.getMainBranch(), "f-123-new-thing", "e2e: getMainBranch returns feature branch");
|
||||
|
||||
// Create slice branch — should branch from f-123-new-thing (current)
|
||||
svc.ensureSliceBranch("M001", "S01");
|
||||
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "e2e: slice branch created");
|
||||
|
||||
// The slice branch should have the feature branch's commit
|
||||
const log = run("git log --oneline", repo);
|
||||
assertTrue(log.includes("initial feature setup"), "e2e: slice branch inherits feature branch content");
|
||||
|
||||
// Do work on the slice branch
|
||||
createFile(repo, "src/feature.ts", "export const feature = true;");
|
||||
svc.commit({ message: "feat: add feature module" });
|
||||
|
||||
// switchToMain should go to feature branch
|
||||
svc.switchToMain();
|
||||
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: switchToMain goes to feature branch, not main");
|
||||
|
||||
// mergeSliceToMain should merge into feature branch
|
||||
const result = svc.mergeSliceToMain("M001", "S01", "Add feature module");
|
||||
assertEq(result.mergedCommitMessage, "feat(M001/S01): Add feature module", "e2e: merge commit message correct");
|
||||
assertEq(svc.getCurrentBranch(), "f-123-new-thing", "e2e: after merge, still on feature branch");
|
||||
|
||||
// The feature branch should have the merged work
|
||||
const files = run("git ls-files", repo);
|
||||
assertTrue(files.includes("src/feature.ts"), "e2e: merged file exists on feature branch");
|
||||
|
||||
// Main should NOT have the merged work
|
||||
run("git checkout main", repo);
|
||||
const mainFiles = run("git ls-files", repo);
|
||||
assertTrue(!mainFiles.includes("src/feature.ts"), "e2e: main does NOT have merged work — it stays on the feature branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── Per-milestone isolation: different milestones, different targets ──
|
||||
|
||||
console.log("\n=== Integration branch: per-milestone isolation ===");
|
||||
|
|
|
|||
|
|
@ -280,119 +280,6 @@ function cleanup(base: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ═══ verifyExpectedArtifact: fix-merge ════════════════════════════════════════
|
||||
|
||||
/** Create a real git repo for fix-merge tests */
|
||||
function createGitBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-fixmerge-test-"));
|
||||
execSync("git init -b main", { cwd: base, stdio: "ignore" });
|
||||
execSync("git config user.email test@test.com", { cwd: base, stdio: "ignore" });
|
||||
execSync("git config user.name Test", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "README.md"), "init\n", "utf-8");
|
||||
execSync("git add -A && git commit -m init", { cwd: base, stdio: "ignore" });
|
||||
// Create .gsd structure for the fixture
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — clean repo returns true ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === true, "clean repo should verify as true");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — MERGE_HEAD present returns false ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
writeFileSync(join(base, ".git", "MERGE_HEAD"), "abc123\n", "utf-8");
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === false, "MERGE_HEAD present should return false");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — SQUASH_MSG present returns false ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
writeFileSync(join(base, ".git", "SQUASH_MSG"), "squash msg\n", "utf-8");
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === false, "SQUASH_MSG present should return false");
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — real UU conflict returns false ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
// Create a conflict: modify same file on two branches
|
||||
writeFileSync(join(base, "conflict.txt"), "main content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "main change"', { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout -b feature", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "conflict.txt"), "feature content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "feature change"', { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout main", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "conflict.txt"), "different main content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "diverge"', { cwd: base, stdio: "ignore" });
|
||||
try { execSync("git merge feature", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === false, "UU conflict should return false");
|
||||
} finally {
|
||||
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — real DU conflict returns false ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
writeFileSync(join(base, "deleted.txt"), "content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "add file"', { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout -b feature2", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "deleted.txt"), "modified on feature\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "modify on feature"', { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout main", { cwd: base, stdio: "ignore" });
|
||||
execSync("git rm deleted.txt", { cwd: base, stdio: "ignore" });
|
||||
execSync('git commit -m "delete on main"', { cwd: base, stdio: "ignore" });
|
||||
try { execSync("git merge feature2", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === false, "DU conflict should return false");
|
||||
} finally {
|
||||
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
console.log("\n=== verifyExpectedArtifact: fix-merge — real AA conflict returns false ===");
|
||||
const base = createGitBase();
|
||||
try {
|
||||
execSync("git checkout -b branch-a", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "both.txt"), "branch-a content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "add on branch-a"', { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout main", { cwd: base, stdio: "ignore" });
|
||||
execSync("git checkout -b branch-b", { cwd: base, stdio: "ignore" });
|
||||
writeFileSync(join(base, "both.txt"), "branch-b content\n", "utf-8");
|
||||
execSync('git add -A && git commit -m "add on branch-b"', { cwd: base, stdio: "ignore" });
|
||||
try { execSync("git merge branch-a", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
|
||||
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
|
||||
assertTrue(result === false, "AA conflict should return false");
|
||||
} finally {
|
||||
execSync("git reset --hard HEAD", { cwd: base, stdio: "ignore" });
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ verifyExpectedArtifact: complete-slice roadmap check ════════════════════
|
||||
// Regression for #indefinite-hang: complete-slice must verify roadmap [x] or
|
||||
// the idempotency skip loops forever after a crash that wrote SUMMARY+UAT but
|
||||
|
|
@ -574,4 +461,25 @@ const ROADMAP_COMPLETE = `# M001: Test Milestone
|
|||
}
|
||||
}
|
||||
|
||||
// ═══ verifyExpectedArtifact: hook unit types ═════════════════════════════════
|
||||
|
||||
console.log("\n=== verifyExpectedArtifact: hook types always return true ===");
|
||||
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Hook units don't have standard artifacts — they should always pass
|
||||
const result1 = verifyExpectedArtifact("hook/code-review", "M001/S01/T01", base);
|
||||
assertTrue(result1, "hook/code-review should always return true");
|
||||
|
||||
const result2 = verifyExpectedArtifact("hook/simplify", "M001/S01/T02", base);
|
||||
assertTrue(result2, "hook/simplify should always return true");
|
||||
|
||||
const result3 = verifyExpectedArtifact("hook/custom-hook", "M001/S01", base);
|
||||
assertTrue(result3, "hook/custom-hook at slice level should return true");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Uses real filesystem and git fixtures — no mocking.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -16,12 +16,8 @@ import { indexWorkspace } from '../workspace-index.ts';
|
|||
import { inlinePriorMilestoneSummary } from '../files.ts';
|
||||
import { getPriorSliceCompletionBlocker } from '../dispatch-guard.ts';
|
||||
import {
|
||||
ensureSliceBranch,
|
||||
getCurrentBranch,
|
||||
getSliceBranchName,
|
||||
mergeSliceToMain,
|
||||
parseSliceBranch,
|
||||
switchToMain,
|
||||
} from '../worktree.ts';
|
||||
import { clearPathCache } from '../paths.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
|
@ -481,84 +477,22 @@ Built the legacy feature successfully.
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Group 6: Branch operations with new-format IDs ─────────────────
|
||||
console.log('\n=== Group 6: Branch operations with new-format IDs ===');
|
||||
// ─── Group 6: Branch name helpers with new-format IDs ───────────────
|
||||
console.log('\n=== Group 6: Branch name helpers with new-format IDs ===');
|
||||
{
|
||||
const base = createGitRepo();
|
||||
try {
|
||||
// Need a milestone dir and initial commit for branch ops
|
||||
writeRoadmap(base, 'M001-abc123', `# M001-abc123: Branch Test
|
||||
// Test getSliceBranchName with new-format ID
|
||||
assertEq(
|
||||
getSliceBranchName('M001-abc123', 'S01'),
|
||||
'gsd/M001-abc123/S01',
|
||||
'G6: getSliceBranchName returns gsd/M001-abc123/S01',
|
||||
);
|
||||
|
||||
**Vision:** Test branches
|
||||
|
||||
## Slices
|
||||
- [ ] **S01: Slice One** \`risk:low\` \`depends:[]\`
|
||||
> Branch test
|
||||
`);
|
||||
writePlan(base, 'M001-abc123', 'S01', `# S01: Slice One
|
||||
|
||||
**Goal:** Test
|
||||
**Demo:** Branch works
|
||||
|
||||
## Tasks
|
||||
- [ ] **T01: Build** \`est:10m\`
|
||||
Build it.
|
||||
`);
|
||||
writeFileSync(join(base, 'README.md'), 'initial\n');
|
||||
run('git add .', base);
|
||||
run('git commit -m init', base);
|
||||
|
||||
// Test getSliceBranchName with new-format ID
|
||||
assertEq(
|
||||
getSliceBranchName('M001-abc123', 'S01'),
|
||||
'gsd/M001-abc123/S01',
|
||||
'G6: getSliceBranchName returns gsd/M001-abc123/S01',
|
||||
);
|
||||
|
||||
// Test parseSliceBranch with new-format branch name
|
||||
const parsed = parseSliceBranch('gsd/M001-abc123/S01');
|
||||
assertTrue(parsed !== null, 'G6: parseSliceBranch returns non-null for new-format');
|
||||
assertEq(parsed?.milestoneId, 'M001-abc123', 'G6: parsed milestoneId is M001-abc123');
|
||||
assertEq(parsed?.sliceId, 'S01', 'G6: parsed sliceId is S01');
|
||||
assertEq(parsed?.worktreeName, null, 'G6: parsed worktreeName is null (no worktree)');
|
||||
|
||||
// Test ensureSliceBranch creates the branch
|
||||
const created = ensureSliceBranch(base, 'M001-abc123', 'S01');
|
||||
assertTrue(created, 'G6: ensureSliceBranch returns true (branch created)');
|
||||
assertEq(
|
||||
getCurrentBranch(base),
|
||||
'gsd/M001-abc123/S01',
|
||||
'G6: getCurrentBranch returns gsd/M001-abc123/S01',
|
||||
);
|
||||
|
||||
// Idempotent: second ensure should not create
|
||||
const secondCreate = ensureSliceBranch(base, 'M001-abc123', 'S01');
|
||||
assertEq(secondCreate, false, 'G6: second ensureSliceBranch returns false');
|
||||
|
||||
// Make a change on the slice branch, commit, then merge to main
|
||||
writeFileSync(join(base, 'feature.txt'), 'new feature from slice\n');
|
||||
run('git add feature.txt', base);
|
||||
run('git commit -m "feat: slice work"', base);
|
||||
|
||||
// Switch to main and merge
|
||||
switchToMain(base);
|
||||
assertEq(getCurrentBranch(base), 'main', 'G6: back on main after switchToMain');
|
||||
|
||||
const merge = mergeSliceToMain(base, 'M001-abc123', 'S01', 'Slice One');
|
||||
assertEq(merge.branch, 'gsd/M001-abc123/S01', 'G6: merge reports correct branch');
|
||||
assertEq(getCurrentBranch(base), 'main', 'G6: still on main after merge');
|
||||
assertTrue(merge.deletedBranch, 'G6: merge deleted the slice branch');
|
||||
|
||||
// Verify the merged content exists on main
|
||||
const content = readFileSync(join(base, 'feature.txt'), 'utf-8');
|
||||
assertTrue(content.includes('new feature from slice'), 'G6: merged content on main');
|
||||
|
||||
// Verify branch is gone
|
||||
const branches = run('git branch', base);
|
||||
assertTrue(!branches.includes('gsd/M001-abc123/S01'), 'G6: slice branch deleted after merge');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
// Test parseSliceBranch with new-format branch name
|
||||
const parsed = parseSliceBranch('gsd/M001-abc123/S01');
|
||||
assertTrue(parsed !== null, 'G6: parseSliceBranch returns non-null for new-format');
|
||||
assertEq(parsed?.milestoneId, 'M001-abc123', 'G6: parsed milestoneId is M001-abc123');
|
||||
assertEq(parsed?.sliceId, 'S01', 'G6: parsed sliceId is S01');
|
||||
assertEq(parsed?.worktreeName, null, 'G6: parsed worktreeName is null (no worktree)');
|
||||
}
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
/**
|
||||
* isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver.
|
||||
*
|
||||
* Tests three resolution paths:
|
||||
* 1. Explicit git.isolation preference overrides everything
|
||||
* 2. Legacy detection: existing gsd/*\/* branches = branch mode
|
||||
* 3. Default: new project = worktree mode
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import { shouldUseWorktreeIsolation } from "../auto-worktree.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function createTempRepo(): string {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-test-")));
|
||||
run("git init", dir);
|
||||
run("git config user.email test@test.com", dir);
|
||||
run("git config user.name Test", dir);
|
||||
writeFileSync(join(dir, "README.md"), "# test\n");
|
||||
run("git add .", dir);
|
||||
run("git commit -m init", dir);
|
||||
run("git branch -M main", dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const savedCwd = process.cwd();
|
||||
|
||||
console.log("\n=== shouldUseWorktreeIsolation ===");
|
||||
|
||||
// Test 1: New project with no gsd branches → defaults to worktree (true)
|
||||
{
|
||||
const dir = createTempRepo();
|
||||
try {
|
||||
const result = shouldUseWorktreeIsolation(dir);
|
||||
assertEq(result, true, "new project defaults to worktree isolation");
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Legacy project with gsd/*/* branches → returns false (branch mode)
|
||||
{
|
||||
const dir = createTempRepo();
|
||||
try {
|
||||
// Create a legacy gsd/*/* branch
|
||||
run("git checkout -b gsd/M001/S01", dir);
|
||||
writeFileSync(join(dir, "slice.md"), "# S01\n");
|
||||
run("git add .", dir);
|
||||
run("git commit -m \"slice work\"", dir);
|
||||
run("git checkout main", dir);
|
||||
|
||||
const result = shouldUseWorktreeIsolation(dir);
|
||||
assertEq(result, false, "legacy project with gsd branches → branch mode");
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Explicit preference override -- isolation: "worktree"
|
||||
{
|
||||
const dir = createTempRepo();
|
||||
try {
|
||||
// Create legacy branches that would normally trigger branch mode
|
||||
run("git checkout -b gsd/M001/S01", dir);
|
||||
writeFileSync(join(dir, "slice.md"), "# S01\n");
|
||||
run("git add .", dir);
|
||||
run("git commit -m \"slice work\"", dir);
|
||||
run("git checkout main", dir);
|
||||
|
||||
const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });
|
||||
assertEq(result, true, "explicit isolation: worktree overrides legacy detection");
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Explicit preference override -- isolation: "branch"
|
||||
{
|
||||
const dir = createTempRepo();
|
||||
try {
|
||||
// No legacy branches -- would normally default to worktree
|
||||
const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" });
|
||||
assertEq(result, false, "explicit isolation: branch overrides default");
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
/**
|
||||
* Tests for orphaned completed slice branch detection.
|
||||
*
|
||||
* Verifies the git operations and detection logic that mergeOrphanedSliceBranches
|
||||
* in auto.ts relies on — without importing auto.ts (which requires @gsd/pi-coding-agent).
|
||||
* Uses execSync directly and roadmap-slices.ts (no pi-coding-agent dep) to replicate
|
||||
* the detection logic.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { relMilestoneFile } from "../paths.ts";
|
||||
import { parseRoadmapSlices } from "../roadmap-slices.ts";
|
||||
|
||||
// Inline SLICE_BRANCH_RE and parseSliceBranch to avoid importing worktree.ts,
|
||||
// which transitively imports preferences.ts → @gsd/pi-coding-agent (not available in tests).
|
||||
const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+)\/(S\d+)$/;
|
||||
|
||||
function parseSliceBranch(
|
||||
branchName: string,
|
||||
): { worktreeName: string | null; milestoneId: string; sliceId: string } | null {
|
||||
const match = branchName.match(SLICE_BRANCH_RE);
|
||||
if (!match) return null;
|
||||
return { worktreeName: match[1] ?? null, milestoneId: match[2]!, sliceId: match[3]! };
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(
|
||||
` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function git(base: string, args: string[]): string {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd: base,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate the core orphan-detection logic from mergeOrphanedSliceBranches
|
||||
* in auto.ts — using only paths.ts + roadmap-slices.ts + execSync (no pi-coding-agent deps).
|
||||
* Returns a list of orphaned branch descriptors.
|
||||
*/
|
||||
function detectOrphanedSliceBranches(base: string): Array<{
|
||||
branch: string;
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
sliceTitle: string;
|
||||
}> {
|
||||
const orphans: Array<{
|
||||
branch: string;
|
||||
milestoneId: string;
|
||||
sliceId: string;
|
||||
sliceTitle: string;
|
||||
}> = [];
|
||||
|
||||
const branchListRaw = git(base, ["branch", "--list", "gsd/*/*", "--format=%(refname:short)"]);
|
||||
if (!branchListRaw) return orphans;
|
||||
|
||||
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
for (const branch of branches) {
|
||||
const parsed = parseSliceBranch(branch);
|
||||
// Skip worktree-namespaced branches
|
||||
if (!parsed || parsed.worktreeName) continue;
|
||||
|
||||
const { milestoneId, sliceId } = parsed;
|
||||
|
||||
// Skip if already merged (no commits ahead of main)
|
||||
const aheadCount = git(base, ["rev-list", "--count", `main..${branch}`]);
|
||||
if (!aheadCount || aheadCount === "0") continue;
|
||||
|
||||
// Read roadmap from the slice branch
|
||||
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
|
||||
const roadmapContent = git(base, ["show", `${branch}:${roadmapRelPath}`]);
|
||||
if (!roadmapContent) continue;
|
||||
|
||||
const slices = parseRoadmapSlices(roadmapContent);
|
||||
const sliceEntry = slices.find(s => s.id === sliceId);
|
||||
if (!sliceEntry?.done) continue;
|
||||
|
||||
orphans.push({
|
||||
branch,
|
||||
milestoneId,
|
||||
sliceId,
|
||||
sliceTitle: sliceEntry.title || sliceId,
|
||||
});
|
||||
}
|
||||
|
||||
return orphans;
|
||||
}
|
||||
|
||||
// ─── Setup helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function initRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-orphan-test-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.email test@example.com", repo);
|
||||
run("git config user.name Test", repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeBaseArtifacts(repo: string): void {
|
||||
mkdirSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
[
|
||||
"# M001: Demo",
|
||||
"",
|
||||
"## Slices",
|
||||
"- [ ] **S01: First Slice** `risk:low` `depends:[]`",
|
||||
" > After this: feature works",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01: First Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Task** `est:5m`\n do it\n",
|
||||
);
|
||||
run("git add .", repo);
|
||||
run('git commit -m "chore: milestone base"', repo);
|
||||
}
|
||||
|
||||
function writeCompletedArtifactsOnBranch(repo: string): void {
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
[
|
||||
"# M001: Demo",
|
||||
"",
|
||||
"## Slices",
|
||||
"- [x] **S01: First Slice** `risk:low` `depends:[]`",
|
||||
" > After this: feature works",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
|
||||
"# S01: First Slice\n\nDone.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"),
|
||||
"# UAT\n\nPassed.\n",
|
||||
);
|
||||
run("git add .", repo);
|
||||
run('git commit -m "feat(M001/S01): complete-slice"', repo);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== parseSliceBranch: plain branch ===");
|
||||
{
|
||||
const parsed = parseSliceBranch("gsd/M001/S01");
|
||||
assert(parsed !== null, "plain branch parsed");
|
||||
assertEq(parsed?.milestoneId, "M001", "milestone ID extracted");
|
||||
assertEq(parsed?.sliceId, "S01", "slice ID extracted");
|
||||
assertEq(parsed?.worktreeName, null, "no worktree name for plain branch");
|
||||
}
|
||||
|
||||
console.log("\n=== parseSliceBranch: worktree-namespaced branch ===");
|
||||
{
|
||||
const parsed = parseSliceBranch("gsd/wt1/M001/S01");
|
||||
assert(parsed !== null, "worktree branch parsed");
|
||||
assertEq(parsed?.milestoneId, "M001", "milestone ID extracted from worktree branch");
|
||||
assertEq(parsed?.sliceId, "S01", "slice ID extracted from worktree branch");
|
||||
assertEq(parsed?.worktreeName, "wt1", "worktree name extracted");
|
||||
}
|
||||
|
||||
console.log("\n=== parseSliceBranch: non-slice branch not matched ===");
|
||||
{
|
||||
assert(parseSliceBranch("main") === null, "main branch not matched");
|
||||
assert(parseSliceBranch("gsd/M001") === null, "bare milestone branch not matched");
|
||||
assert(!SLICE_BRANCH_RE.test("gsd/M001"), "bare milestone branch not matched by regex");
|
||||
assert(SLICE_BRANCH_RE.test("gsd/M001/S01"), "standard slice branch matched by regex");
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: no slice branches ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
const orphans = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphans.length, 0, "no orphans when no slice branches exist");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: slice branch not done ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeFileSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
|
||||
"# Research\n",
|
||||
);
|
||||
run("git add .", repo);
|
||||
run('git commit -m "feat: research"', repo);
|
||||
run("git checkout main", repo);
|
||||
|
||||
const orphans = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphans.length, 0, "incomplete slice branch is not reported as orphan");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: completed slice branch (orphaned) ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeCompletedArtifactsOnBranch(repo);
|
||||
// Return to main without merging — this is the orphaned branch scenario
|
||||
run("git checkout main", repo);
|
||||
|
||||
const orphans = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphans.length, 1, "completed but unmerged branch detected as orphan");
|
||||
assertEq(orphans[0]?.branch, "gsd/M001/S01", "correct branch name reported");
|
||||
assertEq(orphans[0]?.milestoneId, "M001", "correct milestone ID");
|
||||
assertEq(orphans[0]?.sliceId, "S01", "correct slice ID");
|
||||
assertEq(orphans[0]?.sliceTitle, "First Slice", "correct slice title");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: already merged branch is not orphan ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeCompletedArtifactsOnBranch(repo);
|
||||
run("git checkout main", repo);
|
||||
run("git merge --squash gsd/M001/S01", repo);
|
||||
run('git commit -m "feat(M001/S01): merge"', repo);
|
||||
run("git branch -D gsd/M001/S01", repo);
|
||||
|
||||
const orphans = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphans.length, 0, "already-merged branch is not detected as orphan");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: worktree-namespaced branch is skipped ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
// gsd/wt1/M001/S01 — worktree-namespaced branches are managed by the worktree
|
||||
// manager and must not be merged by the main-tree orphan check.
|
||||
run("git checkout -b gsd/wt1/M001/S01", repo);
|
||||
writeCompletedArtifactsOnBranch(repo);
|
||||
run("git checkout main", repo);
|
||||
|
||||
const orphans = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphans.length, 0, "worktree-namespaced branch not detected by main-tree orphan check");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan detection: relMilestoneFile resolves roadmap path for git show ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeCompletedArtifactsOnBranch(repo);
|
||||
run("git checkout main", repo);
|
||||
|
||||
// Simulate what mergeOrphanedSliceBranches does: read roadmap from branch
|
||||
const roadmapRelPath = relMilestoneFile(repo, "M001", "ROADMAP");
|
||||
const roadmapOnBranch = git(repo, ["show", `gsd/M001/S01:${roadmapRelPath}`]);
|
||||
assert(roadmapOnBranch.length > 0, "roadmap readable from orphaned branch via git show");
|
||||
|
||||
const slices = parseRoadmapSlices(roadmapOnBranch);
|
||||
const s01 = slices.find(s => s.id === "S01");
|
||||
assert(s01?.done === true, "slice marked done on orphaned branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== orphan merge: squash-merge resolves orphan, artifacts appear on main ===");
|
||||
{
|
||||
const repo = initRepo();
|
||||
writeBaseArtifacts(repo);
|
||||
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeCompletedArtifactsOnBranch(repo);
|
||||
run("git checkout main", repo);
|
||||
|
||||
const orphansBefore = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphansBefore.length, 1, "orphan detected before merge");
|
||||
|
||||
// Perform squash-merge (as mergeOrphanedSliceBranches does via mergeSliceToMain)
|
||||
run("git merge --squash gsd/M001/S01", repo);
|
||||
run('git commit -m "feat(M001/S01): recover orphaned branch"', repo);
|
||||
run("git branch -D gsd/M001/S01", repo);
|
||||
|
||||
// Verify artifacts are now on main
|
||||
assert(
|
||||
existsSync(
|
||||
join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
|
||||
),
|
||||
"SUMMARY merged to main after orphan recovery",
|
||||
);
|
||||
assert(
|
||||
existsSync(join(repo, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md")),
|
||||
"UAT merged to main after orphan recovery",
|
||||
);
|
||||
|
||||
// Orphan no longer detected after merge + branch delete
|
||||
const orphansAfter = detectOrphanedSliceBranches(repo);
|
||||
assertEq(orphansAfter.length, 0, "no orphans after merge and branch deletion");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* preferences-git.test.ts — Validates git.isolation and git.merge_to_main preference fields.
|
||||
* preferences-git.test.ts — Validates that deprecated git.isolation and
|
||||
* git.merge_to_main preference fields produce deprecation warnings.
|
||||
*/
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
|
@ -8,78 +9,56 @@ import { validatePreferences } from "../preferences.ts";
|
|||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("\n=== git.isolation validation ===");
|
||||
console.log("\n=== git.isolation deprecated ===");
|
||||
|
||||
// Valid values
|
||||
// Any value produces a deprecation warning
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { isolation: "worktree" } });
|
||||
assertEq(errors.length, 0, "isolation: worktree — no errors");
|
||||
assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved");
|
||||
const { warnings } = validatePreferences({ git: { isolation: "worktree" } });
|
||||
assertTrue(warnings.length > 0, "isolation: worktree — produces deprecation warning");
|
||||
assertTrue(warnings[0].includes("deprecated"), "isolation: worktree — warning mentions deprecated");
|
||||
}
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { isolation: "branch" } });
|
||||
assertEq(errors.length, 0, "isolation: branch — no errors");
|
||||
assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved");
|
||||
const { warnings } = validatePreferences({ git: { isolation: "branch" } });
|
||||
assertTrue(warnings.length > 0, "isolation: branch — produces deprecation warning");
|
||||
assertTrue(warnings[0].includes("deprecated"), "isolation: branch — warning mentions deprecated");
|
||||
}
|
||||
|
||||
// Invalid values
|
||||
// Undefined passes through without warning
|
||||
{
|
||||
const { errors } = validatePreferences({ git: { isolation: "invalid" } });
|
||||
assertTrue(errors.length > 0, "isolation: invalid — produces error");
|
||||
assertTrue(errors[0].includes("isolation"), "isolation: invalid — error mentions isolation");
|
||||
}
|
||||
{
|
||||
const { errors } = validatePreferences({ git: { isolation: 42 } });
|
||||
assertTrue(errors.length > 0, "isolation: number — produces error");
|
||||
}
|
||||
|
||||
// Undefined passes through
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
|
||||
assertEq(errors.length, 0, "isolation: undefined — no errors");
|
||||
const { preferences, warnings } = validatePreferences({ git: { auto_push: true } });
|
||||
assertEq(warnings.length, 0, "isolation: undefined — no warnings");
|
||||
assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set");
|
||||
}
|
||||
|
||||
console.log("\n=== git.merge_to_main validation ===");
|
||||
console.log("\n=== git.merge_to_main deprecated ===");
|
||||
|
||||
// Valid values
|
||||
// Any value produces a deprecation warning
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { merge_to_main: "milestone" } });
|
||||
assertEq(errors.length, 0, "merge_to_main: milestone — no errors");
|
||||
assertEq(preferences.git?.merge_to_main, "milestone", "merge_to_main: milestone — value preserved");
|
||||
const { warnings } = validatePreferences({ git: { merge_to_main: "milestone" } });
|
||||
assertTrue(warnings.length > 0, "merge_to_main: milestone — produces deprecation warning");
|
||||
assertTrue(warnings[0].includes("deprecated"), "merge_to_main: milestone — warning mentions deprecated");
|
||||
}
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { merge_to_main: "slice" } });
|
||||
assertEq(errors.length, 0, "merge_to_main: slice — no errors");
|
||||
assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main: slice — value preserved");
|
||||
const { warnings } = validatePreferences({ git: { merge_to_main: "slice" } });
|
||||
assertTrue(warnings.length > 0, "merge_to_main: slice — produces deprecation warning");
|
||||
assertTrue(warnings[0].includes("deprecated"), "merge_to_main: slice — warning mentions deprecated");
|
||||
}
|
||||
|
||||
// Invalid values
|
||||
// Undefined passes through without warning
|
||||
{
|
||||
const { errors } = validatePreferences({ git: { merge_to_main: "invalid" } });
|
||||
assertTrue(errors.length > 0, "merge_to_main: invalid — produces error");
|
||||
assertTrue(errors[0].includes("merge_to_main"), "merge_to_main: invalid — error mentions merge_to_main");
|
||||
}
|
||||
{
|
||||
const { errors } = validatePreferences({ git: { merge_to_main: false } });
|
||||
assertTrue(errors.length > 0, "merge_to_main: boolean — produces error");
|
||||
}
|
||||
|
||||
// Undefined passes through
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
|
||||
assertEq(errors.length, 0, "merge_to_main: undefined — no errors");
|
||||
const { preferences, warnings } = validatePreferences({ git: { auto_push: true } });
|
||||
assertEq(warnings.length, 0, "merge_to_main: undefined — no warnings");
|
||||
assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set");
|
||||
}
|
||||
|
||||
console.log("\n=== both fields together ===");
|
||||
console.log("\n=== both deprecated fields together ===");
|
||||
{
|
||||
const { preferences, errors } = validatePreferences({
|
||||
const { warnings } = validatePreferences({
|
||||
git: { isolation: "worktree", merge_to_main: "slice" },
|
||||
});
|
||||
assertEq(errors.length, 0, "both fields valid — no errors");
|
||||
assertEq(preferences.git?.isolation, "worktree", "isolation preserved");
|
||||
assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main preserved");
|
||||
assertEq(warnings.length, 2, "both deprecated fields — 2 warnings");
|
||||
assertTrue(warnings.some(w => w.includes("isolation")), "one warning mentions isolation");
|
||||
assertTrue(warnings.some(w => w.includes("merge_to_main")), "one warning mentions merge_to_main");
|
||||
}
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
/**
|
||||
* worktree-e2e.test.ts -- End-to-end tests for worktree-isolated git flow.
|
||||
*
|
||||
* Covers 5 cross-cutting groups not tested by individual slice tests:
|
||||
* Covers cross-cutting groups not tested by individual slice tests:
|
||||
* 1. Full lifecycle chain (create -> slice commits -> merge to milestone -> merge to main)
|
||||
* 2. Preference gating (shouldUseWorktreeIsolation with overrides)
|
||||
* 3. merge_to_main mode resolution (getMergeToMainMode)
|
||||
* 4. Self-heal in merge context (abortAndReset, withMergeHeal)
|
||||
* 5. Doctor detection of orphaned worktrees
|
||||
* 2. Self-heal: abortAndReset cleans up failed merges
|
||||
* 3. Doctor detection of orphaned worktrees
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, rmSync,
|
||||
existsSync, realpathSync, readFileSync,
|
||||
existsSync, realpathSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -20,11 +18,9 @@ import { execSync } from "node:child_process";
|
|||
import {
|
||||
createAutoWorktree,
|
||||
mergeMilestoneToMain,
|
||||
mergeSliceToMilestone,
|
||||
shouldUseWorktreeIsolation,
|
||||
} from "../auto-worktree.ts";
|
||||
import { getSliceBranchName } from "../worktree.ts";
|
||||
import { abortAndReset, withMergeHeal, MergeConflictError } from "../git-self-heal.ts";
|
||||
import { abortAndReset } from "../git-self-heal.ts";
|
||||
import { runGSDDoctor } from "../doctor.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
|
|
@ -60,11 +56,11 @@ function makeRoadmap(
|
|||
}
|
||||
|
||||
function addSliceToMilestone(
|
||||
repo: string,
|
||||
_repo: string,
|
||||
wtPath: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
sliceTitle: string,
|
||||
_sliceTitle: string,
|
||||
commits: Array<{ file: string; content: string; message: string }>,
|
||||
): void {
|
||||
const normalizedPath = wtPath.replaceAll("\\", "/");
|
||||
|
|
@ -81,7 +77,7 @@ function addSliceToMilestone(
|
|||
run(`git commit -m "${c.message}"`, wtPath);
|
||||
}
|
||||
run(`git checkout milestone/${milestoneId}`, wtPath);
|
||||
mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
|
||||
run(`git merge --no-ff ${sliceBranch} -m "merge ${sliceId}"`, wtPath);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
|
@ -144,59 +140,10 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
// ================================================================
|
||||
// Group 2: Preference gating (shouldUseWorktreeIsolation)
|
||||
// ================================================================
|
||||
console.log("\n=== Preference gating ===");
|
||||
{
|
||||
const repo = createTempRepo();
|
||||
tempDirs.push(repo);
|
||||
|
||||
// Override to branch mode
|
||||
const branchResult = shouldUseWorktreeIsolation(repo, { isolation: "branch" });
|
||||
assertEq(branchResult, false, "isolation=branch returns false");
|
||||
|
||||
// Override to worktree mode
|
||||
const wtResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
|
||||
assertEq(wtResult, true, "isolation=worktree returns true");
|
||||
|
||||
// Default (no legacy branches) returns true
|
||||
const defaultResult = shouldUseWorktreeIsolation(repo);
|
||||
assertEq(defaultResult, true, "new project defaults to worktree (true)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Group 3: merge_to_main mode resolution
|
||||
// ================================================================
|
||||
console.log("\n=== merge_to_main mode ===");
|
||||
{
|
||||
// getMergeToMainMode reads from loadEffectiveGSDPreferences — test via legacy branch detection
|
||||
// Instead, test that the function returns the default "milestone" when no prefs set
|
||||
// (Cannot inject overridePrefs — function signature doesn't accept them)
|
||||
// We verify the shouldUseWorktreeIsolation override path handles legacy detection
|
||||
const repo = createTempRepo();
|
||||
tempDirs.push(repo);
|
||||
|
||||
// Create a legacy gsd/*/* branch to test legacy detection
|
||||
run("git checkout -b gsd/M001/S01", repo);
|
||||
writeFileSync(join(repo, "legacy.txt"), "legacy\n");
|
||||
run("git add .", repo);
|
||||
run("git commit -m legacy", repo);
|
||||
run("git checkout main", repo);
|
||||
|
||||
const legacyResult = shouldUseWorktreeIsolation(repo);
|
||||
assertEq(legacyResult, false, "legacy gsd branches detected -> branch mode");
|
||||
|
||||
// Explicit worktree override wins over legacy detection
|
||||
const overrideResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
|
||||
assertEq(overrideResult, true, "explicit worktree override wins over legacy");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Group 4: Self-heal (abortAndReset, withMergeHeal)
|
||||
// Group 2: Self-heal (abortAndReset)
|
||||
// ================================================================
|
||||
console.log("\n=== Self-heal ===");
|
||||
{
|
||||
// 4a: abortAndReset cleans up MERGE_HEAD
|
||||
const repo = createTempRepo();
|
||||
tempDirs.push(repo);
|
||||
|
||||
|
|
@ -218,36 +165,9 @@ async function main(): Promise<void> {
|
|||
assertTrue(!existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after abort");
|
||||
assertTrue(abortResult.cleaned.length > 0, "abortAndReset reports cleaned items");
|
||||
}
|
||||
{
|
||||
// 4b: withMergeHeal throws MergeConflictError for real conflicts
|
||||
const repo = createTempRepo();
|
||||
tempDirs.push(repo);
|
||||
|
||||
run("git checkout -b conflict-branch", repo);
|
||||
writeFileSync(join(repo, "file.txt"), "branch version\n");
|
||||
run("git add .", repo);
|
||||
run("git commit -m branch-ver", repo);
|
||||
run("git checkout main", repo);
|
||||
writeFileSync(join(repo, "file.txt"), "main version\n");
|
||||
run("git add .", repo);
|
||||
run("git commit -m main-ver", repo);
|
||||
|
||||
let caughtError: unknown = null;
|
||||
try {
|
||||
withMergeHeal(repo, () => {
|
||||
execSync("git merge conflict-branch", { cwd: repo, stdio: "pipe" });
|
||||
});
|
||||
} catch (e) {
|
||||
caughtError = e;
|
||||
}
|
||||
assertTrue(caughtError instanceof MergeConflictError, "withMergeHeal throws MergeConflictError");
|
||||
if (caughtError instanceof MergeConflictError) {
|
||||
assertTrue(caughtError.conflictedFiles.length > 0, "MergeConflictError has conflictedFiles");
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Group 5: Doctor detects orphaned worktrees
|
||||
// Group 3: Doctor detects orphaned worktrees
|
||||
// Skip on Windows: git worktree path resolution in temp dirs uses
|
||||
// UNC/8.3 forms that don't match after normalization.
|
||||
// ================================================================
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
* Tests the full lifecycle of GSD operations inside a worktree:
|
||||
* - Branch namespacing (gsd/<wt>/<M>/<S> instead of gsd/<M>/<S>)
|
||||
* - getMainBranch returns worktree/<name> inside a worktree
|
||||
* - switchToMain goes to worktree/<name>, not main
|
||||
* - mergeSliceToMain merges into worktree/<name>
|
||||
* - Parallel worktrees don't conflict on branch names
|
||||
* - State derivation works correctly inside worktrees
|
||||
*/
|
||||
|
|
@ -19,21 +17,15 @@ import {
|
|||
createWorktree,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
worktreePath,
|
||||
worktreeBranchName,
|
||||
} from "../worktree-manager.ts";
|
||||
|
||||
import {
|
||||
detectWorktreeName,
|
||||
ensureSliceBranch,
|
||||
getActiveSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
getSliceBranchName,
|
||||
isOnSliceBranch,
|
||||
mergeSliceToMain,
|
||||
switchToMain,
|
||||
autoCommitCurrentBranch,
|
||||
SLICE_BRANCH_RE,
|
||||
} from "../worktree.ts";
|
||||
|
||||
import { deriveState } from "../state.ts";
|
||||
|
|
@ -104,21 +96,20 @@ async function main(): Promise<void> {
|
|||
console.log("\n=== Worktree initial branch ===");
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
|
||||
|
||||
// ── ensureSliceBranch inside worktree ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== ensureSliceBranch in worktree ===");
|
||||
const created = ensureSliceBranch(wt.path, "M001", "S01");
|
||||
assertTrue(created, "slice branch created");
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
||||
assertTrue(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
|
||||
assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
|
||||
|
||||
// ── Verify branch name helper ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== getSliceBranchName with worktree ===");
|
||||
assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
|
||||
|
||||
// ── Slice branch creation and detection inside worktree ────────────────────
|
||||
|
||||
console.log("\n=== Slice branch in worktree ===");
|
||||
const sliceBranch = getSliceBranchName("M001", "S01", "alpha");
|
||||
run(`git checkout -b ${sliceBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
||||
assertTrue(SLICE_BRANCH_RE.test(getCurrentBranch(wt.path)), "slice branch regex matches namespaced branch");
|
||||
|
||||
// ── Do work on slice branch, then merge to worktree branch ─────────────────
|
||||
|
||||
console.log("\n=== Work and merge slice in worktree ===");
|
||||
|
|
@ -126,14 +117,12 @@ async function main(): Promise<void> {
|
|||
run("git add .", wt.path);
|
||||
run('git commit -m "feat: add feature"', wt.path);
|
||||
|
||||
// switchToMain should go to worktree/alpha, NOT main
|
||||
switchToMain(wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "switchToMain goes to worktree branch, not main");
|
||||
// Checkout worktree base branch and merge slice branch
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
|
||||
// mergeSliceToMain should merge into worktree/alpha
|
||||
const merge = mergeSliceToMain(wt.path, "M001", "S01", "First");
|
||||
assertEq(merge.branch, "gsd/alpha/M001/S01", "merged the namespaced branch");
|
||||
assertTrue(merge.deletedBranch, "slice branch deleted after merge");
|
||||
run(`git merge --no-ff ${sliceBranch} -m "feat(M001/S01): First"`, wt.path);
|
||||
run(`git branch -d ${sliceBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
|
||||
assertTrue(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
|
||||
|
||||
|
|
@ -144,36 +133,19 @@ async function main(): Promise<void> {
|
|||
// ── Second slice in same worktree ──────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Second slice in worktree ===");
|
||||
const created2 = ensureSliceBranch(wt.path, "M001", "S02");
|
||||
assertTrue(created2, "S02 branch created");
|
||||
const sliceBranch2 = getSliceBranchName("M001", "S02", "alpha");
|
||||
run(`git checkout -b ${sliceBranch2}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
|
||||
|
||||
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
|
||||
run("git add .", wt.path);
|
||||
run('git commit -m "feat: add feature 2"', wt.path);
|
||||
|
||||
switchToMain(wt.path);
|
||||
const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
|
||||
assertEq(merge2.branch, "gsd/alpha/M001/S02", "S02 merge correct");
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
run(`git merge --no-ff ${sliceBranch2} -m "feat(M001/S02): Second"`, wt.path);
|
||||
run(`git branch -d ${sliceBranch2}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
|
||||
// ── Main tree can still do its own slice work independently ────────────────
|
||||
|
||||
console.log("\n=== Main tree independent slice work ===");
|
||||
assertEq(getCurrentBranch(base), "main", "main tree still on main");
|
||||
const mainCreated = ensureSliceBranch(base, "M001", "S01");
|
||||
assertTrue(mainCreated, "main tree can create S01 branch (no conflict with worktree)");
|
||||
assertEq(getCurrentBranch(base), "gsd/M001/S01", "main tree on plain branch name");
|
||||
|
||||
writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
|
||||
run("git add .", base);
|
||||
run('git commit -m "feat: main work"', base);
|
||||
|
||||
switchToMain(base);
|
||||
assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");
|
||||
const mainMerge = mergeSliceToMain(base, "M001", "S01", "First");
|
||||
assertEq(mainMerge.branch, "gsd/M001/S01", "main tree merge uses plain branch");
|
||||
|
||||
// ── Parallel worktrees don't conflict ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== Parallel worktrees ===");
|
||||
|
|
@ -181,13 +153,13 @@ async function main(): Promise<void> {
|
|||
assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
|
||||
|
||||
// Both worktrees can create S01 branches without conflict
|
||||
const betaCreated = ensureSliceBranch(wt2.path, "M001", "S01");
|
||||
assertTrue(betaCreated, "beta worktree can create S01");
|
||||
const betaBranch = getSliceBranchName("M001", "S01", "beta");
|
||||
run(`git checkout -b ${betaBranch}`, wt2.path);
|
||||
assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
|
||||
|
||||
// Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
|
||||
const alphaReCreated = ensureSliceBranch(wt.path, "M001", "S01");
|
||||
assertTrue(alphaReCreated, "alpha worktree can re-create S01");
|
||||
const alphaReBranch = getSliceBranchName("M001", "S01", "alpha");
|
||||
run(`git checkout -b ${alphaReBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
|
||||
|
||||
// Both exist simultaneously
|
||||
|
|
@ -199,7 +171,7 @@ async function main(): Promise<void> {
|
|||
|
||||
console.log("\n=== State derivation in worktree ===");
|
||||
// Switch alpha back to its base so deriveState sees milestone files
|
||||
switchToMain(wt.path);
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
const state = await deriveState(wt.path);
|
||||
assertTrue(state.activeMilestone !== null, "worktree has active milestone");
|
||||
assertEq(state.activeMilestone?.id, "M001", "correct milestone");
|
||||
|
|
@ -207,7 +179,8 @@ async function main(): Promise<void> {
|
|||
// ── autoCommitCurrentBranch in worktree ────────────────────────────────────
|
||||
|
||||
console.log("\n=== autoCommitCurrentBranch in worktree ===");
|
||||
ensureSliceBranch(wt2.path, "M001", "S01"); // re-checkout if needed
|
||||
// Re-checkout the beta slice branch
|
||||
run(`git checkout ${betaBranch}`, wt2.path);
|
||||
writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
|
||||
const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
|
||||
assertTrue(commitMsg !== null, "auto-commit works in worktree");
|
||||
|
|
@ -217,8 +190,8 @@ async function main(): Promise<void> {
|
|||
|
||||
console.log("\n=== Cleanup ===");
|
||||
// Switch worktrees back to their base branches before removal
|
||||
switchToMain(wt.path);
|
||||
switchToMain(wt2.path);
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
run("git checkout worktree/beta", wt2.path);
|
||||
removeWorktree(base, "alpha", { deleteBranch: true });
|
||||
removeWorktree(base, "beta", { deleteBranch: true });
|
||||
assertEq(listWorktrees(base).length, 0, "all worktrees removed");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
|
@ -7,21 +7,14 @@ import {
|
|||
autoCommitCurrentBranch,
|
||||
captureIntegrationBranch,
|
||||
detectWorktreeName,
|
||||
ensureSliceBranch,
|
||||
getActiveSliceBranch,
|
||||
getCurrentBranch,
|
||||
getMainBranch,
|
||||
getSliceBranchName,
|
||||
isOnSliceBranch,
|
||||
mergeSliceToMain,
|
||||
parseSliceBranch,
|
||||
setActiveMilestoneId,
|
||||
SLICE_BRANCH_RE,
|
||||
switchToMain,
|
||||
} from "../worktree.ts";
|
||||
import { readIntegrationBranch } from "../git-service.ts";
|
||||
import { deriveState } from "../state.ts";
|
||||
import { indexWorkspace } from "../workspace-index.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
|
@ -41,27 +34,6 @@ run("git add .", base);
|
|||
run('git commit -m "chore: init"', base);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("\n=== ensureSliceBranch ===");
|
||||
const created = ensureSliceBranch(base, "M001", "S01");
|
||||
assertTrue(created, "branch created on first ensure");
|
||||
assertEq(getCurrentBranch(base), "gsd/M001/S01", "switched to slice branch");
|
||||
|
||||
console.log("\n=== idempotent ensure ===");
|
||||
const secondCreate = ensureSliceBranch(base, "M001", "S01");
|
||||
assertEq(secondCreate, false, "branch not recreated on second ensure");
|
||||
assertEq(getCurrentBranch(base), "gsd/M001/S01", "still on slice branch");
|
||||
|
||||
console.log("\n=== getActiveSliceBranch ===");
|
||||
assertEq(getActiveSliceBranch(base), "gsd/M001/S01", "getActiveSliceBranch returns current slice branch");
|
||||
|
||||
console.log("\n=== state surfaces active branch ===");
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeBranch, "gsd/M001/S01", "state exposes active branch");
|
||||
|
||||
console.log("\n=== workspace index surfaces branch ===");
|
||||
const index = await indexWorkspace(base);
|
||||
const slice = index.milestones[0]?.slices[0];
|
||||
assertEq(slice?.branch, "gsd/M001/S01", "workspace index exposes branch");
|
||||
|
||||
console.log("\n=== autoCommitCurrentBranch ===");
|
||||
// Clean — should return null
|
||||
|
|
@ -75,56 +47,6 @@ async function main(): Promise<void> {
|
|||
assertTrue(dirtyResult!.includes("M001/S01/T01"), "commit message includes unit id");
|
||||
assertEq(run("git status --short", base), "", "repo is clean after auto-commit");
|
||||
|
||||
console.log("\n=== switchToMain ===");
|
||||
switchToMain(base);
|
||||
assertEq(getCurrentBranch(base), "main", "switched back to main");
|
||||
assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main");
|
||||
|
||||
console.log("\n=== mergeSliceToMain ===");
|
||||
// Switch back to slice, make a change, switch to main, merge
|
||||
ensureSliceBranch(base, "M001", "S01");
|
||||
writeFileSync(join(base, "README.md"), "hello from slice\n", "utf-8");
|
||||
run("git add README.md", base);
|
||||
run('git commit -m "feat: slice change"', base);
|
||||
switchToMain(base);
|
||||
|
||||
const merge = mergeSliceToMain(base, "M001", "S01", "Slice One");
|
||||
assertEq(merge.branch, "gsd/M001/S01", "merge reports branch");
|
||||
assertEq(getCurrentBranch(base), "main", "still on main after merge");
|
||||
assertTrue(readFileSync(join(base, "README.md"), "utf-8").includes("slice"), "main got squashed content");
|
||||
assertTrue(merge.deletedBranch, "branch was deleted");
|
||||
|
||||
// Verify branch is actually gone
|
||||
const branches = run("git branch", base);
|
||||
assertTrue(!branches.includes("gsd/M001/S01"), "slice branch no longer exists");
|
||||
|
||||
console.log("\n=== switchToMain auto-commits dirty files ===");
|
||||
// Set up S02
|
||||
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
|
||||
"# M001: Demo", "", "## Slices",
|
||||
"- [x] **S01: Slice One** `risk:low` `depends:[]`", " > Done",
|
||||
"- [ ] **S02: Slice Two** `risk:low` `depends:[]`", " > Demo 2",
|
||||
].join("\n") + "\n", "utf-8");
|
||||
run("git add .", base);
|
||||
run('git commit -m "chore: add S02"', base);
|
||||
|
||||
ensureSliceBranch(base, "M001", "S02");
|
||||
writeFileSync(join(base, "feature.txt"), "new feature\n", "utf-8");
|
||||
// Don't commit — switchToMain should auto-commit
|
||||
switchToMain(base);
|
||||
assertEq(getCurrentBranch(base), "main", "switched to main despite dirty files");
|
||||
|
||||
// Verify the commit happened on the slice branch
|
||||
ensureSliceBranch(base, "M001", "S02");
|
||||
assertTrue(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "dirty file was committed on slice branch");
|
||||
switchToMain(base);
|
||||
|
||||
// Now merge S02
|
||||
const mergeS02 = mergeSliceToMain(base, "M001", "S02", "Slice Two");
|
||||
assertTrue(readFileSync(join(base, "feature.txt"), "utf-8").includes("new feature"), "main got feature from auto-committed branch");
|
||||
assertEq(mergeS02.deletedBranch, true, "S02 branch deleted");
|
||||
|
||||
console.log("\n=== getSliceBranchName ===");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct");
|
||||
assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch");
|
||||
|
|
@ -161,90 +83,8 @@ async function main(): Promise<void> {
|
|||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name");
|
||||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir");
|
||||
|
||||
// ── Regression: slice branch from non-main working branch ───────────
|
||||
// Reproduces the bug where planning artifacts committed to a working
|
||||
// branch (e.g. "developer") are lost when the slice branch is created
|
||||
// from "main" which doesn't have them.
|
||||
console.log("\n=== ensureSliceBranch from non-main working branch ===");
|
||||
const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-"));
|
||||
run("git init -b main", base2);
|
||||
run('git config user.name "Pi Test"', base2);
|
||||
run('git config user.email "pi@example.com"', base2);
|
||||
writeFileSync(join(base2, "README.md"), "hello\n", "utf-8");
|
||||
run("git add .", base2);
|
||||
run('git commit -m "chore: init"', base2);
|
||||
|
||||
// Create a "developer" branch with planning artifacts (like the real scenario)
|
||||
run("git checkout -b developer", base2);
|
||||
mkdirSync(join(base2, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md"), "# M001 Context\nGoal: fix eslint\n", "utf-8");
|
||||
writeFileSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
|
||||
"# M001: ESLint Cleanup", "", "## Slices",
|
||||
"- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config",
|
||||
].join("\n") + "\n", "utf-8");
|
||||
run("git add .", base2);
|
||||
run('git commit -m "docs(M001): context and roadmap"', base2);
|
||||
|
||||
// Verify main does NOT have the artifacts
|
||||
const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2);
|
||||
assertTrue(mainRoadmap.includes("MISSING") || mainRoadmap.includes("does not exist"), "main branch lacks roadmap");
|
||||
|
||||
// Now create slice branch from developer — should inherit artifacts
|
||||
assertEq(getCurrentBranch(base2), "developer", "on developer branch before ensure");
|
||||
const created3 = ensureSliceBranch(base2, "M001", "S01");
|
||||
assertTrue(created3, "slice branch created from developer");
|
||||
assertEq(getCurrentBranch(base2), "gsd/M001/S01", "switched to slice branch");
|
||||
|
||||
// The critical assertion: planning artifacts must exist on the slice branch
|
||||
assertTrue(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-ROADMAP.md")), "roadmap exists on slice branch");
|
||||
assertTrue(existsSync(join(base2, ".gsd", "milestones", "M001", "M001-CONTEXT.md")), "context exists on slice branch");
|
||||
|
||||
// Verify deriveState sees the correct phase (not pre-planning)
|
||||
const state2 = await deriveState(base2);
|
||||
assertEq(state2.phase, "planning", "deriveState sees planning phase on slice branch");
|
||||
assertTrue(state2.activeSlice !== null, "active slice found");
|
||||
assertEq(state2.activeSlice!.id, "S01", "active slice is S01");
|
||||
|
||||
rmSync(base2, { recursive: true, force: true });
|
||||
|
||||
// ── Slice branch from another slice branch falls back to main ───────
|
||||
console.log("\n=== ensureSliceBranch from slice branch falls back to main ===");
|
||||
const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-"));
|
||||
run("git init -b main", base3);
|
||||
run('git config user.name "Pi Test"', base3);
|
||||
run('git config user.email "pi@example.com"', base3);
|
||||
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
||||
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
|
||||
writeFileSync(join(base3, "README.md"), "hello\n", "utf-8");
|
||||
writeFileSync(join(base3, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [
|
||||
"# M001: Demo", "", "## Slices",
|
||||
"- [ ] **S01: First** `risk:low` `depends:[]`", " > first",
|
||||
"- [ ] **S02: Second** `risk:low` `depends:[]`", " > second",
|
||||
].join("\n") + "\n", "utf-8");
|
||||
run("git add .", base3);
|
||||
run('git commit -m "chore: init"', base3);
|
||||
|
||||
ensureSliceBranch(base3, "M001", "S01");
|
||||
assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch");
|
||||
|
||||
// Creating S02 while on S01 should NOT chain from S01 — should use main
|
||||
const created4 = ensureSliceBranch(base3, "M001", "S02");
|
||||
assertTrue(created4, "S02 branch created");
|
||||
assertEq(getCurrentBranch(base3), "gsd/M001/S02", "switched to S02");
|
||||
|
||||
// S02 should be based on main, not on gsd/M001/S01
|
||||
const s02Base = run("git merge-base main gsd/M001/S02", base3);
|
||||
const mainHead = run("git rev-parse main", base3);
|
||||
assertEq(s02Base, mainHead, "S02 is based on main, not on S01 slice branch");
|
||||
|
||||
rmSync(base3, { recursive: true, force: true });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Integration branch — facade-level tests
|
||||
//
|
||||
// These exercise the same codepath auto.ts uses:
|
||||
// captureIntegrationBranch() → setActiveMilestoneId() → getMainBranch()
|
||||
// → switchToMain() → mergeSliceToMain()
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── captureIntegrationBranch on a feature branch ──────────────────────
|
||||
|
|
@ -273,43 +113,6 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── captureIntegrationBranch is idempotent on same lineage ──────────
|
||||
|
||||
console.log("\n=== captureIntegrationBranch: idempotent ===");
|
||||
|
||||
{
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-idem-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.name 'Pi Test'", repo);
|
||||
run("git config user.email 'pi@example.com'", repo);
|
||||
writeFileSync(join(repo, "README.md"), "init\n");
|
||||
run("git add -A && git commit -m init", repo);
|
||||
run("git checkout -b f-first", repo);
|
||||
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
|
||||
"first capture records f-first");
|
||||
|
||||
// Capture again on the same branch (simulates restart/resume) — should NOT overwrite
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
|
||||
"second capture on same branch does not overwrite");
|
||||
|
||||
// After creating a slice branch (which inherits the metadata commit),
|
||||
// capture should still be idempotent
|
||||
ensureSliceBranch(repo, "M001", "S01");
|
||||
// Now on gsd/M001/S01 — capture should be no-op (slice branch rejected)
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
switchToMain(repo);
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "f-first",
|
||||
"capture from slice branch is no-op, original preserved");
|
||||
assertEq(getCurrentBranch(repo), "f-first",
|
||||
"switchToMain returns to feature branch, confirming integration branch works");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── captureIntegrationBranch skips slice branches ─────────────────────
|
||||
|
||||
console.log("\n=== captureIntegrationBranch: skips slice branches ===");
|
||||
|
|
@ -359,234 +162,6 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Full multi-slice lifecycle on a feature branch ────────────────────
|
||||
//
|
||||
// Simulates what auto.ts does: start on feature branch, capture it,
|
||||
// create S01, work, merge S01 back to feature branch, then S02 branches
|
||||
// from feature branch (not main), works, merges to feature branch.
|
||||
// Main stays untouched throughout.
|
||||
|
||||
console.log("\n=== Multi-slice lifecycle on feature branch ===");
|
||||
|
||||
{
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-multi-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.name 'Pi Test'", repo);
|
||||
run("git config user.email 'pi@example.com'", repo);
|
||||
writeFileSync(join(repo, "README.md"), "base\n");
|
||||
run("git add -A && git commit -m init", repo);
|
||||
|
||||
// User creates feature branch
|
||||
run("git checkout -b feature/big-change", repo);
|
||||
writeFileSync(join(repo, "setup.txt"), "feature setup\n");
|
||||
run('git add -A && git commit -m "feat: initial setup"', repo);
|
||||
|
||||
// auto.ts startup: capture + set milestone
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
|
||||
assertEq(getMainBranch(repo), "feature/big-change",
|
||||
"multi: getMainBranch returns feature branch");
|
||||
|
||||
// ── S01 lifecycle ──────────────────────────────────────────────────
|
||||
ensureSliceBranch(repo, "M001", "S01");
|
||||
assertEq(getCurrentBranch(repo), "gsd/M001/S01", "multi: on S01");
|
||||
|
||||
// Verify S01 has feature branch content
|
||||
assertTrue(existsSync(join(repo, "setup.txt")),
|
||||
"multi: S01 inherited feature branch content");
|
||||
|
||||
writeFileSync(join(repo, "s01-work.txt"), "s01 output\n");
|
||||
run('git add -A && git commit -m "feat(S01): work"', repo);
|
||||
|
||||
switchToMain(repo);
|
||||
assertEq(getCurrentBranch(repo), "feature/big-change",
|
||||
"multi: switchToMain goes to feature branch");
|
||||
|
||||
const s01merge = mergeSliceToMain(repo, "M001", "S01", "First slice");
|
||||
assertEq(getCurrentBranch(repo), "feature/big-change",
|
||||
"multi: after S01 merge, on feature branch");
|
||||
assertTrue(existsSync(join(repo, "s01-work.txt")),
|
||||
"multi: S01 work merged to feature branch");
|
||||
assertTrue(s01merge.deletedBranch, "multi: S01 branch deleted");
|
||||
|
||||
// Main should NOT have S01 work
|
||||
run("git stash", repo); // stash any .gsd changes
|
||||
run("git checkout main", repo);
|
||||
assertTrue(!existsSync(join(repo, "s01-work.txt")),
|
||||
"multi: main does NOT have S01 work");
|
||||
run("git checkout feature/big-change", repo);
|
||||
run("git stash pop || true", repo);
|
||||
|
||||
// ── S02 lifecycle ──────────────────────────────────────────────────
|
||||
// S02 should branch from feature/big-change which now has S01's work
|
||||
ensureSliceBranch(repo, "M001", "S02");
|
||||
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "multi: on S02");
|
||||
|
||||
// S02 should have S01's merged output (branched from feature branch)
|
||||
assertTrue(existsSync(join(repo, "s01-work.txt")),
|
||||
"multi: S02 has S01 output (inherited via feature branch)");
|
||||
|
||||
writeFileSync(join(repo, "s02-work.txt"), "s02 output\n");
|
||||
run('git add -A && git commit -m "feat(S02): work"', repo);
|
||||
|
||||
switchToMain(repo);
|
||||
assertEq(getCurrentBranch(repo), "feature/big-change",
|
||||
"multi: switchToMain goes to feature branch after S02");
|
||||
|
||||
const s02merge = mergeSliceToMain(repo, "M001", "S02", "Second slice");
|
||||
assertEq(getCurrentBranch(repo), "feature/big-change",
|
||||
"multi: after S02 merge, on feature branch");
|
||||
assertTrue(existsSync(join(repo, "s02-work.txt")),
|
||||
"multi: S02 work merged to feature branch");
|
||||
assertTrue(existsSync(join(repo, "s01-work.txt")),
|
||||
"multi: S01 work still on feature branch after S02 merge");
|
||||
assertTrue(s02merge.deletedBranch, "multi: S02 branch deleted");
|
||||
|
||||
// Final check: main still untouched
|
||||
run("git stash", repo);
|
||||
run("git checkout main", repo);
|
||||
assertTrue(!existsSync(join(repo, "s01-work.txt")),
|
||||
"multi: main still lacks S01 work at end");
|
||||
assertTrue(!existsSync(join(repo, "s02-work.txt")),
|
||||
"multi: main still lacks S02 work at end");
|
||||
assertEq(readFileSync(join(repo, "README.md"), "utf-8").trim(), "base",
|
||||
"multi: main README unchanged");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Resume scenario: milestone ID re-set after restart ────────────────
|
||||
//
|
||||
// Simulates crash + restart: the cached GitServiceImpl is lost, but the
|
||||
// metadata file persists on disk. Re-calling setActiveMilestoneId should
|
||||
// restore integration branch resolution.
|
||||
|
||||
console.log("\n=== Resume: milestone ID re-set restores integration branch ===");
|
||||
|
||||
{
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-resume-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.name 'Pi Test'", repo);
|
||||
run("git config user.email 'pi@example.com'", repo);
|
||||
writeFileSync(join(repo, "README.md"), "init\n");
|
||||
run("git add -A && git commit -m init", repo);
|
||||
|
||||
run("git checkout -b my-feature", repo);
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
|
||||
// Create a slice and do some work
|
||||
ensureSliceBranch(repo, "M001", "S01");
|
||||
writeFileSync(join(repo, "work.txt"), "wip\n");
|
||||
run('git add -A && git commit -m "wip"', repo);
|
||||
|
||||
// Simulate "restart" — clear milestone ID (fresh service instance)
|
||||
setActiveMilestoneId(repo, null);
|
||||
assertEq(getMainBranch(repo), "main",
|
||||
"resume: getMainBranch returns main when milestone cleared");
|
||||
|
||||
// Re-set milestone ID (what auto.ts does on resume)
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
assertEq(getMainBranch(repo), "my-feature",
|
||||
"resume: getMainBranch returns feature branch after re-set");
|
||||
|
||||
// Full lifecycle still works after resume
|
||||
switchToMain(repo);
|
||||
assertEq(getCurrentBranch(repo), "my-feature",
|
||||
"resume: switchToMain goes to feature branch after re-set");
|
||||
|
||||
const result = mergeSliceToMain(repo, "M001", "S01", "Resume slice");
|
||||
assertEq(getCurrentBranch(repo), "my-feature",
|
||||
"resume: merge lands on feature branch after re-set");
|
||||
assertTrue(existsSync(join(repo, "work.txt")),
|
||||
"resume: merged work exists on feature branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Backward compat: no metadata file, plain main workflow ────────────
|
||||
//
|
||||
// Simulates existing projects that were created before this feature.
|
||||
// No metadata file exists, milestone ID is set — getMainBranch should
|
||||
// still return "main" and the entire slice lifecycle works unchanged.
|
||||
|
||||
console.log("\n=== Backward compat: no metadata, main workflow ===");
|
||||
|
||||
{
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-compat-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.name 'Pi Test'", repo);
|
||||
run("git config user.email 'pi@example.com'", repo);
|
||||
writeFileSync(join(repo, "README.md"), "init\n");
|
||||
run("git add -A && git commit -m init", repo);
|
||||
|
||||
// Set milestone but DON'T capture integration branch (simulates old project)
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
|
||||
assertEq(getMainBranch(repo), "main",
|
||||
"compat: getMainBranch returns main without metadata");
|
||||
|
||||
// Full lifecycle on main still works
|
||||
ensureSliceBranch(repo, "M001", "S01");
|
||||
writeFileSync(join(repo, "feature.txt"), "new\n");
|
||||
run('git add -A && git commit -m "feat: work"', repo);
|
||||
|
||||
switchToMain(repo);
|
||||
assertEq(getCurrentBranch(repo), "main",
|
||||
"compat: switchToMain goes to main");
|
||||
|
||||
const result = mergeSliceToMain(repo, "M001", "S01", "Compat slice");
|
||||
assertEq(getCurrentBranch(repo), "main",
|
||||
"compat: merge lands on main");
|
||||
assertTrue(existsSync(join(repo, "feature.txt")),
|
||||
"compat: merged work exists on main");
|
||||
assertTrue(result.deletedBranch, "compat: branch deleted");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── ensureSliceBranch from another slice with integration branch ──────
|
||||
//
|
||||
// When on gsd/M001/S01 and creating S02, the code falls back to
|
||||
// getMainBranch() (not the current slice). With integration branch set,
|
||||
// S02 should branch from the feature branch.
|
||||
|
||||
console.log("\n=== ensureSliceBranch: S02 from S01 uses integration branch as base ===");
|
||||
|
||||
{
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-integ-chain-"));
|
||||
run("git init -b main", repo);
|
||||
run("git config user.name 'Pi Test'", repo);
|
||||
run("git config user.email 'pi@example.com'", repo);
|
||||
writeFileSync(join(repo, "README.md"), "init\n");
|
||||
run("git add -A && git commit -m init", repo);
|
||||
|
||||
run("git checkout -b dev-branch", repo);
|
||||
writeFileSync(join(repo, "dev-only.txt"), "from dev\n");
|
||||
run('git add -A && git commit -m "dev setup"', repo);
|
||||
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
|
||||
// Create S01 (from dev-branch)
|
||||
ensureSliceBranch(repo, "M001", "S01");
|
||||
writeFileSync(join(repo, "s01.txt"), "s01\n");
|
||||
run('git add -A && git commit -m "s01 work"', repo);
|
||||
|
||||
// While on S01, create S02 — should fall back to integration branch
|
||||
ensureSliceBranch(repo, "M001", "S02");
|
||||
assertEq(getCurrentBranch(repo), "gsd/M001/S02", "chain: on S02");
|
||||
|
||||
// S02 should be based on dev-branch (the integration branch)
|
||||
assertTrue(existsSync(join(repo, "dev-only.txt")),
|
||||
"chain: S02 has dev-branch content");
|
||||
assertTrue(!existsSync(join(repo, "s01.txt")),
|
||||
"chain: S02 does NOT have S01 content (not chained from S01)");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
report();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@ export interface GSDState {
|
|||
recentDecisions: string[];
|
||||
blockers: string[];
|
||||
nextAction: string;
|
||||
activeBranch?: string;
|
||||
activeWorkspace?: string;
|
||||
registry: MilestoneRegistryEntry[];
|
||||
requirements?: RequirementCounts;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
/**
|
||||
* GSD Slice Branch Management — Thin Facade
|
||||
* GSD Worktree Utilities
|
||||
*
|
||||
* Simple branch-per-slice workflow. No worktrees, no registry.
|
||||
* Runtime state (metrics, activity, lock, STATE.md) is gitignored
|
||||
* so branch switches are clean.
|
||||
* Pure utility functions for worktree name detection, legacy branch name
|
||||
* parsing, and integration branch capture.
|
||||
*
|
||||
* All git-mutation functions delegate to GitServiceImpl from git-service.ts.
|
||||
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
|
||||
* SLICE_BRANCH_RE) remain standalone.
|
||||
* SLICE_BRANCH_RE) remain standalone for backwards compatibility.
|
||||
*
|
||||
* Flow:
|
||||
* 1. ensureSliceBranch() — create + checkout slice branch
|
||||
* 2. agent does work, commits
|
||||
* 3. mergeSliceToMain() — checkout integration branch, squash-merge, delete slice branch
|
||||
* Branchless architecture: all work commits sequentially on the milestone branch.
|
||||
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
|
||||
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
|
||||
*/
|
||||
|
||||
import { sep } from "node:path";
|
||||
|
|
@ -20,8 +17,6 @@ import { sep } from "node:path";
|
|||
import { GitServiceImpl, writeIntegrationBranch } from "./git-service.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
|
||||
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
|
||||
export type { MergeSliceResult } from "./git-service.js";
|
||||
export { MergeConflictError } from "./git-service.js";
|
||||
|
||||
// ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────
|
||||
|
|
@ -140,19 +135,6 @@ export function getCurrentBranch(basePath: string): string {
|
|||
return getService(basePath).getCurrentBranch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the slice branch exists and is checked out.
|
||||
* Creates the branch from the current branch if it's not a slice branch,
|
||||
* otherwise from main. This preserves planning artifacts (CONTEXT, ROADMAP,
|
||||
* etc.) that were committed on the working branch — which may differ from
|
||||
* the repo's default branch (e.g. `developer` vs `main`).
|
||||
* When inside a worktree, the branch is namespaced to avoid conflicts.
|
||||
* Returns true if the branch was newly created.
|
||||
*/
|
||||
export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
|
||||
return getService(basePath).ensureSliceBranch(milestoneId, sliceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-commit any dirty files in the current working tree.
|
||||
* Returns the commit message used, or null if already clean.
|
||||
|
|
@ -163,44 +145,4 @@ export function autoCommitCurrentBranch(
|
|||
return getService(basePath).autoCommit(unitType, unitId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the integration branch, auto-committing any dirty files on the current branch first.
|
||||
*/
|
||||
export function switchToMain(basePath: string): void {
|
||||
getService(basePath).switchToMain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash-merge a completed slice branch into the integration branch.
|
||||
* Expects to already be on the integration branch (call switchToMain first).
|
||||
* Deletes the slice branch after merge.
|
||||
*/
|
||||
export function mergeSliceToMain(
|
||||
basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,
|
||||
): import("./git-service.ts").MergeSliceResult {
|
||||
return getService(basePath).mergeSliceToMain(milestoneId, sliceId, sliceTitle);
|
||||
}
|
||||
|
||||
// ─── Query Functions (delegate to GitServiceImpl) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Check if we're currently on a slice branch (not main).
|
||||
* Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.
|
||||
*/
|
||||
export function isOnSliceBranch(basePath: string): boolean {
|
||||
const current = getCurrentBranch(basePath);
|
||||
return SLICE_BRANCH_RE.test(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active slice branch name, or null if on main.
|
||||
* Handles both plain and worktree-namespaced branch patterns.
|
||||
*/
|
||||
export function getActiveSliceBranch(basePath: string): string | null {
|
||||
try {
|
||||
const current = getCurrentBranch(basePath);
|
||||
return SLICE_BRANCH_RE.test(current) ? current : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue