diff --git a/.gitignore b/.gitignore index 82161d724..cdea9257c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/ADR-001-branchless-worktree-architecture.md b/docs/ADR-001-branchless-worktree-architecture.md new file mode 100644 index 000000000..478dade24 --- /dev/null +++ b/docs/ADR-001-branchless-worktree-architecture.md @@ -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/`). 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/`. 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/` +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 -- ` (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 :.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//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 diff --git a/docs/PRD-branchless-worktree-architecture.md b/docs/PRD-branchless-worktree-architecture.md new file mode 100644 index 000000000..4c511353c --- /dev/null +++ b/docs/PRD-branchless-worktree-architecture.md @@ -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/`. + +### 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/` +5. Build commit message with milestone summary + slice manifest +6. `git commit` +7. Optional: `git push` +8. `removeWorktree()` + `git branch -D milestone/` + +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//` 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. diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index e45ae0544..d06d25449 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 3dfe517a0..aef0fb752 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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, -): Promise { - // List all local gsd// 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 \``, - `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; } diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 16cfd4a58..ae03220c7 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -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 { diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts index 513f54b6f..305d01034 100644 --- a/src/resources/extensions/gsd/git-self-heal.ts +++ b/src/resources/extensions/gsd/git-self-heal.ts @@ -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(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 `. - * 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 }> = [ { diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 6db4694f0..9dd22eede 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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 ───────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 8626bc6af..008ce7dcd 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -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 diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 2f06c7154..1b3d9eabc 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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 { diff --git a/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts deleted file mode 100644 index 78e137628..000000000 --- a/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +++ /dev/null @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 034bbf118..af6e64e13 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -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 { diff --git a/src/resources/extensions/gsd/tests/git-self-heal.test.ts b/src/resources/extensions/gsd/tests/git-self-heal.test.ts index 6c6e2ecad..58bf81d59 100644 --- a/src/resources/extensions/gsd/tests/git-self-heal.test.ts +++ b/src/resources/extensions/gsd/tests/git-self-heal.test.ts @@ -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 ──"); diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 68ed0ec20..eaba65c7c 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -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 { 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 { ".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 { // 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 { 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 { 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 { 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 { 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 { 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 ==="); diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 4f63dcb99..8c52f2a3f 100644 --- a/src/resources/extensions/gsd/tests/idle-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/idle-recovery.test.ts @@ -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 diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index 7e16581eb..1cbdba021 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/isolation-resolver.test.ts b/src/resources/extensions/gsd/tests/isolation-resolver.test.ts deleted file mode 100644 index ff455ef38..000000000 --- a/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +++ /dev/null @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts b/src/resources/extensions/gsd/tests/orphaned-branch.test.ts deleted file mode 100644 index 31ef1a877..000000000 --- a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +++ /dev/null @@ -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(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); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts index 616cc9f9c..802a75f7c 100644 --- a/src/resources/extensions/gsd/tests/preferences-git.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-git.test.ts @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts index 6fab1e7e7..b621a43a4 100644 --- a/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-e2e.test.ts @@ -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 { @@ -144,59 +140,10 @@ async function main(): Promise { } // ================================================================ - // 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 { 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. // ================================================================ diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts index e2cf0cc1a..2d4bcdb4a 100644 --- a/src/resources/extensions/gsd/tests/worktree-integration.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -4,8 +4,6 @@ * Tests the full lifecycle of GSD operations inside a worktree: * - Branch namespacing (gsd/// instead of gsd//) * - getMainBranch returns worktree/ inside a worktree - * - switchToMain goes to worktree/, not main - * - mergeSliceToMain merges into worktree/ * - 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 { 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 { 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 { // ── 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 { 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 { 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 { // ── 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 { 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"); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 69de83435..f2ac55d9b 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -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 { - 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 { 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 { 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 { 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 { 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(); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 1a4e65d5c..a215657a5 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -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) ────────────────────────── /**