feat: add worktree post-create hook for environment setup (#597) (#617)

* feat: add worktree post-create hook for environment setup (#597)

Add git.worktree_post_create preference — a script path that GSD
runs after creating any worktree (both auto-mode and manual /worktree).
The script receives SOURCE_DIR and WORKTREE_DIR as environment
variables, enabling users to copy .env files, symlink asset
directories, or run other setup commands that git worktrees don't
inherit from the main tree.

Implementation:
- Add worktree_post_create field to GitPreferences interface
- Add validation in validatePreferences (must be non-empty string)
- Add runWorktreePostCreateHook() in auto-worktree.ts — resolves
  relative paths against project root, runs with 30s timeout,
  failure is non-fatal (warning only)
- Integrate hook call in createAutoWorktree() (auto-mode path)
- Integrate hook call in worktree-command.ts (manual /worktree path)
- Update docs/configuration.md with full usage guide and example
  hook script
- Update preferences-reference.md with field documentation

Example configuration:
  git:
    worktree_post_create: .gsd/hooks/post-worktree-create

Example hook script:
  #!/bin/bash
  cp "$SOURCE_DIR/.env" "$WORKTREE_DIR/.env"
  ln -sf "$SOURCE_DIR/assets" "$WORKTREE_DIR/assets"

Closes #597

* fix: use Node.js scripts in hook tests for Windows compatibility

Replace bash hook scripts with cross-platform Node.js scripts in
worktree-post-create-hook.test.ts. On macOS/Linux, scripts use
#!/usr/bin/env node shebang. On Windows, generates batch files
that invoke node -e. Fixes windows-portability CI failures.

* fix: Windows CI failures in worktree post-create hook tests

- Use path.isAbsolute() instead of startsWith("/") to detect absolute
  paths on Windows (fixes double-path bug like C:\...\C:\...)
- Add .bat extension to hook scripts on Windows so they are recognized
  as executable by cmd.exe
- Extract isWin constant and hookPath() helper for consistent
  platform-aware test setup

Fixes 3 failing tests in windows-portability CI job:
  - executes hook script with correct env vars
  - supports absolute hook paths
  - hook can copy files from source to worktree

* fix: adopt main's help command and error message in commands.ts

The auto-merge missed main's addition of the help handler, showHelp
function, and updated description/subcommands array. Added them
manually and updated the visualizer help text to reflect 7-tab TUI.

* fix: write Windows hook scripts as .bat + companion .js file

The previous approach embedded multi-line JavaScript in a node -e "..."
argument inside the .bat file. cmd.exe splits on newlines, so each JS
line was interpreted as a separate batch command ('const' is not
recognized...).

Now writes the JS code to a companion .js file and the .bat invokes
it with `node "%~dp0<file>.js"`, which works reliably on Windows.

---------

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Jeremy McSpadden 2026-03-16 10:50:45 -05:00 committed by GitHub
parent e21ebec072
commit 30b688bee0
7 changed files with 263 additions and 1 deletions

View file

@ -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`

View file

@ -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 {

View file

@ -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`.

View file

@ -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_\-\/.]+$/;

View file

@ -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.");

View file

@ -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 });
}
});

View file

@ -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;