From 15be720fbfe4e42e740b7719f566cbb518413bf5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 08:58:23 -0400 Subject: [PATCH] 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 --- src/onboarding.ts | 4 +-- src/remote-questions-config.ts | 40 ++++++++++++++++++++++++ src/resources/extensions/gsd/auto.ts | 27 ++++++++++++++++ src/resources/extensions/gsd/commands.ts | 38 ++++++++++++---------- src/resources/extensions/gsd/worktree.ts | 22 +++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 src/remote-questions-config.ts diff --git a/src/onboarding.ts b/src/onboarding.ts index 7fd66694c..de4267286 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -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)}`) diff --git a/src/remote-questions-config.ts b/src/remote-questions-config.ts new file mode 100644 index 000000000..39293b4dc --- /dev/null +++ b/src/remote-questions-config.ts @@ -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"); +} diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a4c6f498b..8d93cf3d8 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index f6bf82dab..7e4007e3b 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 { - 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, }); diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 32160d08d..59c4e9543 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -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//`, 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. *