From 5c1fd8f4af6bc575e0c852e181d00de48d857e3f Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Fri, 3 Apr 2026 16:25:52 +0300 Subject: [PATCH 1/3] fix: bootstrap .gsd state when manually creating worktrees Manual worktree creation (gsd -w and /worktree create) skipped the syncGsdStateToWorktree() call that auto/parallel flows already use. The new worktree was a valid git worktree but had no .gsd/ state, so GSD treated it as uninitialized. Fixes #3427 --- src/resources/extensions/gsd/worktree-command.ts | 5 ++++- src/worktree-cli.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index a1722132d2..cdac4b9269 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -13,7 +13,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { loadPrompt } from "./prompt-loader.js"; import { autoCommitCurrentBranch, getMainBranch, resolveGitHeadPath, nudgeGitBranchCache } from "./worktree.js"; -import { runWorktreePostCreateHook } from "./auto-worktree.js"; +import { runWorktreePostCreateHook, syncGsdStateToWorktree } from "./auto-worktree.js"; import { showConfirm } from "../shared/tui.js"; import { gsdRoot, milestonesDir } from "./paths.js"; import { @@ -329,6 +329,9 @@ async function handleCreate( ctx.ui.notify(hookError, "warning"); } + // Sync .gsd/ state from main into the new worktree (#3427) + syncGsdStateToWorktree(mainBase, info.path); + // Track original cwd before switching if (!originalCwd) originalCwd = basePath; diff --git a/src/worktree-cli.ts b/src/worktree-cli.ts index 70abba8566..1594bdb23d 100644 --- a/src/worktree-cli.ts +++ b/src/worktree-cli.ts @@ -42,6 +42,7 @@ interface ExtensionModules { worktreeBranchName: (name: string) => string worktreePath: (basePath: string, name: string) => string runWorktreePostCreateHook: (basePath: string, wtPath: string) => string | null + syncGsdStateToWorktree: (mainBasePath: string, worktreePath: string) => { synced: string[] } nativeHasChanges: (path: string) => boolean nativeDetectMainBranch: (basePath: string) => string nativeCommitCountBetween: (basePath: string, from: string, to: string) => number @@ -68,6 +69,7 @@ async function loadExtensionModules(): Promise { worktreeBranchName: wtMgr.worktreeBranchName, worktreePath: wtMgr.worktreePath, runWorktreePostCreateHook: autoWt.runWorktreePostCreateHook, + syncGsdStateToWorktree: autoWt.syncGsdStateToWorktree, nativeHasChanges: gitBridge.nativeHasChanges, nativeDetectMainBranch: gitBridge.nativeDetectMainBranch, nativeCommitCountBetween: gitBridge.nativeCommitCountBetween, @@ -381,6 +383,8 @@ async function createAndEnter(ext: ExtensionModules, basePath: string, name: str process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`)) } + ext.syncGsdStateToWorktree(basePath, info.path) + process.chdir(info.path) process.env.GSD_CLI_WORKTREE = name process.env.GSD_CLI_WORKTREE_BASE = basePath From 6f10ec57bb0b497503d04a7340b500910a0e9750 Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Tue, 7 Apr 2026 07:48:53 +0300 Subject: [PATCH 2/3] fix: create .gsd/ in worktree before syncing if it doesn't exist syncGsdStateToWorktree() returned early when the worktree had no .gsd/, which is always the case for a fresh git worktree (it's gitignored). Now mkdir the destination before syncing so manual worktree creation actually bootstraps state. --- src/resources/extensions/gsd/auto-worktree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 92cb389c8d..096ae1668e 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -518,7 +518,8 @@ export function syncGsdStateToWorktree( // If both resolve to the same directory (symlink), no sync needed if (isSamePath(mainGsd, wtGsd)) return { synced }; - if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced }; + if (!existsSync(mainGsd)) return { synced }; + if (!existsSync(wtGsd)) mkdirSync(wtGsd, { recursive: true }); // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.) for (const f of ROOT_STATE_FILES) { From 8cb749e0fc952ba909adc21d940c89ff169fda5e Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Sat, 11 Apr 2026 08:45:58 +0300 Subject: [PATCH 3/3] test: add regression test for syncGsdStateToWorktree .gsd bootstrap (#3427) --- .../tests/sync-gsd-state-to-worktree.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/sync-gsd-state-to-worktree.test.ts diff --git a/src/resources/extensions/gsd/tests/sync-gsd-state-to-worktree.test.ts b/src/resources/extensions/gsd/tests/sync-gsd-state-to-worktree.test.ts new file mode 100644 index 0000000000..6eadfa8ea6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/sync-gsd-state-to-worktree.test.ts @@ -0,0 +1,98 @@ +/** + * sync-gsd-state-to-worktree.test.ts — Regression test for #3427. + * + * syncGsdStateToWorktree() must create the .gsd/ directory in the target + * worktree if it does not exist, and then sync root state files from the + * main project's .gsd/ into the worktree's .gsd/. + * + * The bug: fresh git worktrees have no .gsd/ (it is gitignored), so the + * function returned early without syncing anything. + */ + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { syncGsdStateToWorktree } from "../auto-worktree.ts"; + +describe("syncGsdStateToWorktree() (#3427)", () => { + let mainProject: string; + let mainGsd: string; + let worktreeDir: string; + + beforeEach(() => { + mainProject = realpathSync(mkdtempSync(join(tmpdir(), "gsd-sync-main-"))); + mainGsd = join(mainProject, ".gsd"); + mkdirSync(mainGsd, { recursive: true }); + + worktreeDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-sync-wt-"))); + // Intentionally do NOT create .gsd/ in worktreeDir — simulates fresh git worktree + }); + + afterEach(() => { + rmSync(mainProject, { recursive: true, force: true }); + rmSync(worktreeDir, { recursive: true, force: true }); + }); + + test("creates .gsd/ in worktree when it does not exist and syncs state files", () => { + // Set up some root state files in main + writeFileSync(join(mainGsd, "DECISIONS.md"), "# Decisions\n"); + writeFileSync(join(mainGsd, "REQUIREMENTS.md"), "# Requirements\n"); + writeFileSync(join(mainGsd, "PROJECT.md"), "# Project\n"); + + const result = syncGsdStateToWorktree(mainProject, worktreeDir); + + // .gsd/ should have been created in worktree + assert.ok(existsSync(join(worktreeDir, ".gsd")), ".gsd/ directory should be created"); + + // State files should be synced + assert.equal( + readFileSync(join(worktreeDir, ".gsd", "DECISIONS.md"), "utf-8"), + "# Decisions\n", + ); + assert.equal( + readFileSync(join(worktreeDir, ".gsd", "REQUIREMENTS.md"), "utf-8"), + "# Requirements\n", + ); + assert.equal( + readFileSync(join(worktreeDir, ".gsd", "PROJECT.md"), "utf-8"), + "# Project\n", + ); + + // synced array should reflect what was copied + assert.ok(result.synced.includes("DECISIONS.md")); + assert.ok(result.synced.includes("REQUIREMENTS.md")); + assert.ok(result.synced.includes("PROJECT.md")); + }); + + test("does not overwrite existing files in worktree .gsd/", () => { + writeFileSync(join(mainGsd, "DECISIONS.md"), "main version\n"); + + // Pre-create .gsd/ in worktree with an existing file + const wtGsd = join(worktreeDir, ".gsd"); + mkdirSync(wtGsd, { recursive: true }); + writeFileSync(join(wtGsd, "DECISIONS.md"), "worktree version\n"); + + syncGsdStateToWorktree(mainProject, worktreeDir); + + // Existing file should NOT be overwritten + assert.equal( + readFileSync(join(wtGsd, "DECISIONS.md"), "utf-8"), + "worktree version\n", + ); + }); + + test("returns empty synced array when main has no .gsd/", () => { + // Remove main .gsd/ + rmSync(mainGsd, { recursive: true, force: true }); + + const result = syncGsdStateToWorktree(mainProject, worktreeDir); + + assert.deepEqual(result.synced, []); + // Should not create .gsd/ in worktree if main has none + assert.ok(!existsSync(join(worktreeDir, ".gsd"))); + }); +});