Merge pull request #676 from adamdry/f-opt-out-of-worktrees

feat: add git.isolation "none" mode — no worktree, no milestone branch
This commit is contained in:
TÂCHES 2026-03-16 13:11:35 -06:00 committed by GitHub
commit 2f1abf7aae
17 changed files with 369 additions and 37 deletions

View file

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

View file

@ -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/<MID>` 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/<MID>/` on a `milestone/<MID>` 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/<MID>` 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.

View file

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

View file

@ -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/<MID>/` | `milestone/<MID>` | Most projects — full file isolation between milestones |
| `branch` | Project root | `milestone/<MID>` | 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/<MID>/` on a `milestone/<MID>` 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/<MID>` 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/<MID>/`
- **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.

View file

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

4
package-lock.json generated
View file

@ -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": [

View file

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

View file

@ -991,7 +991,7 @@ async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string,
const currentIsolation = git.isolation ? String(git.isolation) : "";
const isolationChoice = await ctx.ui.select(
`Git isolation strategy${currentIsolation ? ` (current: ${currentIsolation})` : " (default: worktree)"}:`,
["worktree", "branch", "(keep current)"],
["worktree", "branch", "none", "(keep current)"],
);
if (isolationChoice && isolationChoice !== "(keep current)") {
git.isolation = isolationChoice;

View file

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

View file

@ -474,6 +474,7 @@ async function checkGitHealth(
issues: DoctorIssue[],
fixesApplied: string[],
shouldFix: (code: DoctorIssueCode) => boolean,
isolationMode: "none" | "worktree" | "branch" = "worktree",
): Promise<void> {
// 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);

View file

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

View file

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

View file

@ -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/<MID>/`. This is a full git worktree on the `milestone/<MID>` 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/<MID>/`, a full git worktree on the `milestone/<MID>` 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/<MID>` 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

View file

@ -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<void> {
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 */ }

View file

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

View file

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

View file

@ -155,7 +155,7 @@ async function main(): Promise<void> {
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<void> {
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
{