fix(gsd): change default isolation mode from worktree to none (#2481)

When no preferences.md exists, getIsolationMode() and
shouldUseWorktreeIsolation() defaulted to "worktree", which requires
git branch infrastructure (milestone/<MID> branches) that isn't
automatically set up. This caused milestone-complete to fail with
"branch doesn't exist" when users worked directly on main without
configuring preferences.

Change the default to "none" (work on current branch) across all five
locations: getIsolationMode(), shouldUseWorktreeIsolation(),
MODE_DEFAULTS for solo/team, doctor.ts, and doctor-checks.ts.
Worktree isolation is now explicit opt-in via preferences.md.

Closes #2480
This commit is contained in:
Jeremy McSpadden 2026-03-25 09:44:08 -05:00 committed by GitHub
parent bf54012d1f
commit 7b162fe4ce
7 changed files with 64 additions and 23 deletions

View file

@ -250,9 +250,9 @@ const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
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
if (prefs?.isolation === "worktree") return true;
// Default is false — worktree isolation requires explicit opt-in
return false;
}
/** Crash recovery prompt — set by startAuto, consumed by the main loop */

View file

@ -25,7 +25,7 @@ export async function checkGitHealth(
issues: DoctorIssue[],
fixesApplied: string[],
shouldFix: (code: DoctorIssueCode) => boolean,
isolationMode: "none" | "worktree" | "branch" = "worktree",
isolationMode: "none" | "worktree" | "branch" = "none",
): Promise<void> {
// Degrade gracefully if not a git repo
if (!nativeIsRepo(basePath)) {

View file

@ -360,8 +360,8 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
// Git health checks — timed
const t0git = Date.now();
const isolationMode: "none" | "worktree" | "branch" = options?.isolationMode ??
(prefs?.preferences?.git?.isolation === "none" ? "none" :
prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree");
(prefs?.preferences?.git?.isolation === "worktree" ? "worktree" :
prefs?.preferences?.git?.isolation === "branch" ? "branch" : "none");
await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode);
const gitMs = Date.now() - t0git;

View file

@ -34,7 +34,7 @@ export const MODE_DEFAULTS: Record<WorkflowMode, Partial<GSDPreferences>> = {
push_branches: false,
pre_merge_check: false,
merge_strategy: "squash",
isolation: "worktree",
isolation: "none",
},
unique_milestone_ids: false,
},
@ -44,7 +44,7 @@ export const MODE_DEFAULTS: Record<WorkflowMode, Partial<GSDPreferences>> = {
push_branches: true,
pre_merge_check: true,
merge_strategy: "squash",
isolation: "worktree",
isolation: "none",
},
unique_milestone_ids: true,
},

View file

@ -497,13 +497,17 @@ export function resolvePreDispatchHooks(): PreDispatchHookConfig[] {
/**
* Resolve the effective git isolation mode from preferences.
* Returns "worktree" (default), "branch", or "none".
* Returns "none" (default), "worktree", or "branch".
*
* Default is "none" so GSD works out of the box without preferences.md.
* Worktree isolation requires explicit opt-in because it depends on git
* branch infrastructure that must be set up before use.
*/
export function getIsolationMode(): "none" | "worktree" | "branch" {
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
if (prefs?.isolation === "none") return "none";
if (prefs?.isolation === "worktree") return "worktree";
if (prefs?.isolation === "branch") return "branch";
return "worktree"; // default
return "none"; // default — no isolation, work on current branch
}
export function resolveParallelConfig(prefs: GSDPreferences | undefined): import("./types.js").ParallelConfig {

View file

@ -70,18 +70,20 @@ try {
}
});
// Test 4: shouldUseWorktreeIsolation returns true for no prefs (default)
// Test 4: shouldUseWorktreeIsolation returns false for no prefs (default: none)
// Worktree isolation requires explicit opt-in — default is "none" so GSD
// works out of the box without preferences.md (#2480).
// Skip if global prefs exist — they override the default and this test
// cannot control ~/.gsd/preferences.md.
test('shouldUseWorktreeIsolation returns true for no prefs (default)', () => {
test('shouldUseWorktreeIsolation returns false for no prefs (default: none)', () => {
const globalPrefsExist = existsSync(join(homedir(), ".gsd", "preferences.md"))
|| existsSync(join(homedir(), ".gsd", "PREFERENCES.md"));
if (!globalPrefsExist) {
try {
removeRunnerPreferences(); // ensure no prefs file
invalidateAllCaches();
assert.deepStrictEqual(shouldUseWorktreeIsolation(), true, "shouldUseWorktreeIsolation() with no prefs (default worktree)");
assert.deepStrictEqual(shouldUseWorktreeIsolation(), false, "shouldUseWorktreeIsolation() with no prefs (default none)");
} finally {
invalidateAllCaches();
}
@ -89,6 +91,21 @@ test('shouldUseWorktreeIsolation returns true for no prefs (default)', () => {
}
});
// Test 5: getIsolationMode returns "none" when no preferences.md exists (#2480)
test('getIsolationMode returns "none" with no prefs (default)', () => {
const globalPrefsExist = existsSync(join(homedir(), ".gsd", "preferences.md"))
|| existsSync(join(homedir(), ".gsd", "PREFERENCES.md"));
if (!globalPrefsExist) {
try {
removeRunnerPreferences();
invalidateAllCaches();
assert.deepStrictEqual(getIsolationMode(), "none", "getIsolationMode() with no prefs defaults to none");
} finally {
invalidateAllCaches();
}
}
});
test('getIsolationMode returns "none" with none prefs', () => {
try {
writeRunnerPreferences("none");
@ -100,6 +117,28 @@ try {
}
});
test('getIsolationMode returns "worktree" with worktree prefs', () => {
try {
writeRunnerPreferences("worktree");
invalidateAllCaches();
assert.deepStrictEqual(getIsolationMode(), "worktree", "getIsolationMode() with worktree prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
test('getIsolationMode returns "branch" with branch prefs', () => {
try {
writeRunnerPreferences("branch");
invalidateAllCaches();
assert.deepStrictEqual(getIsolationMode(), "branch", "getIsolationMode() with branch prefs");
} finally {
removeRunnerPreferences();
invalidateAllCaches();
}
});
test('getActiveAutoWorktreeContext returns null at baseline', () => {
assert.deepStrictEqual(getActiveAutoWorktreeContext(), null, "getActiveAutoWorktreeContext() returns null without enterAutoWorktree()");
});

View file

@ -41,18 +41,16 @@ test("git.merge_to_main produces deprecation warning", () => {
});
test("getIsolationMode defaults to worktree when preferences have no isolation setting", () => {
test("getIsolationMode defaults to none when preferences have no isolation setting", () => {
// Validate the default via validatePreferences: when no isolation is set,
// preferences.git.isolation is undefined, and getIsolationMode returns "worktree".
// We test the function's logic by verifying its documented default.
// preferences.git.isolation is undefined, and getIsolationMode returns "none".
// Default changed from "worktree" to "none" so GSD works out of the box
// without preferences.md (#2480).
const { preferences } = validatePreferences({});
assert.equal(preferences.git?.isolation, undefined, "no isolation in empty prefs");
// The function returns "worktree" when prefs?.git?.isolation is not "none" or "branch"
// This is a compile-time-verifiable truth from the function body — test it directly
// by constructing the same conditions getIsolationMode checks.
const isolation = preferences.git?.isolation;
const expected = isolation === "none" ? "none" : isolation === "branch" ? "branch" : "worktree";
assert.equal(expected, "worktree", "default isolation mode is worktree");
const expected = isolation === "worktree" ? "worktree" : isolation === "branch" ? "branch" : "none";
assert.equal(expected, "none", "default isolation mode is none");
});
// ── Mode defaults ────────────────────────────────────────────────────────────
@ -63,7 +61,7 @@ test("solo mode applies correct defaults", () => {
assert.equal(result.git?.push_branches, false);
assert.equal(result.git?.pre_merge_check, false);
assert.equal(result.git?.merge_strategy, "squash");
assert.equal(result.git?.isolation, "worktree");
assert.equal(result.git?.isolation, "none");
assert.equal(result.unique_milestone_ids, false);
});