refactor(git): branchless worktree architecture (#506)

Eliminate slice branches — all work commits sequentially on milestone/<MID>
within auto-mode worktrees. No branch creation, switching, or merging
within a worktree. Planning artifacts (.gsd/milestones/) tracked in git
properly instead of being blanket-gitignored then force-added.

Removes ~2,600 lines: ensureSliceBranch, switchToMain, mergeSliceToMain,
mergeSliceToMilestone, shouldUseWorktreeIsolation, getMergeToMainMode,
withMergeHeal, recoverCheckout, fix-merge dispatch/labels, and associated
tests. Adds legacy_slice_branches doctor check, deprecation warnings for
git.isolation and git.merge_to_main preferences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-15 13:21:58 -06:00 committed by GitHub
parent 2d4a14b7ca
commit ed47018496
23 changed files with 879 additions and 3480 deletions

29
.gitignore vendored
View file

@ -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

View 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

View 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.

View file

@ -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)

View file

@ -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) {
@ -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);
@ -1230,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;
}
}
@ -1247,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();
}
}
@ -1271,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 "";
}
}
@ -1601,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");
@ -1612,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";
@ -1813,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");
@ -1902,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;
@ -3193,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 [
@ -3399,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 ──────────────────────────────────────────────────────────────
@ -3809,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;
}
@ -3833,14 +3518,6 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
// is managed by the hook engine, not the artifact verification system.
if (unitType.startsWith("hook/")) return true;
// 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;
}
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
@ -3948,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;
}

View file

@ -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> {

View file

@ -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 }> = [
{

View file

@ -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 ───────────────────────────────────────────
@ -190,10 +165,7 @@ 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, ["commit", "--no-verify", "-F", "-"], {
@ -332,20 +304,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 });
}
@ -470,98 +428,6 @@ export class GitServiceImpl {
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.
*
@ -644,253 +510,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 ─────────────────────────────────────────────────

View file

@ -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

View file

@ -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 {

View file

@ -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();

View file

@ -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> {

View file

@ -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 ──");

View file

@ -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 });
@ -591,486 +590,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 +602,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 +625,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 +722,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 +933,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 ===");

View file

@ -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

View file

@ -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 ──────────────────────────────────────────────────────────

View file

@ -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();

View file

@ -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);

View file

@ -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();

View file

@ -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.
// ================================================================

View file

@ -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,20 +17,15 @@ import {
createWorktree,
listWorktrees,
removeWorktree,
worktreePath,
worktreeBranchName,
} from "../worktree-manager.ts";
import {
detectWorktreeName,
ensureSliceBranch,
getActiveSliceBranch,
getCurrentBranch,
getMainBranch,
getSliceBranchName,
isOnSliceBranch,
mergeSliceToMain,
switchToMain,
getActiveSliceBranch,
autoCommitCurrentBranch,
} from "../worktree.ts";
@ -104,21 +97,21 @@ 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(isOnSliceBranch(wt.path), "isOnSliceBranch returns true");
assertEq(getActiveSliceBranch(wt.path), "gsd/alpha/M001/S01", "getActiveSliceBranch returns namespaced branch");
// ── Do work on slice branch, then merge to worktree branch ─────────────────
console.log("\n=== Work and merge slice in worktree ===");
@ -126,14 +119,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 +135,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 +155,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 +173,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 +181,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 +192,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");

View file

@ -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,16 @@ 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 +36,12 @@ 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 on main ===");
assertEq(getActiveSliceBranch(base), null, "getActiveSliceBranch returns null on main");
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=== isOnSliceBranch on main ===");
assertEq(isOnSliceBranch(base), false, "isOnSliceBranch returns false on main");
console.log("\n=== autoCommitCurrentBranch ===");
// Clean — should return null
@ -75,56 +55,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 +91,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 +121,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 +170,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();
}

View file

@ -9,10 +9,9 @@
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
* SLICE_BRANCH_RE) remain standalone.
*
* Flow:
* 1. ensureSliceBranch() create + checkout slice branch
* 2. agent does work, commits
* 3. mergeSliceToMain() checkout 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 +19,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 +137,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,24 +147,6 @@ 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) ──────────────────────────
/**