Merge pull request #3511 from jeremymcs/fix/steer-worktree-path

fix(gsd): steer writes overrides to worktree when active
This commit is contained in:
Jeremy McSpadden 2026-04-04 16:10:30 -05:00 committed by GitHub
commit 099e6f3120
2 changed files with 128 additions and 7 deletions

View file

@ -20,7 +20,8 @@ import {
selectDoctorScope,
filterDoctorIssues,
} from "./doctor.js";
import { isAutoActive } from "./auto.js";
import { isAutoActive, checkRemoteAutoSession } from "./auto.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
import { projectRoot } from "./commands/context.js";
import { loadPrompt } from "./prompt-loader.js";
@ -222,7 +223,19 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
const sid = state.activeSlice?.id ?? "none";
const tid = state.activeTask?.id ?? "none";
const appliedAt = `${mid}/${sid}/${tid}`;
await appendOverride(basePath, change, appliedAt);
// Resolve the correct target path: only route to a worktree when auto-mode
// is actively running there (in-process or remote). A worktree directory may
// exist from a previous session without being the active runtime path —
// writing there without a live session would silently drop the override.
const autoRunning = isAutoActive() || checkRemoteAutoSession(basePath).running;
const wtPath = autoRunning && mid !== "none"
? getAutoWorktreePath(basePath, mid)
: null;
const targetPath = wtPath ?? basePath;
await appendOverride(targetPath, change, appliedAt);
const overrideLoc = wtPath ? "worktree `.gsd/OVERRIDES.md`" : "`.gsd/OVERRIDES.md`";
if (isAutoActive()) {
pi.sendMessage({
@ -232,14 +245,14 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
"",
`**Override:** ${change}`,
"",
"This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.",
`This override has been saved to ${overrideLoc} and will be injected into all future task prompts.`,
"A document rewrite unit will run before the next task to propagate this change across all active plan documents.",
"",
"If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.",
].join("\n"),
display: false,
}, { triggerTurn: true });
ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info");
ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Will be applied before next task dispatch.`, "info");
} else {
pi.sendMessage({
customType: "gsd-hard-steer",
@ -248,13 +261,13 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
"",
`**Override:** ${change}`,
"",
"This override has been saved to `.gsd/OVERRIDES.md`.",
"Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.",
`This override has been saved to ${overrideLoc}.`,
`Before continuing, read ${overrideLoc} and update the current plan documents to reflect this change.`,
"Focus on: active slice plan, incomplete task plans, and DECISIONS.md.",
].join("\n"),
display: false,
}, { triggerTurn: true });
ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info");
ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Update plan documents to reflect this change.`, "info");
}
}

View file

@ -0,0 +1,108 @@
// GSD Extension - Steer Worktree Path Resolution Test
// Regression test for #3476: /gsd steer must write overrides to the worktree .gsd/,
// not the project root .gsd/, when a worktree is active.
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { appendOverride, loadActiveOverrides } from "../files.ts";
import { getAutoWorktreePath } from "../auto-worktree.ts";
describe("steer worktree path resolution (#3476)", () => {
let projectRoot: string;
let worktreePath: string;
beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), "gsd-steer-wt-"));
mkdirSync(join(projectRoot, ".gsd"), { recursive: true });
// Simulate a worktree with its own .gsd directory
worktreePath = join(projectRoot, ".gsd", "worktrees", "M001");
mkdirSync(join(worktreePath, ".gsd"), { recursive: true });
});
afterEach(() => {
rmSync(projectRoot, { recursive: true, force: true });
});
test("appendOverride writes to worktree .gsd/ when worktree path is used", async () => {
await appendOverride(worktreePath, "Use Postgres instead of SQLite", "M001/S01/T01");
// Override should be in the worktree .gsd/
const wtOverrides = join(worktreePath, ".gsd", "OVERRIDES.md");
assert.ok(existsSync(wtOverrides), "override file exists in worktree .gsd/");
const content = readFileSync(wtOverrides, "utf-8");
assert.ok(content.includes("Use Postgres instead of SQLite"), "override content is correct");
// Override should NOT be in the project root .gsd/
const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md");
assert.ok(!existsSync(rootOverrides), "no override file in project root .gsd/");
});
test("loadActiveOverrides reads from worktree .gsd/ when worktree path is used", async () => {
await appendOverride(worktreePath, "Switch to JWT auth", "M001/S02/T01");
// Loading from worktree should find the override
const wtOverrides = await loadActiveOverrides(worktreePath);
assert.equal(wtOverrides.length, 1, "one active override in worktree");
assert.equal(wtOverrides[0].change, "Switch to JWT auth");
// Loading from project root should find nothing
const rootOverrides = await loadActiveOverrides(projectRoot);
assert.equal(rootOverrides.length, 0, "no overrides in project root");
});
test("appendOverride falls back to project root when no worktree exists", async () => {
await appendOverride(projectRoot, "Use Redis cache", "M001/S01/T01");
const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md");
assert.ok(existsSync(rootOverrides), "override file exists in project root .gsd/");
const content = readFileSync(rootOverrides, "utf-8");
assert.ok(content.includes("Use Redis cache"), "override content is correct");
});
test("getAutoWorktreePath returns null for worktree without valid .git file", () => {
// The worktree directory exists but has no .git file — this is an inactive/
// leftover worktree. getAutoWorktreePath must return null so handleSteer
// does not route overrides to a dead worktree.
const result = getAutoWorktreePath(projectRoot, "M001");
assert.equal(result, null, "returns null for worktree without .git file");
});
test("override routing: inactive worktree directory should not receive overrides", async () => {
// Simulate the handleSteer path-resolution logic:
// When no auto-mode is running, even if a worktree dir exists,
// overrides must go to the project root.
const autoRunning = false; // no live session
const wtPath = autoRunning ? getAutoWorktreePath(projectRoot, "M001") : null;
const targetPath = wtPath ?? projectRoot;
await appendOverride(targetPath, "Should go to project root", "M001/S01/T01");
const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md");
const wtOverrides = join(worktreePath, ".gsd", "OVERRIDES.md");
assert.ok(existsSync(rootOverrides), "override written to project root");
assert.ok(!existsSync(wtOverrides), "override NOT written to inactive worktree");
});
test("override routing: active worktree with valid .git should receive overrides", async () => {
// Simulate the handleSteer path-resolution logic with active auto-mode.
// getAutoWorktreePath requires a valid .git file, so even with autoRunning=true,
// it returns null for our test worktree (no real .git). This confirms the
// double-gate: both autoRunning AND valid worktree must be true.
const autoRunning = true;
const wtPath = autoRunning ? getAutoWorktreePath(projectRoot, "M001") : null;
const targetPath = wtPath ?? projectRoot;
// Without a valid .git file, falls back to project root
await appendOverride(targetPath, "Falls back without .git", "M001/S01/T01");
const rootOverrides = join(projectRoot, ".gsd", "OVERRIDES.md");
assert.ok(existsSync(rootOverrides), "override written to project root (no valid .git in worktree)");
});
});