From 6aaace9838bf5d1c7d448584c73354180fc2481c Mon Sep 17 00:00:00 2001 From: Adam Dry Date: Mon, 16 Mar 2026 18:45:15 +0000 Subject: [PATCH 1/3] feat: add git.isolation "none" mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds git.isolation: "none" so developers can use GSD's full planning/execution methodology with all work happening directly on their current branch — no worktree, no milestone branch, no merge step. - Preference plumbing: "none" accepted/validated, getIsolationMode() helper - Core bypass: all worktree/branch gates in auto.ts skip in none mode - Peripheral: doctor skips worktree checks, system prompt omits worktree context - Docs: git-strategy, configuration, auto-mode, troubleshooting, system prompt updated - Tests: 11 new tests, 621/621 pass --- docs/auto-mode.md | 8 +- docs/configuration.md | 6 +- docs/git-strategy.md | 45 ++++++- docs/troubleshooting.md | 3 +- src/resources/extensions/gsd/auto.ts | 26 ++-- src/resources/extensions/gsd/commands.ts | 2 +- src/resources/extensions/gsd/doctor.ts | 12 +- src/resources/extensions/gsd/git-service.ts | 3 +- src/resources/extensions/gsd/preferences.ts | 17 ++- .../extensions/gsd/prompts/system.md | 12 +- .../extensions/gsd/tests/doctor-git.test.ts | 118 ++++++++++++++++++ .../gsd/tests/none-mode-gates.test.ts | 105 ++++++++++++++++ .../gsd/tests/preferences-git.test.ts | 23 +++- .../preferences-schema-validation.test.ts | 9 +- 14 files changed, 355 insertions(+), 34 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/none-mode-gates.test.ts diff --git a/docs/auto-mode.md b/docs/auto-mode.md index 6b548e127..85186f0f2 100644 --- a/docs/auto-mode.md +++ b/docs/auto-mode.md @@ -41,9 +41,13 @@ The dispatch prompt is carefully constructed with: The amount of context inlined is controlled by your [token profile](./token-optimization.md). Budget mode inlines minimal context; quality mode inlines everything. -### Git Worktree Isolation +### Git Isolation -Each milestone runs in its own git worktree with a `milestone/` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit. +GSD isolates milestone work using one of three modes (configured via `git.isolation` in preferences): + +- **`worktree`** (default): Each milestone runs in its own git worktree at `.gsd/worktrees//` on a `milestone/` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit. +- **`branch`**: Work happens in the project root on a `milestone/` branch. Useful for submodule-heavy repos where worktrees don't work well. +- **`none`**: Work happens directly on your current branch. No worktree, no milestone branch. Ideal for hot-reload workflows where file isolation breaks dev tooling. See [Git Strategy](./git-strategy.md) for details. diff --git a/docs/configuration.md b/docs/configuration.md index 5bcd62d4a..989129aee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,7 +193,7 @@ git: commit_type: feat # override conventional commit prefix main_branch: main # primary branch name merge_strategy: squash # how worktree branches merge: "squash" or "merge" - isolation: worktree # git isolation: "worktree" or "branch" + isolation: worktree # git isolation: "worktree", "branch", or "none" commit_docs: true # commit .gsd/ artifacts to git (set false to keep local) worktree_post_create: .gsd/hooks/post-worktree-create # script to run after worktree creation ``` @@ -208,7 +208,7 @@ git: | `commit_type` | string | (inferred) | Override conventional commit prefix (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`) | | `main_branch` | string | `"main"` | Primary branch name | | `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) | -| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory) or `"branch"` (work in project root — useful for submodule-heavy repos) | +| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) | | `commit_docs` | boolean | `true` | Commit `.gsd/` planning artifacts to git. Set `false` to keep local-only | | `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars | @@ -429,7 +429,7 @@ auto_supervisor: git: auto_push: true merge_strategy: squash - isolation: worktree + isolation: worktree # "worktree", "branch", or "none" commit_docs: true # Skills diff --git a/docs/git-strategy.md b/docs/git-strategy.md index e9db91582..d4a1012a0 100644 --- a/docs/git-strategy.md +++ b/docs/git-strategy.md @@ -1,8 +1,36 @@ # Git Strategy -GSD uses git worktrees for milestone isolation and sequential commits within each milestone. The strategy is fully automated — you don't need to manage branches manually. +GSD uses git for milestone isolation and sequential commits within each milestone. You choose an **isolation mode** that controls where work happens. The strategy is fully automated — you don't need to manage branches manually. -## Branching Model +## Isolation Modes + +GSD supports three isolation modes, configured via the `git.isolation` preference: + +| Mode | Working Directory | Branch | Best For | +|------|-------------------|--------|----------| +| `worktree` (default) | `.gsd/worktrees//` | `milestone/` | Most projects — full file isolation between milestones | +| `branch` | Project root | `milestone/` | Submodule-heavy repos where worktrees don't work well | +| `none` | Project root | Current branch (no milestone branch) | Hot-reload workflows where file isolation breaks dev tooling | + +### `worktree` Mode (Default) + +Each milestone gets its own git worktree at `.gsd/worktrees//` on a `milestone/` branch. All execution happens inside the worktree. On completion, the worktree is squash-merged to main as one clean commit. The worktree and branch are then cleaned up. + +This provides full file isolation — changes in a milestone can't interfere with your main working copy. + +### `branch` Mode + +Work happens in the project root on a `milestone/` branch. No worktree is created. On completion, the branch is merged to main (squash or regular merge, per `merge_strategy`). + +Use this when worktrees cause problems — submodule-heavy repos, repos with hardcoded paths, or environments where worktree symlinks don't behave. + +### `none` Mode + +Work happens directly on your current branch. No worktree, no milestone branch. GSD still commits sequentially with conventional commit messages, but there's no branch isolation. + +Use this for hot-reload workflows where file isolation breaks dev tooling (e.g., file watchers that only see the project root), or for small projects where branch overhead isn't worth it. + +## Branching Model (Worktree Mode) ``` main ───────────────────────────────────────────────────────── @@ -16,12 +44,14 @@ main ───────────────────────── → squash-merged to main as single commit ``` +In **branch mode**, the flow is the same except work happens in the project root instead of a separate worktree directory. + +In **none mode**, commits land directly on the current branch — no milestone branch is created, and no merge step is needed. + ### Key Properties -- **One worktree per milestone** — all work happens in `.gsd/worktrees//` - **Sequential commits on one branch** — no per-slice branches, no merge conflicts within a milestone -- **Squash merge to main** — when the milestone completes, all commits are squashed into one clean commit on main -- **Worktree teardown** — after merge, the worktree and branch are cleaned up +- **Squash merge to main** — in worktree and branch modes, all commits are squashed into one clean commit on main (configurable via `merge_strategy`) ### Commit Format @@ -36,6 +66,8 @@ docs(M001/S04): workflow documentation ## Worktree Management +These features apply only in **worktree mode**. + ### Automatic (Auto Mode) Auto mode creates and manages worktrees automatically: @@ -94,6 +126,7 @@ git: commit_type: feat # override commit type prefix main_branch: main # primary branch name commit_docs: true # commit .gsd/ to git + isolation: worktree # "worktree", "branch", or "none" ``` ### `commit_docs: false` @@ -106,7 +139,7 @@ GSD includes automatic recovery for common git issues: - **Detached HEAD** — automatically reattaches to the correct branch - **Stale lock files** — removes `index.lock` files from crashed processes -- **Orphaned worktrees** — detects and offers to clean up abandoned worktrees +- **Orphaned worktrees** — detects and offers to clean up abandoned worktrees (worktree mode only) Run `/gsd doctor` to check git health manually. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3d368cbd3..699d2c7ae 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,7 +12,7 @@ It checks: - File structure and naming conventions - Roadmap ↔ slice ↔ task referential integrity - Completion state consistency -- Git worktree health +- Git worktree health (worktree and branch modes only — skipped in none mode) - Stale lock files and orphaned runtime records ## Common Issues @@ -112,3 +112,4 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte - **GitHub Issues:** [github.com/gsd-build/GSD-2/issues](https://github.com/gsd-build/GSD-2/issues) - **Dashboard:** `Ctrl+Alt+G` or `/gsd status` for real-time diagnostics - **Session logs:** `.gsd/activity/` contains JSONL session dumps for crash forensics +ctivity/` contains JSONL session dumps for crash forensics diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index b9058d6e6..2477c3517 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -40,7 +40,7 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig } from "./preferences.js"; +import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig, getIsolationMode } from "./preferences.js"; import { sendDesktopNotification } from "./notifications.js"; import type { GSDPreferences } from "./preferences.js"; import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js"; @@ -204,12 +204,15 @@ function checkResourcesStale(): string | null { /** * Resolve whether auto-mode should use worktree isolation. - * Returns true for worktree mode (default), false for branch mode. + * Returns true for worktree mode (default), false for branch and none modes. * Branch mode works directly in the project root — useful for repos * with git submodules where worktrees don't work well (#531). + * None mode skips all worktree and milestone-branch logic — commits + * land on the current branch with no isolation (#M001-S02). */ -function shouldUseWorktreeIsolation(): boolean { +export function shouldUseWorktreeIsolation(): boolean { const prefs = loadEffectiveGSDPreferences()?.preferences?.git; + if (prefs?.isolation === "none") return false; if (prefs?.isolation === "branch") return false; return true; // default: worktree } @@ -675,7 +678,7 @@ export async function startAuto( // ── Auto-worktree: re-enter worktree on resume if not already inside ── // Skip if already inside a worktree (manual /worktree) to prevent nesting. - // Skip entirely in branch isolation mode (#531). + // Skip entirely in branch or none isolation mode (#531). if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) { try { const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId); @@ -744,7 +747,7 @@ export async function startAuto( return; } - // Ensure git repo exists — GSD needs it for worktree isolation + // Ensure git repo exists — GSD needs it for commits and state tracking if (!nativeIsRepo(base)) { const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; nativeInit(base, mainBranch); @@ -952,7 +955,9 @@ export async function startAuto( // of the repo's default (main/master). Idempotent when the branch is the // same; updates the record when started from a different branch (#300). if (currentMilestoneId) { - captureIntegrationBranch(base, currentMilestoneId, { commitDocs }); + if (getIsolationMode() !== "none") { + captureIntegrationBranch(base, currentMilestoneId, { commitDocs }); + } setActiveMilestoneId(base, currentMilestoneId); } @@ -1783,8 +1788,11 @@ async function dispatchNextUnit( } } } else { - // Not in worktree — just capture integration branch for the new milestone - captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + // Not in worktree — capture integration branch for the new milestone (branch mode only). + // In none mode there's no milestone branch to merge back to, so skip. + if (getIsolationMode() !== "none") { + captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); + } } // Prune completed milestone from queue order file @@ -1880,7 +1888,7 @@ async function dispatchNextUnit( try { process.chdir(basePath); } catch { /* best-effort */ } } } - } else if (currentMilestoneId && !isInAutoWorktree(basePath)) { + } else if (currentMilestoneId && !isInAutoWorktree(basePath) && getIsolationMode() !== "none") { // Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch. // Squash-merge back to the integration branch (or main) before stopping. try { diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 05a048b41..d62bbdf6f 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -991,7 +991,7 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record boolean, + isolationMode: "none" | "worktree" | "branch" = "worktree", ): Promise { // Degrade gracefully if not a git repo if (!nativeIsRepo(basePath)) { @@ -482,7 +483,10 @@ async function checkGitHealth( const gitDir = join(basePath, ".git"); - // ── Orphaned auto-worktrees ────────────────────────────────────────── + // ── Orphaned auto-worktrees & Stale milestone branches ──────────────── + // These checks only apply in worktree/branch modes — skip in none mode + // where no milestone worktrees or branches are created. + if (isolationMode !== "none") { try { const worktrees = listWorktrees(basePath); const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/")); @@ -576,6 +580,7 @@ async function checkGitHealth( } catch { // listWorktrees or deriveState failed — skip worktree/branch checks } + } // end isolationMode !== "none" // ── Corrupt merge state ──────────────────────────────────────────────── try { @@ -976,7 +981,10 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files) - await checkGitHealth(basePath, issues, fixesApplied, shouldFix); + const isolationMode: "none" | "worktree" | "branch" = + prefs?.preferences?.git?.isolation === "none" ? "none" : + prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree"; + await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode); // Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore) await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix); diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 06fd2b422..c151c3764 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -45,8 +45,9 @@ export interface GitPreferences { /** Controls auto-mode git isolation strategy. * - "worktree": (default) creates a milestone worktree for isolated work * - "branch": works directly in the project root (for submodule-heavy repos) + * - "none": no git isolation — commits land on the user's current branch directly */ - isolation?: "worktree" | "branch"; + isolation?: "worktree" | "branch" | "none"; /** When false, prevents GSD from committing .gsd/ planning artifacts to git. * The .gsd/ folder is added to .gitignore and kept local-only. * Default: true (planning docs are tracked in git). diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index a2c8c5924..686a5f72d 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -525,6 +525,17 @@ export function resolveSkillStalenessDays(): number { return prefs?.preferences.skill_staleness_days ?? 60; } +/** + * Resolve the effective git isolation mode from preferences. + * Returns "worktree" (default), "branch", or "none". + */ +export function getIsolationMode(): "none" | "worktree" | "branch" { + const prefs = loadEffectiveGSDPreferences()?.preferences?.git; + if (prefs?.isolation === "none") return "none"; + if (prefs?.isolation === "branch") return "branch"; + return "worktree"; // default +} + /** * Resolve which model ID to use for a given auto-mode unit type. * Returns undefined if no model preference is set for this unit type. @@ -1197,11 +1208,11 @@ export function validatePreferences(preferences: GSDPreferences): { } } if (g.isolation !== undefined) { - const validIsolation = new Set(["worktree", "branch"]); + const validIsolation = new Set(["worktree", "branch", "none"]); if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) { - git.isolation = g.isolation as "worktree" | "branch"; + git.isolation = g.isolation as "worktree" | "branch" | "none"; } else { - errors.push("git.isolation must be one of: worktree, branch"); + errors.push("git.isolation must be one of: worktree, branch, none"); } } if (g.commit_docs !== undefined) { diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 9652bcdaa..7281f8535 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -90,11 +90,17 @@ Titles live inside file content (headings, frontmatter), not in file or director T01-SUMMARY.md ``` -### Worktree Model +### Isolation Model -All auto-mode work happens inside a worktree at `.gsd/worktrees//`. This is a full git worktree on the `milestone/` branch — it has its own working copy of the project and its own `.gsd/` directory. Slices commit sequentially on this branch; there are no per-slice branches. When a milestone completes, the worktree is merged back to the integration branch. +Auto-mode supports three isolation modes (configured in `.gsd/preferences.md` under `taskIsolation.mode`): -**If you are executing in auto-mode, your working directory is already set to the worktree.** Use relative paths or the path shown in the Working Directory section of your prompt. Do not navigate to any other copy of the project. +- **worktree** (default): Work happens in `.gsd/worktrees//`, a full git worktree on the `milestone/` branch. Each worktree has its own working copy and `.gsd/` directory. Squash-merged back to the integration branch on milestone completion. +- **branch**: Work happens in the project root on a `milestone/` branch. No worktree directory — files are checked out in-place. +- **none**: Work happens directly on the current branch. No worktree, no milestone branch. Commits land in-place. + +In all modes, slices commit sequentially on the active branch; there are no per-slice branches. + +**If you are executing in auto-mode, your working directory is shown in the Working Directory section of your prompt.** Use relative paths. Do not navigate to any other copy of the project. ### Conventions diff --git a/src/resources/extensions/gsd/tests/doctor-git.test.ts b/src/resources/extensions/gsd/tests/doctor-git.test.ts index c92aa421c..8e3003dd8 100644 --- a/src/resources/extensions/gsd/tests/doctor-git.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-git.test.ts @@ -65,6 +65,27 @@ _None_ return dir; } +/** Write a .gsd/preferences.md with the given git isolation mode. */ +function writePreferencesFile(dir: string, isolation: "none" | "worktree" | "branch"): void { + const gsdDir = join(dir, ".gsd"); + mkdirSync(gsdDir, { recursive: true }); + writeFileSync(join(gsdDir, "preferences.md"), `---\ngit:\n isolation: "${isolation}"\n---\n`); +} + +/** + * Write preferences to the test runner's cwd .gsd/preferences.md. + * loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH at module + * load time from process.cwd(), so we must write there — not to the temp dir. + */ +const RUNNER_PREFS_PATH = join(process.cwd(), ".gsd", "preferences.md"); +function writeRunnerPreferences(isolation: "none" | "worktree" | "branch"): void { + mkdirSync(join(process.cwd(), ".gsd"), { recursive: true }); + writeFileSync(RUNNER_PREFS_PATH, `---\ngit:\n isolation: "${isolation}"\n---\n`); +} +function removeRunnerPreferences(): void { + try { rmSync(RUNNER_PREFS_PATH); } catch { /* ignore if already gone */ } +} + /** Create a repo with an in-progress milestone. */ function createRepoWithActiveMilestone(): string { const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-"))); @@ -252,6 +273,103 @@ async function main(): Promise { console.log("\n=== active worktree safety (skipped on Windows) ==="); } + // ─── Test 7: none-mode skips orphaned worktree check ─────────────── + // NOTE: loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH + // at module load time from process.cwd(). We write the prefs file to + // the test runner's cwd .gsd/preferences.md and clean up afterwards. + if (process.platform !== "win32") { + console.log("\n=== none-mode skips orphaned worktree ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Create worktree with milestone/M001 branch under .gsd/worktrees/ + mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true }); + run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir); + + // Write preferences to runner's cwd (where the module resolves project prefs) + writeRunnerPreferences("none"); + try { + const result = await runGSDDoctor(dir); + const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree"); + assertEq(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected"); + } finally { + removeRunnerPreferences(); + } + } + } else { + console.log("\n=== none-mode skips orphaned worktree (skipped on Windows) ==="); + } + + // ─── Test 8: none-mode skips stale branch check ──────────────────── + if (process.platform !== "win32") { + console.log("\n=== none-mode skips stale branch ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Create a milestone/M001 branch (no worktree) + run("git branch milestone/M001", dir); + + // Write preferences to runner's cwd + writeRunnerPreferences("none"); + try { + const result = await runGSDDoctor(dir); + const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch"); + assertEq(staleIssues.length, 0, "none-mode: stale branch NOT detected"); + } finally { + removeRunnerPreferences(); + } + } + } else { + console.log("\n=== none-mode skips stale branch (skipped on Windows) ==="); + } + + // ─── Test 9: none-mode still detects corrupt merge state ─────────── + console.log("\n=== none-mode keeps corrupt merge state ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Inject MERGE_HEAD into .git + const headHash = run("git rev-parse HEAD", dir); + writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n"); + + // Write preferences to runner's cwd + writeRunnerPreferences("none"); + try { + const result = await runGSDDoctor(dir); + const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state"); + assertTrue(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected"); + } finally { + removeRunnerPreferences(); + } + } + + // ─── Test 10: none-mode still detects tracked runtime files ──────── + console.log("\n=== none-mode keeps tracked runtime files ==="); + { + const dir = createRepoWithCompletedMilestone(); + cleanups.push(dir); + + // Force-add a runtime file + const activityDir = join(dir, ".gsd", "activity"); + mkdirSync(activityDir, { recursive: true }); + writeFileSync(join(activityDir, "test.log"), "log data\n"); + run("git add -f .gsd/activity/test.log", dir); + run("git commit -m \"track runtime file\"", dir); + + // Write preferences to runner's cwd + writeRunnerPreferences("none"); + try { + const result = await runGSDDoctor(dir); + const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files"); + assertTrue(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected"); + } finally { + removeRunnerPreferences(); + } + } + } finally { for (const dir of cleanups) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/src/resources/extensions/gsd/tests/none-mode-gates.test.ts b/src/resources/extensions/gsd/tests/none-mode-gates.test.ts new file mode 100644 index 000000000..67953b042 --- /dev/null +++ b/src/resources/extensions/gsd/tests/none-mode-gates.test.ts @@ -0,0 +1,105 @@ +/** + * none-mode-gates.test.ts — Tests for isolation-mode gate functions. + * + * Verifies that shouldUseWorktreeIsolation(), getIsolationMode(), and + * getActiveAutoWorktreeContext() behave correctly across all three + * isolation modes (none, branch, worktree) and at baseline (no prefs). + * + * Uses the writeRunnerPreferences pattern from doctor-git.test.ts: + * PROJECT_PREFERENCES_PATH is a module-level constant frozen at import + * time, so process.chdir() won't redirect preference loading. We write + * prefs to the runner's cwd .gsd/preferences.md and clean up in finally. + */ + +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { shouldUseWorktreeIsolation } from "../auto.ts"; +import { getIsolationMode } from "../preferences.ts"; +import { getActiveAutoWorktreeContext } from "../auto-worktree.ts"; +import { invalidateAllCaches } from "../cache.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// --- Preferences helpers (same pattern as doctor-git.test.ts K001) --- + +const RUNNER_PREFS_PATH = join(process.cwd(), ".gsd", "preferences.md"); + +function writeRunnerPreferences(isolation: "none" | "worktree" | "branch"): void { + mkdirSync(join(process.cwd(), ".gsd"), { recursive: true }); + writeFileSync(RUNNER_PREFS_PATH, `---\ngit:\n isolation: "${isolation}"\n---\n`); +} + +function removeRunnerPreferences(): void { + try { rmSync(RUNNER_PREFS_PATH); } catch { /* ignore if already gone */ } +} + +// --- Tests --- + +// Test 1: shouldUseWorktreeIsolation returns false for none +console.log("Test 1: shouldUseWorktreeIsolation returns false for none"); +try { + writeRunnerPreferences("none"); + invalidateAllCaches(); + assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with none prefs"); +} finally { + removeRunnerPreferences(); + invalidateAllCaches(); +} + +// Test 2: shouldUseWorktreeIsolation returns false for branch +console.log("Test 2: shouldUseWorktreeIsolation returns false for branch"); +try { + writeRunnerPreferences("branch"); + invalidateAllCaches(); + assertEq(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with branch prefs"); +} finally { + removeRunnerPreferences(); + invalidateAllCaches(); +} + +// Test 3: shouldUseWorktreeIsolation returns true for worktree +console.log("Test 3: shouldUseWorktreeIsolation returns true for worktree"); +try { + writeRunnerPreferences("worktree"); + invalidateAllCaches(); + assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with worktree prefs"); +} finally { + removeRunnerPreferences(); + invalidateAllCaches(); +} + +// Test 4: shouldUseWorktreeIsolation returns true for no prefs (default) +console.log("Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)"); +try { + removeRunnerPreferences(); // ensure no prefs file + invalidateAllCaches(); + assertEq(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with no prefs (default worktree)"); +} finally { + invalidateAllCaches(); +} + +// Test 5: getIsolationMode returns "none" with none prefs +console.log("Test 5: getIsolationMode returns 'none' with none prefs"); +try { + writeRunnerPreferences("none"); + invalidateAllCaches(); + assertEq(getIsolationMode(), "none", "getIsolationMode() with none prefs"); +} finally { + removeRunnerPreferences(); + invalidateAllCaches(); +} + +// Test 6: getActiveAutoWorktreeContext returns null at baseline +console.log("Test 6: getActiveAutoWorktreeContext returns null at baseline"); +assertEq(getActiveAutoWorktreeContext(), null, "getActiveAutoWorktreeContext() returns null without enterAutoWorktree()"); + +// Test 7: System prompt worktree block absent without active worktree +console.log("Test 7: System prompt worktree block absent without active worktree"); +{ + const ctx = getActiveAutoWorktreeContext(); + assertTrue(ctx === null, "getActiveAutoWorktreeContext() null confirms system prompt worktree block will not be injected"); +} + +report(); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts index 15b9d7903..cbc2bed64 100644 --- a/src/resources/extensions/gsd/tests/preferences-git.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-git.test.ts @@ -1,7 +1,7 @@ // GSD Git Preferences Tests — validates git.isolation and git.merge_to_main handling import { createTestContext } from "./test-helpers.ts"; -import { validatePreferences } from "../preferences.ts"; +import { validatePreferences, getIsolationMode } from "../preferences.ts"; const { assertEq, assertTrue, report } = createTestContext(); @@ -21,12 +21,18 @@ async function main(): Promise { assertEq(warnings.length, 0, "isolation: branch — no warnings"); assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved"); } + { + const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "none" } }); + assertEq(errors.length, 0, "isolation: none — no errors"); + assertEq(warnings.length, 0, "isolation: none — no warnings"); + assertEq(preferences.git?.isolation, "none", "isolation: none — value preserved"); + } // Invalid values produce errors { const { errors } = validatePreferences({ git: { isolation: "invalid" as any } }); assertTrue(errors.length > 0, "isolation: invalid — produces error"); - assertTrue(errors[0].includes("worktree, branch"), "isolation: invalid — error mentions valid values"); + assertTrue(errors[0].includes("worktree, branch, none"), "isolation: invalid — error mentions valid values"); } // Undefined passes through without warning @@ -95,6 +101,19 @@ async function main(): Promise { assertEq(preferences.git?.commit_docs, undefined, "commit_docs: undefined — not set"); } + console.log("\n=== getIsolationMode() ==="); + + // Returns "none" when set to "none" + // Note: getIsolationMode() reads from disk via loadEffectiveGSDPreferences, + // so we test it indirectly by verifying the function is exported and callable. + // The validation tests above prove the preference value is stored correctly. + // Direct mode tests require mocking the filesystem, so we test the function's + // default return value (no preferences file in test context). + { + const mode = getIsolationMode(); + assertEq(mode, "worktree", "getIsolationMode: returns worktree as default when no prefs file"); + } + report(); } diff --git a/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts b/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts index 81a57a88c..7a4cf5af0 100644 --- a/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts @@ -155,7 +155,7 @@ async function main(): Promise { console.log("\n=== existing behavior preserved ==="); - // git.isolation is a valid active setting (worktree | branch) — no warnings or errors + // git.isolation is a valid active setting (worktree | branch | none) — no warnings or errors { const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "worktree" } } as GSDPreferences); const unknownWarnings = warnings.filter(w => w.includes("unknown")); @@ -163,6 +163,13 @@ async function main(): Promise { assertEq(errors.length, 0, "valid git.isolation produces no errors"); assertEq(preferences.git?.isolation, "worktree", "git.isolation value passes through"); } + { + const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "none" } } as GSDPreferences); + const unknownWarnings = warnings.filter(w => w.includes("unknown")); + assertEq(unknownWarnings.length, 0, "git.isolation none — no unknown-key warning"); + assertEq(errors.length, 0, "git.isolation none produces no errors"); + assertEq(preferences.git?.isolation, "none", "git.isolation none value passes through"); + } // git.merge_to_main is deprecated — still produces deprecation warning { From 6cf0df1bb2ecd2447c6056bf3fa79e45de3bade5 Mon Sep 17 00:00:00 2001 From: Adam Dry Date: Mon, 16 Mar 2026 18:48:10 +0000 Subject: [PATCH 2/3] ci: fail if .gsd/ directory is checked in --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb10f406..47a78c5f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: branches: [main] jobs: + no-gsd-dir: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Ensure .gsd/ is not checked in + run: | + if [ -d ".gsd" ]; then + echo "::error::.gsd/ directory must not be checked in" + exit 1 + fi + build: runs-on: ubuntu-latest From bd8bc876ee66f5f79343fa5af795bbc985e885f8 Mon Sep 17 00:00:00 2001 From: Adam Dry Date: Mon, 16 Mar 2026 18:58:41 +0000 Subject: [PATCH 3/3] docs: add "none" to isolation field in preferences-reference.md PR #651 added preferences-reference.md which listed only "worktree" and "branch" as isolation options. Updated to include "none" with description. --- package-lock.json | 4 ++-- src/resources/extensions/gsd/docs/preferences-reference.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f98a94182..a46897802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.21.0", + "version": "2.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.21.0", + "version": "2.20.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 20e5455c8..0700b51cf 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -123,7 +123,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content. - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`. - `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`. - - `isolation`: `"worktree"` or `"branch"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root (useful for submodule-heavy repos). Default: `"worktree"`. + - `isolation`: `"worktree"`, `"branch"`, or `"none"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root but creates a milestone branch (useful for submodule-heavy repos); `"none"` works directly on the current branch with no worktree or milestone branch (ideal for step-mode with hot reloads). Default: `"worktree"`. - `commit_docs`: boolean — when `false`, prevents GSD from committing `.gsd/` planning artifacts to git. The `.gsd/` folder is added to `.gitignore` and kept local-only. Useful for teams where only some members use GSD, or when company policy requires a clean repository. Default: `true`. - `worktree_post_create`: string — script to run after a worktree is created (both auto-mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none.