fix: multiple open issue bugfixes (#592, #603, #606, #608, #602) (#612)

Four bugfixes for open issues:

1. Worktree created from integration branch, not main (#606)
   - createAutoWorktree reads integration branch from META.json
   - mergeMilestoneToMain merges to integration branch, not hardcoded main
   - createWorktree accepts optional startPoint parameter

2. Resolve project root from worktree paths in all commands (#608, #602)
   - Add resolveProjectRoot() to detect .gsd/worktrees/ in cwd
   - All GSD commands use projectRoot() instead of raw process.cwd()
   - Fixes stale cwd after milestone completion (#608)
   - Fixes discuss/status basepath disagreement (#602)

3. Milestone merge skipped in branch isolation mode (#603)
   - Add branch-mode fallback when isInAutoWorktree() is false
   - Detects milestone/* branch and performs squash-merge
   - Uses same mergeMilestoneToMain flow as worktree mode

4. Remote questions onboarding missing .js module (#592)
   - Extract saveRemoteQuestionsConfig into compiled src/ helper
   - Avoids cross-boundary import from compiled JS to raw .ts
This commit is contained in:
Tom Boucher 2026-03-16 08:58:23 -04:00 committed by GitHub
parent 7e25e6d427
commit 15be720fbf
5 changed files with 113 additions and 18 deletions

View file

@ -747,7 +747,7 @@ async function runRemoteQuestionsStep(
})
if (p.isCancel(channelId) || !channelId) return null
const { saveRemoteQuestionsConfig } = await import('./resources/extensions/remote-questions/remote-command.js')
const { saveRemoteQuestionsConfig } = await import('./remote-questions-config.js')
saveRemoteQuestionsConfig('slack', (channelId as string).trim())
p.log.success(`Slack channel: ${pc.green((channelId as string).trim())}`)
return 'Slack'
@ -852,7 +852,7 @@ async function runDiscordChannelStep(p: ClackModule, pc: PicoModule, token: stri
}
// Save remote questions config
const { saveRemoteQuestionsConfig } = await import('./resources/extensions/remote-questions/remote-command.js')
const { saveRemoteQuestionsConfig } = await import('./remote-questions-config.js')
saveRemoteQuestionsConfig('discord', channelId)
const channelName = channels.find(ch => ch.id === channelId)?.name
p.log.success(`Discord channel: ${pc.green(channelName ? `#${channelName}` : channelId)}`)

View file

@ -0,0 +1,40 @@
/**
* Remote Questions Config Helper
*
* Extracted from remote-questions extension so onboarding.ts can import
* it without crossing the compiled/uncompiled boundary. The extension
* files in src/resources/ are shipped as raw .ts and loaded via jiti,
* but onboarding.ts is compiled by tsc dynamic imports from compiled
* JS to uncompiled .ts fail at runtime (#592).
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getGlobalGSDPreferencesPath } from "./resources/extensions/gsd/preferences.js";
export function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
const prefsPath = getGlobalGSDPreferencesPath();
const block = [
"remote_questions:",
` channel: ${channel}`,
` channel_id: "${channelId}"`,
" timeout_minutes: 5",
" poll_interval_seconds: 5",
].join("\n");
const content = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : "";
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
let next = content;
if (fmMatch) {
let frontmatter = fmMatch[1];
const regex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/;
frontmatter = regex.test(frontmatter) ? frontmatter.replace(regex, block) : `${frontmatter.trimEnd()}\n${block}`;
next = `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}`;
} else {
next = `---\n${block}\n---\n\n${content}`;
}
mkdirSync(dirname(prefsPath), { recursive: true });
writeFileSync(prefsPath, next, "utf-8");
}

View file

@ -92,6 +92,7 @@ import {
getAutoWorktreePath,
getAutoWorktreeOriginalBase,
mergeMilestoneToMain,
autoWorktreeBranch,
} from "./auto-worktree.js";
import { pruneQueueOrder } from "./queue-order.js";
import { showNextAction } from "../shared/next-action-ui.js";
@ -1404,6 +1405,32 @@ async function dispatchNextUnit(
try { process.chdir(basePath); } catch { /* best-effort */ }
}
}
} else if (currentMilestoneId && !isInAutoWorktree(basePath)) {
// 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 {
const currentBranch = getCurrentBranch(basePath);
const milestoneBranch = autoWorktreeBranch(currentMilestoneId);
if (currentBranch === milestoneBranch) {
const roadmapPath = resolveMilestoneFile(basePath, currentMilestoneId, "ROADMAP");
if (roadmapPath) {
const roadmapContent = readFileSync(roadmapPath, "utf-8");
// mergeMilestoneToMain handles: auto-commit, checkout integration branch,
// squash merge, commit, optional push, branch deletion.
const mergeResult = mergeMilestoneToMain(basePath, currentMilestoneId, roadmapContent);
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
ctx.ui.notify(
`Milestone ${currentMilestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
"info",
);
}
}
} catch (err) {
ctx.ui.notify(
`Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
"warning",
);
}
}
sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
await stopAuto(ctx, pi);

View file

@ -13,6 +13,7 @@ import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import { showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
import { resolveProjectRoot } from "./worktree.js";
import {
getGlobalGSDPreferencesPath,
getLegacyGlobalGSDPreferencesPath,
@ -56,6 +57,11 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
);
}
/** Resolve the effective project root, accounting for worktree paths. */
function projectRoot(): string {
return resolveProjectRoot(process.cwd());
}
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
@ -169,24 +175,24 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
if (trimmed === "next" || trimmed.startsWith("next ")) {
if (trimmed.includes("--dry-run")) {
await handleDryRun(ctx, process.cwd());
await handleDryRun(ctx, projectRoot());
return;
}
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true });
await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true });
return;
}
if (trimmed === "auto" || trimmed.startsWith("auto ")) {
const verboseMode = trimmed.includes("--verbose");
await startAuto(ctx, pi, process.cwd(), verboseMode);
await startAuto(ctx, pi, projectRoot(), verboseMode);
return;
}
if (trimmed === "stop") {
if (!isAutoActive() && !isAutoPaused()) {
// Not running in this process — check for a remote auto-mode session
const result = stopAutoRemote(process.cwd());
const result = stopAutoRemote(projectRoot());
if (result.found) {
ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info");
} else if (result.error) {
@ -214,42 +220,42 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
if (trimmed === "history" || trimmed.startsWith("history ")) {
await handleHistory(trimmed.replace(/^history\s*/, "").trim(), ctx, process.cwd());
await handleHistory(trimmed.replace(/^history\s*/, "").trim(), ctx, projectRoot());
return;
}
if (trimmed === "undo" || trimmed.startsWith("undo ")) {
await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, process.cwd());
await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, projectRoot());
return;
}
if (trimmed.startsWith("skip ")) {
await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, process.cwd());
await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, projectRoot());
return;
}
if (trimmed === "export" || trimmed.startsWith("export ")) {
await handleExport(trimmed.replace(/^export\s*/, "").trim(), ctx, process.cwd());
await handleExport(trimmed.replace(/^export\s*/, "").trim(), ctx, projectRoot());
return;
}
if (trimmed === "cleanup branches") {
await handleCleanupBranches(ctx, process.cwd());
await handleCleanupBranches(ctx, projectRoot());
return;
}
if (trimmed === "cleanup snapshots") {
await handleCleanupSnapshots(ctx, process.cwd());
await handleCleanupSnapshots(ctx, projectRoot());
return;
}
if (trimmed === "queue") {
await showQueue(ctx, pi, process.cwd());
await showQueue(ctx, pi, projectRoot());
return;
}
if (trimmed === "discuss") {
await showDiscuss(ctx, pi, process.cwd());
await showDiscuss(ctx, pi, projectRoot());
return;
}
@ -295,7 +301,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
if (trimmed === "") {
// Bare /gsd defaults to step mode
await startAuto(ctx, pi, process.cwd(), false, { step: true });
await startAuto(ctx, pi, projectRoot(), false, { step: true });
return;
}
@ -308,7 +314,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
const basePath = process.cwd();
const basePath = projectRoot();
const state = await deriveState(basePath);
if (state.registry.length === 0) {
@ -392,9 +398,9 @@ async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: Exte
const parts = trimmed ? trimmed.split(/\s+/) : [];
const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor";
const requestedScope = mode === "doctor" ? parts[0] : parts[1];
const scope = await selectDoctorScope(process.cwd(), requestedScope);
const scope = await selectDoctorScope(projectRoot(), requestedScope);
const effectiveScope = mode === "audit" ? requestedScope : scope;
const report = await runGSDDoctor(process.cwd(), {
const report = await runGSDDoctor(projectRoot(), {
fix: mode === "fix" || mode === "heal",
scope: effectiveScope,
});

View file

@ -76,6 +76,28 @@ export function detectWorktreeName(basePath: string): string | null {
return name || null;
}
/**
* Resolve the project root from a path that may be inside a worktree.
* If the path contains `/.gsd/worktrees/<name>/`, returns the portion
* before `/.gsd/`. Otherwise returns the input unchanged.
*
* Use this in commands that call `process.cwd()` to ensure they always
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath: string): string {
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return basePath;
// Return the original path up to the .gsd/ marker (un-normalized)
// Account for potential OS-specific separators
const sep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
const idxOs = basePath.indexOf(markerOs);
if (idxOs !== -1) return basePath.slice(0, idxOs);
return basePath.slice(0, idx);
}
/**
* Get the slice branch name, namespaced by worktree when inside one.
*