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
19 changes: 18 additions & 1 deletion src/commands/mv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { getConfig } from "../lib/config";
import { getRepoInfo, isInsideGitRepo } from "../lib/git/repo";
import { findWorktree, moveWorktree } from "../lib/git/worktree";
import { executeHook, getHookConfig } from "../lib/hooks";
import { resolveWorktreePath } from "../lib/paths";
import {
CannotRemovePrimaryError,
Expand Down Expand Up @@ -56,11 +57,14 @@ export async function mv(oldName: string, newName: string): Promise<void> {
process.exit(1);
}

const { worktreeBase } = await getConfig(
const { worktreeBase, hooks } = await getConfig(
repoInfo.worktreeRoot,
repoInfo.repoId,
);

// Store old path before move
const oldPath = oldWorktree.path;

// Determine new path
const newPath = resolveWorktreePath(repoInfo.repoId, newName, worktreeBase);

Expand All @@ -75,6 +79,19 @@ export async function mv(oldName: string, newName: string): Promise<void> {
}

console.log(formatSuccess(`Renamed worktree '${oldName}' to '${newName}'`));

// Execute post-rename hook
const hookConfig = getHookConfig(hooks, "post-rename");
if (hookConfig) {
await executeHook("post-rename", hookConfig, {
name: newName,
path: newPath,
branch: oldWorktree.branch ?? "",
repoId: repoInfo.repoId,
oldName,
oldPath,
});
}
} catch (error) {
console.error(
formatError(error instanceof Error ? error.message : String(error)),
Expand Down
16 changes: 14 additions & 2 deletions src/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../lib/constants";
import { getRepoInfo, isInsideGitRepo } from "../lib/git/repo";
import { createWorktree, findWorktree } from "../lib/git/worktree";
import { executeHook, getHookConfig } from "../lib/hooks";
import { resolveWorktreePath } from "../lib/paths";
import type { CreateWorktreeOptions } from "../lib/types";
import {
Expand Down Expand Up @@ -73,8 +74,8 @@ export async function newWorktree(
process.exit(1);
}

// Get config for branch prefix
const { branchPrefix, worktreeBase } = await getConfig(
// Get config for branch prefix and hooks
const { branchPrefix, worktreeBase, hooks } = await getConfig(
repoInfo.worktreeRoot,
repoInfo.repoId,
);
Expand Down Expand Up @@ -109,6 +110,17 @@ export async function newWorktree(

console.log(formatSuccess(`Created worktree '${name}'`));
console.log(formatHint(`cd to it with: wt cd ${name}`));

// Execute post-create hook
const hookConfig = getHookConfig(hooks, "post-create");
if (hookConfig) {
await executeHook("post-create", hookConfig, {
name,
path: worktreePath,
branch: options.detach ? "" : branchName,
repoId: repoInfo.repoId,
});
}
} catch (error) {
console.error(
formatError(error instanceof Error ? error.message : String(error)),
Expand Down
34 changes: 33 additions & 1 deletion src/commands/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* wt rm - Remove a worktree
*/

import { isInsideGitRepo } from "../lib/git/repo";
import { getConfig } from "../lib/config";
import { getRepoInfo, isInsideGitRepo } from "../lib/git/repo";
import { findWorktree, removeWorktree } from "../lib/git/worktree";
import { executeHook, getHookConfig } from "../lib/hooks";
import {
CannotRemovePrimaryError,
NotInRepoError,
Expand Down Expand Up @@ -31,6 +33,13 @@ export async function rm(name: string, options: RmOptions = {}): Promise<void> {
}

try {
// Get repo info for hooks
const repoInfo = await getRepoInfo();
if (!repoInfo) {
console.error(formatError("Could not determine repository information"));
process.exit(1);
}

// Find the worktree
const worktree = await findWorktree(name);
if (!worktree) {
Expand All @@ -48,6 +57,13 @@ export async function rm(name: string, options: RmOptions = {}): Promise<void> {
process.exit(1);
}

// Store worktree info for hook (before deletion)
const deletedWorktreePath = worktree.path;
const deletedWorktreeBranch = worktree.branch ?? "";

// Get config for hooks
const { hooks } = await getConfig(repoInfo.worktreeRoot, repoInfo.repoId);

// Remove the worktree
const result = await removeWorktree(worktree.path, {
force: options.force,
Expand All @@ -62,6 +78,22 @@ export async function rm(name: string, options: RmOptions = {}): Promise<void> {

console.log(formatSuccess(`Removed worktree '${name}'`));

// Execute post-delete hook (from repo root, since worktree is deleted)
const hookConfig = getHookConfig(hooks, "post-delete");
if (hookConfig) {
await executeHook(
"post-delete",
hookConfig,
{
name,
path: deletedWorktreePath,
branch: deletedWorktreeBranch,
repoId: repoInfo.repoId,
},
repoInfo.worktreeRoot, // Run from repo root
);
}

// Handle branch deletion
if (worktree.branch) {
if (options.deleteBranch) {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ export const GLOBAL_CONFIG_PATH = join(
"config.json",
);

/** Hook types supported by the system */
export type HookType =
| "post-create"
| "post-select"
| "post-delete"
| "post-rename";

/** Schema for hook command configuration */
export const HookCommandSchema = z.union([
z.string(),
z.array(z.string()),
z.object({
commands: z.union([z.string(), z.array(z.string())]),
timeout: z.number().positive().optional(),
continueOnError: z.boolean().optional(),
}),
]);

export type HookCommand = z.infer<typeof HookCommandSchema>;

/** Schema for hooks configuration */
export const HooksSchema = z
.object({
"post-create": HookCommandSchema.optional(),
"post-select": HookCommandSchema.optional(),
"post-delete": HookCommandSchema.optional(),
"post-rename": HookCommandSchema.optional(),
})
.optional();

export type HooksConfig = z.infer<typeof HooksSchema>;

/** Schema for per-repo overrides */
const RepoOverrideSchema = z.object({
branchPrefix: z
Expand All @@ -25,6 +57,7 @@ const RepoOverrideSchema = z.object({
.optional(),
base: z.string().optional(),
comparisonBranch: z.string().optional(),
hooks: HooksSchema,
});

/** Schema for global configuration */
Expand All @@ -42,6 +75,7 @@ export const ConfigSchema = z.object({
comparisonBranch: z.string().optional(),
})
.default({ branchPrefix: "", staleDays: 30 }),
hooks: HooksSchema,
repos: z.record(z.string(), RepoOverrideSchema).optional(),
});

Expand Down Expand Up @@ -103,6 +137,7 @@ export async function getConfig(
branchPrefix: string;
worktreeBase: string;
comparisonBranch?: string;
hooks: HooksConfig;
}> {
const config = await loadGlobalConfig();

Expand All @@ -120,11 +155,22 @@ export async function getConfig(
const comparisonBranch =
repoOverride?.comparisonBranch ?? config.defaults.comparisonBranch;

// Resolve hooks: repo-specific hooks completely replace global hooks per hook type
const globalHooks = config.hooks ?? {};
const repoHooks = repoOverride?.hooks ?? {};
const hooks: HooksConfig = {
"post-create": repoHooks["post-create"] ?? globalHooks["post-create"],
"post-select": repoHooks["post-select"] ?? globalHooks["post-select"],
"post-delete": repoHooks["post-delete"] ?? globalHooks["post-delete"],
"post-rename": repoHooks["post-rename"] ?? globalHooks["post-rename"],
};

return {
config,
branchPrefix,
worktreeBase,
comparisonBranch,
hooks,
};
}

Expand Down
66 changes: 66 additions & 0 deletions src/lib/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Unit tests for hooks module
*/

import { describe, expect, test } from "bun:test";
import { normalizeHookConfig } from "./hooks";

describe("normalizeHookConfig", () => {
test("returns null for undefined config", () => {
const result = normalizeHookConfig(undefined);
expect(result).toBeNull();
});

test("normalizes string format", () => {
const result = normalizeHookConfig("npm install");

expect(result).not.toBeNull();
expect(result?.commands).toEqual(["npm install"]);
expect(result?.timeout).toBe(30);
expect(result?.continueOnError).toBe(false);
});

test("normalizes array format", () => {
const result = normalizeHookConfig(["npm install", "npm run build"]);

expect(result).not.toBeNull();
expect(result?.commands).toEqual(["npm install", "npm run build"]);
expect(result?.timeout).toBe(30);
expect(result?.continueOnError).toBe(false);
});

test("normalizes object format with string commands", () => {
const result = normalizeHookConfig({
commands: "npm install",
timeout: 60,
continueOnError: true,
});

expect(result).not.toBeNull();
expect(result?.commands).toEqual(["npm install"]);
expect(result?.timeout).toBe(60);
expect(result?.continueOnError).toBe(true);
});

test("normalizes object format with array commands", () => {
const result = normalizeHookConfig({
commands: ["npm install", "npm run build"],
timeout: 120,
});

expect(result).not.toBeNull();
expect(result?.commands).toEqual(["npm install", "npm run build"]);
expect(result?.timeout).toBe(120);
expect(result?.continueOnError).toBe(false);
});

test("uses defaults for missing object fields", () => {
const result = normalizeHookConfig({
commands: "echo test",
});

expect(result).not.toBeNull();
expect(result?.timeout).toBe(30);
expect(result?.continueOnError).toBe(false);
});
});
Loading