Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,24 @@ All hooks receive the following environment variables:
| `REPO_ROOT` | Repository root path | all |
| `BASE_BRANCH` | Base branch (create: `--base` value; update: parent branch) | `post-create`, `post-update`, `on-conflict` |

### Hook Execution Model

Hooks are shell strings, not argument arrays. Each command is executed as `sh -c "<command>"` with the worktree as the working directory, so pipes, redirects, `&&`, globs, and variable expansion all work as they do in a terminal:

```jsonc
{
"hooks": {
"post-create": ["pnpm install --frozen-lockfile && cp ../../.env.shared .env"]
}
}
```

This is deliberate. Hook commands come from your own repo config (`.worktreekit.jsonc`, `.worktreekit.local.jsonc`, or the global config), which is the same trust level as a `package.json` script or a git hook — worktree-kit does not escape, sandbox, or validate them. Treat a config file from an untrusted repo the way you would treat its build scripts.

Each command inherits the parent environment plus the hook variables above. A non-zero exit code is reported as a warning and does not abort the command (except `on-conflict`, which is expected to resolve the rebase). Commands time out after 5 minutes.

**Windows:** hooks require `sh` on `PATH`. Under Git Bash or WSL they work normally; under native `cmd.exe` / PowerShell there is no `sh`, so worktree-kit warns that hooks were skipped and continues with the rest of the command. Running hooks through `cmd.exe` or PowerShell is not supported — a config written for `sh` would not survive the translation, and the alternative shells are not interchangeable enough to pick one automatically.

## Migration from `.worktreekitrc`

If you have an existing `.worktreekitrc` config, run:
Expand Down
45 changes: 45 additions & 0 deletions src/application/use-cases/run-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,51 @@ describe("runHooks", () => {
expect(shell.calls[0]?.options.cwd).toBe("/worktrees/feature");
});

test("warns once and skips remaining commands when no shell is available", async () => {
const shell = createFakeShell({
defaultResult: Result.err({ code: "SHELL_UNAVAILABLE", message: "no POSIX shell on PATH" }),
});

const result = await runHooks(
{
commands: ["pnpm install", "cp .env.example .env", "echo done"],
context: defaultContext,
},
{ shell },
);

expect(result.success).toBe(true);
if (result.success) {
expect(result.data.notifications).toHaveLength(1);
expect(result.data.notifications[0]?.level).toBe("warn");
expect(result.data.notifications[0]?.message).toBe("Skipped 3 hook(s): no POSIX shell on PATH");
expect(result.data.failedCommands).toEqual(["pnpm install", "cp .env.example .env", "echo done"]);
}
// Stops after the first attempt — no shell means no command can run.
expect(shell.calls).toHaveLength(1);
});

test("reports only the unrun commands when the shell disappears mid-run", async () => {
const results = new Map();
results.set("second", Result.err({ code: "SHELL_UNAVAILABLE", message: "no POSIX shell on PATH" }));

const shell = createFakeShell({ results });
const result = await runHooks(
{
commands: ["first", "second", "third"],
context: defaultContext,
},
{ shell },
);

expect(result.success).toBe(true);
if (result.success) {
expect(result.data.failedCommands).toEqual(["second", "third"]);
expect(result.data.notifications.map((n) => n.level)).toEqual(["info", "warn"]);
expect(result.data.notifications[1]?.message).toBe("Skipped 2 hook(s): no POSIX shell on PATH");
}
});

test("returns empty results for empty commands", async () => {
const shell = createFakeShell();
const result = await runHooks(
Expand Down
20 changes: 15 additions & 5 deletions src/application/use-cases/run-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,28 @@ export async function runHooks(input: RunHooksInput, deps: RunHooksDeps): Promis
...(context.baseBranch && { BASE_BRANCH: context.baseBranch }),
};

for (const command of commands) {
for (const [index, command] of commands.entries()) {
const result = await shell.execute(command, {
cwd: context.worktreePath,
env,
});

if (!result.success) {
failedCommands.push(command);
notifications.push(N.warn(`Hook failed: "${command}" - ${result.error.message}`));
} else {
if (result.success) {
notifications.push(N.info(`Hook completed: "${command}"`));
continue;
}

// No shell means no hook can ever run — report once and stop instead of
// repeating the same failure for every remaining command.
if (result.error.code === "SHELL_UNAVAILABLE") {
const skipped = commands.slice(index);
failedCommands.push(...skipped);
notifications.push(N.warn(`Skipped ${skipped.length} hook(s): ${result.error.message}`));
break;
}

failedCommands.push(command);
notifications.push(N.warn(`Hook failed: "${command}" - ${result.error.message}`));
}

return R.ok({ notifications, failedCommands });
Expand Down
13 changes: 12 additions & 1 deletion src/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ export function createCommand(container: Container) {
env.BASE_BRANCH = hookContext.baseBranch;
}

let shellUnavailable: { message: string; skipped: number } | undefined;

for (const [i, command] of hookCommands.entries()) {
const message = `Running hook ${i + 1}/${total}: ${command}...`;

Expand All @@ -201,11 +203,20 @@ export function createCommand(container: Container) {
});

if (!result.success) {
if (result.error.code === "SHELL_UNAVAILABLE") {
shellUnavailable = { message: result.error.message, skipped: total - i };
break;
}
ui.warn(`Hook failed: "${command}" - ${result.error.message}`);
}
}

hooksSpinner.stop(pc.green("Hooks completed"));
if (shellUnavailable) {
hooksSpinner.stop(pc.yellow("Hooks skipped"));
ui.warn(`Skipped ${shellUnavailable.skipped} hook(s): ${shellUnavailable.message}`);
} else {
hooksSpinner.stop(pc.green("Hooks completed"));
}
}

ui.success(`Created worktree for branch: ${branch} at ${createResult.data.worktree.path}`);
Expand Down
23 changes: 22 additions & 1 deletion src/cli/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export function removeCommand(container: Container) {
REPO_ROOT: repoRoot,
};

let shellUnavailable: { message: string; skipped: number } | undefined;

for (const [i, command] of preRemoveHooks.entries()) {
const message = `Running pre-remove hook ${i + 1}/${total}: ${command}...`;
if (i === 0) {
Expand All @@ -155,10 +157,20 @@ export function removeCommand(container: Container) {
});

if (!hookResult.success) {
if (hookResult.error.code === "SHELL_UNAVAILABLE") {
shellUnavailable = { message: hookResult.error.message, skipped: total - i };
break;
}
ui.warn(`Pre-remove hook failed: "${command}" - ${hookResult.error.message}`);
}
}
hooksSpinner.stop(pc.green("Pre-remove hooks completed"));

if (shellUnavailable) {
hooksSpinner.stop(pc.yellow("Pre-remove hooks skipped"));
ui.warn(`Skipped ${shellUnavailable.skipped} pre-remove hook(s): ${shellUnavailable.message}`);
} else {
hooksSpinner.stop(pc.green("Pre-remove hooks completed"));
}
}

// Remove worktree
Expand Down Expand Up @@ -231,6 +243,7 @@ export function removeCommand(container: Container) {
const ms = ui.createMultiSpinner(keys);
const warnings: string[] = [];
const unmergedBranches: string[] = [];
let shellUnavailableMessage: string | undefined;

await Promise.all(
worktreesToRemove.map(async (wt) => {
Expand All @@ -251,6 +264,10 @@ export function removeCommand(container: Container) {
env,
});
if (!hookResult.success) {
if (hookResult.error.code === "SHELL_UNAVAILABLE") {
shellUnavailableMessage = hookResult.error.message;
break;
}
warnings.push(`Hook failed for "${displayLabel}": ${command}`);
}
}
Expand Down Expand Up @@ -298,6 +315,10 @@ export function removeCommand(container: Container) {

ms.stop();

if (shellUnavailableMessage) {
warnings.push(`Pre-remove hooks skipped: ${shellUnavailableMessage}`);
}

for (const warning of warnings) {
ui.warn(warning);
}
Expand Down
6 changes: 5 additions & 1 deletion src/domain/ports/shell-port.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Result } from "../../shared/result.ts";

export interface ShellError {
readonly code: "EXECUTION_FAILED" | "TIMEOUT" | "UNKNOWN";
/**
* `SHELL_UNAVAILABLE` — the platform provides no POSIX shell to run the command with.
* Commands are shell strings, so there is no fallback: callers should report and skip.
*/
readonly code: "EXECUTION_FAILED" | "TIMEOUT" | "SHELL_UNAVAILABLE" | "UNKNOWN";
readonly message: string;
readonly exitCode?: number;
readonly stderr?: string;
Expand Down
60 changes: 60 additions & 0 deletions src/infrastructure/adapters/bun-shell-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import { expectErr, expectOk } from "../../test-utils/assertions.ts";
import { createNoopLogger } from "../../test-utils/noop-logger.ts";
import { createTempDir } from "../../test-utils/temp-dir.ts";
import { createBunShellAdapter } from "./bun-shell-adapter.ts";

describe("BunShellAdapter", () => {
test("runs the command through the resolved shell", async () => {
await using tmp = await createTempDir();
const shell = createBunShellAdapter(createNoopLogger());

const result = expectOk(await shell.execute("echo hello", { cwd: tmp.path }));

expect(result.stdout).toBe("hello");
expect(result.exitCode).toBe(0);
});

test("passes env variables to the command", async () => {
await using tmp = await createTempDir();
const shell = createBunShellAdapter(createNoopLogger());

const result = expectOk(
await shell.execute("echo $WORKTREE_BRANCH", { cwd: tmp.path, env: { WORKTREE_BRANCH: "feature" } }),
);

expect(result.stdout).toBe("feature");
});

test("returns EXECUTION_FAILED for a non-zero exit code", async () => {
await using tmp = await createTempDir();
const shell = createBunShellAdapter(createNoopLogger());

const error = expectErr(await shell.execute("exit 3", { cwd: tmp.path }));

expect(error.code).toBe("EXECUTION_FAILED");
expect(error.exitCode).toBe(3);
});

test("returns SHELL_UNAVAILABLE when sh is not on PATH", async () => {
const shell = createBunShellAdapter(createNoopLogger(), () => null);

const error = expectErr(await shell.execute("echo hello", { cwd: process.cwd() }));

expect(error.code).toBe("SHELL_UNAVAILABLE");
expect(error.message).toContain("sh -c");
});

test("resolves the shell once and reuses the lookup", async () => {
const lookups: string[] = [];
const shell = createBunShellAdapter(createNoopLogger(), (cmd) => {
lookups.push(cmd);
return null;
});

await shell.execute("echo one", { cwd: process.cwd() });
await shell.execute("echo two", { cwd: process.cwd() });

expect(lookups).toEqual(["sh"]);
});
});
29 changes: 27 additions & 2 deletions src/infrastructure/adapters/bun-shell-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,43 @@ import { Result } from "../../shared/result.ts";

const DEFAULT_TIMEOUT = 5 * 60 * 1000; // 5 minutes

export function createBunShellAdapter(logger: LoggerPort): ShellPort {
const SHELL_UNAVAILABLE_MESSAGE =
"no POSIX shell on PATH — commands are shell strings run via `sh -c`, which native Windows does not provide (run wt from Git Bash or WSL)";

/** Resolves an executable to its absolute path, or `null` when it is not on PATH. */
export type WhichFn = (command: string) => string | null;

export function createBunShellAdapter(logger: LoggerPort, which: WhichFn = (cmd) => Bun.which(cmd)): ShellPort {
let shellPath: string | null | undefined;

function resolveShell(): string | null {
if (shellPath === undefined) {
shellPath = which("sh");
logger.debug("shell", `sh -> ${shellPath ?? "not found"}`);
}
return shellPath;
}

return {
async execute(command: string, options: ShellExecuteOptions): Promise<Result<ShellExecuteResult, ShellError>> {
const { cwd, env = {}, timeout = DEFAULT_TIMEOUT } = options;

logger.debug("shell", command);
logger.debug("shell", `cwd: ${cwd}`);

const sh = resolveShell();
if (sh === null) {
logger.debug("shell", "-> SHELL_UNAVAILABLE");
return Result.err({
code: "SHELL_UNAVAILABLE",
message: SHELL_UNAVAILABLE_MESSAGE,
});
}

const startTime = Date.now();

try {
const proc = Bun.spawn(["sh", "-c", command], {
const proc = Bun.spawn([sh, "-c", command], {
cwd,
env: { ...process.env, ...env },
stdout: "pipe",
Expand Down