diff --git a/docs/configuration.md b/docs/configuration.md index d05ce6dc1..5bcd62d4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -195,6 +195,7 @@ git: merge_strategy: squash # how worktree branches merge: "squash" or "merge" isolation: worktree # git isolation: "worktree" or "branch" commit_docs: true # commit .gsd/ artifacts to git (set false to keep local) + worktree_post_create: .gsd/hooks/post-worktree-create # script to run after worktree creation ``` | Field | Type | Default | Description | @@ -209,6 +210,32 @@ git: | `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) | | `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory) or `"branch"` (work in project root — useful for submodule-heavy repos) | | `commit_docs` | boolean | `true` | Commit `.gsd/` planning artifacts to git. Set `false` to keep local-only | +| `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars | + +#### `git.worktree_post_create` + +Script to run after a worktree is created (both auto-mode and manual `/worktree`). Useful for copying `.env` files, symlinking asset directories, or running setup commands that worktrees don't inherit from the main tree. + +```yaml +git: + worktree_post_create: .gsd/hooks/post-worktree-create +``` + +The script receives two environment variables: +- `SOURCE_DIR` — the original project root +- `WORKTREE_DIR` — the newly created worktree path + +Example hook script (`.gsd/hooks/post-worktree-create`): + +```bash +#!/bin/bash +# Copy environment files and symlink assets into the new worktree +cp "$SOURCE_DIR/.env" "$WORKTREE_DIR/.env" +cp "$SOURCE_DIR/.env.local" "$WORKTREE_DIR/.env.local" 2>/dev/null || true +ln -sf "$SOURCE_DIR/assets" "$WORKTREE_DIR/assets" +``` + +The path can be absolute or relative to the project root. The script runs with a 30-second timeout. Failure is non-fatal — GSD logs a warning and continues. ### `notifications` diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d686fdfe9..0e95b2f40 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -7,7 +7,7 @@ */ import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js"; import { execSync, execFileSync } from "node:child_process"; import { @@ -77,6 +77,48 @@ function nudgeGitBranchCache(previousCwd: string): void { } } +// ─── Worktree Post-Create Hook (#597) ──────────────────────────────────────── + +/** + * Run the user-configured post-create hook script after worktree creation. + * The script receives SOURCE_DIR and WORKTREE_DIR as environment variables. + * Failure is non-fatal — returns the error message or null on success. + * + * Reads the hook path from git.worktree_post_create in preferences. + * Pass hookPath directly to bypass preference loading (useful for testing). + */ +export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string, hookPath?: string): string | null { + if (hookPath === undefined) { + const prefs = loadEffectiveGSDPreferences()?.preferences?.git; + hookPath = prefs?.worktree_post_create; + } + if (!hookPath) return null; + + // Resolve relative paths against the source project root + const resolved = isAbsolute(hookPath) ? hookPath : join(sourceDir, hookPath); + if (!existsSync(resolved)) { + return `Worktree post-create hook not found: ${resolved}`; + } + + try { + execSync(resolved, { + cwd: worktreeDir, + env: { + ...process.env, + SOURCE_DIR: sourceDir, + WORKTREE_DIR: worktreeDir, + }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + timeout: 30_000, // 30 second timeout + }); + return null; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `Worktree post-create hook failed: ${msg}`; + } +} + // ─── Auto-Worktree Branch Naming ─────────────────────────────────────────── export function autoWorktreeBranch(milestoneId: string): string { @@ -118,6 +160,13 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin // on plan-slice because the plan file doesn't exist in the worktree. copyPlanningArtifacts(basePath, info.path); + // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets + const hookError = runWorktreePostCreateHook(basePath, info.path); + if (hookError) { + // Non-fatal — log but don't prevent worktree usage + console.error(`[GSD] ${hookError}`); + } + const previousCwd = process.cwd(); try { diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 9033bcb0f..96c802e1c 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -111,6 +111,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`. - `isolation`: `"worktree"` or `"branch"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root (useful for submodule-heavy repos). Default: `"worktree"`. - `commit_docs`: boolean — when `false`, prevents GSD from committing `.gsd/` planning artifacts to git. The `.gsd/` folder is added to `.gitignore` and kept local-only. Useful for teams where only some members use GSD, or when company policy requires a clean repository. Default: `true`. + - `worktree_post_create`: string — script to run after a worktree is created (both auto-mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none. - `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`. diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 9e2fb7fbb..06fd2b422 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -52,6 +52,12 @@ export interface GitPreferences { * Default: true (planning docs are tracked in git). */ commit_docs?: boolean; + /** Script to run after a worktree is created (#597). + * Receives SOURCE_DIR and WORKTREE_DIR as environment variables. + * Can be an absolute path or relative to the project root. + * Failure is non-fatal — logged as a warning. + */ + worktree_post_create?: string; } export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 3190fc614..f408c7763 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -1115,6 +1115,13 @@ export function validatePreferences(preferences: GSDPreferences): { if (typeof g.commit_docs === "boolean") git.commit_docs = g.commit_docs; else errors.push("git.commit_docs must be a boolean"); } + if (g.worktree_post_create !== undefined) { + if (typeof g.worktree_post_create === "string" && g.worktree_post_create.trim()) { + git.worktree_post_create = g.worktree_post_create.trim(); + } else { + errors.push("git.worktree_post_create must be a non-empty string (path to script)"); + } + } // Deprecated: merge_to_main is ignored (branchless architecture). if (g.merge_to_main !== undefined) { warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting."); diff --git a/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts b/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts new file mode 100644 index 000000000..d5a6625d7 --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts @@ -0,0 +1,165 @@ +/** + * worktree-post-create-hook.test.ts — Tests for #597 worktree post-create hook. + * + * Verifies that runWorktreePostCreateHook correctly executes user scripts + * with SOURCE_DIR and WORKTREE_DIR environment variables. + * + * Uses Node.js scripts instead of bash for Windows compatibility. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { runWorktreePostCreateHook } from "../auto-worktree.ts"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "gsd-wt-hook-test-")); +} + +const isWin = process.platform === "win32"; + +/** Return the platform-appropriate hook file path (adds .bat on Windows). */ +function hookPath(base: string): string { + return isWin ? `${base}.bat` : base; +} + +/** Create a cross-platform Node.js hook script. */ +function writeNodeHookScript(filePath: string, code: string): void { + if (isWin) { + // Write the JS code to a companion .js file and have the .bat invoke it. + // node -e with multi-line code breaks on Windows because cmd.exe splits on newlines. + const jsPath = filePath.replace(/\.bat$/, ".js"); + writeFileSync(jsPath, code); + writeFileSync(filePath, `@echo off\nnode "%~dp0${jsPath.split("\\").pop()}" %*\n`); + } else { + writeFileSync(filePath, `#!/usr/bin/env node\n${code}\n`); + chmodSync(filePath, 0o755); + } +} + +// ─── runWorktreePostCreateHook ────────────────────────────────────────────── + +test("returns null when no hook path is provided", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + const result = runWorktreePostCreateHook(src, wt, undefined); + assert.equal(result, null); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); + +test("returns error when hook script does not exist", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + const result = runWorktreePostCreateHook(src, wt, ".gsd/hooks/nonexistent"); + assert.ok(result !== null, "should return error string"); + assert.ok(result!.includes("not found"), "error should mention 'not found'"); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); + +test("executes hook script with correct SOURCE_DIR and WORKTREE_DIR env vars", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + const hooksDir = join(src, ".gsd", "hooks"); + mkdirSync(hooksDir, { recursive: true }); + const hookFile = hookPath(join(hooksDir, "post-create")); + const code = [ + `const fs = require("fs");`, + `const path = require("path");`, + `const out = path.join(process.env.WORKTREE_DIR, "hook-output.txt");`, + `fs.writeFileSync(out, "SOURCE=" + process.env.SOURCE_DIR + "\\n" + "WORKTREE=" + process.env.WORKTREE_DIR + "\\n");`, + ].join("\n"); + writeNodeHookScript(hookFile, code); + + const result = runWorktreePostCreateHook(src, wt, hookPath(".gsd/hooks/post-create")); + assert.equal(result, null, "should succeed"); + + const outputFile = join(wt, "hook-output.txt"); + assert.ok(existsSync(outputFile), "hook should have created output file"); + + const output = readFileSync(outputFile, "utf-8"); + assert.ok(output.includes(`SOURCE=${src}`), "SOURCE_DIR should match source dir"); + assert.ok(output.includes(`WORKTREE=${wt}`), "WORKTREE_DIR should match worktree dir"); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); + +test("returns error message when hook script fails", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + const hooksDir = join(src, ".gsd", "hooks"); + mkdirSync(hooksDir, { recursive: true }); + const hookFile = hookPath(join(hooksDir, "failing-hook")); + writeNodeHookScript(hookFile, `process.exit(1);`); + + const result = runWorktreePostCreateHook(src, wt, hookPath(".gsd/hooks/failing-hook")); + assert.ok(result !== null, "should return error string"); + assert.ok(result!.includes("hook failed"), "error should mention 'hook failed'"); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); + +test("supports absolute hook paths", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + const hookFile = hookPath(join(src, "absolute-hook")); + const code = [ + `const fs = require("fs");`, + `const path = require("path");`, + `fs.writeFileSync(path.join(process.env.WORKTREE_DIR, "absolute-hook-ran"), "");`, + ].join("\n"); + writeNodeHookScript(hookFile, code); + + const result = runWorktreePostCreateHook(src, wt, hookFile); + assert.equal(result, null, "absolute path hook should succeed"); + assert.ok(existsSync(join(wt, "absolute-hook-ran")), "hook should have run"); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); + +test("hook can copy files from source to worktree", () => { + const src = makeTmpDir(); + const wt = makeTmpDir(); + try { + writeFileSync(join(src, ".env"), "DB_HOST=localhost\nAPI_KEY=secret123\n"); + + const hookFile = hookPath(join(src, "setup-hook")); + const code = [ + `const fs = require("fs");`, + `const path = require("path");`, + `const envSrc = path.join(process.env.SOURCE_DIR, ".env");`, + `const envDst = path.join(process.env.WORKTREE_DIR, ".env");`, + `fs.copyFileSync(envSrc, envDst);`, + ].join("\n"); + writeNodeHookScript(hookFile, code); + + const result = runWorktreePostCreateHook(src, wt, hookFile); + assert.equal(result, null, "hook should succeed"); + + assert.ok(existsSync(join(wt, ".env")), ".env should be copied to worktree"); + const envContent = readFileSync(join(wt, ".env"), "utf-8"); + assert.ok(envContent.includes("API_KEY=secret123"), ".env content should match"); + } finally { + rmSync(src, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 3b194dc40..25fa3c8ab 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -13,6 +13,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { loadPrompt } from "./prompt-loader.js"; import { autoCommitCurrentBranch } from "./worktree.js"; +import { runWorktreePostCreateHook } from "./auto-worktree.js"; import { showConfirm } from "../shared/confirm-ui.js"; import { gsdRoot, milestonesDir } from "./paths.js"; import { @@ -360,6 +361,12 @@ async function handleCreate( const mainBase = originalCwd ?? basePath; const info = createWorktree(mainBase, name); + // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets + const hookError = runWorktreePostCreateHook(mainBase, info.path); + if (hookError) { + ctx.ui.notify(hookError, "warning"); + } + // Track original cwd before switching if (!originalCwd) originalCwd = basePath;