* 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:
parent
e21ebec072
commit
30b688bee0
7 changed files with 263 additions and 1 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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_\-\/.]+$/;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue