Merge pull request #3511 from jeremymcs/fix/steer-worktree-path
fix(gsd): steer writes overrides to worktree when active
This commit is contained in:
commit
099e6f3120
2 changed files with 128 additions and 7 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
108
src/resources/extensions/gsd/tests/steer-worktree-path.test.ts
Normal file
108
src/resources/extensions/gsd/tests/steer-worktree-path.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue