Skip to content
Open
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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,40 @@ wt remove [pathOrBranch] [options]

**Interactive Selection**: Run `wt remove` without arguments to select a worktree to remove.

**Cleanup Scripts**: If `cleanup-worktree` is defined in `worktrees.json`, those commands will be executed before removal (with user confirmation).

Options:
- `-f, --force`: Force removal without confirmation
- `-s, --skip-cleanup`: Skip cleanup scripts defined in worktrees.json
- `-t, --trust`: Trust and run cleanup commands without confirmation (for CI environments)

Example:
```bash
wt remove # Interactive selection
wt remove feature/login # Remove by branch name
wt remove ./path/to/worktree # Remove by path
wt remove feature/old -f # Force remove
wt remove # Interactive selection
wt remove feature/login # Remove by branch name
wt remove ./path/to/worktree # Remove by path
wt remove feature/old -f # Force remove
wt remove feature/old -s # Skip cleanup scripts
wt remove feature/old -t # Run cleanup without confirmation
```

### Cleanup Worktree Configuration

You can define cleanup commands that run automatically when removing a worktree:

```json
{
"setup-worktree": ["npm install"],
"cleanup-worktree": ["docker-compose down", "echo 'Cleanup complete'"]
}
```

Cleanup behavior:
- **Automatic execution**: Runs before `wt remove` (after confirmation)
- **Skip option**: Use `--skip-cleanup` or `-s` to bypass
- **Trust mode**: Use `--trust` to run without confirmation (CI environments)
- **Environment variables**: `$ROOT_WORKTREE_PATH` is available

### Purge multiple worktrees

```bash
Expand Down
5 changes: 5 additions & 0 deletions build/commands/remove.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { stat, rm } from "node:fs/promises";
import { findWorktreeByBranch, findWorktreeByPath } from "../utils/git.js";
import { selectWorktree, confirm } from "../utils/tui.js";
import { withSpinner } from "../utils/spinner.js";
import { runCleanupScriptsSecure } from "../utils/setup.js";
export async function removeWorktreeHandler(pathOrBranch = "", options) {
try {
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
Expand Down Expand Up @@ -70,6 +71,10 @@ export async function removeWorktreeHandler(pathOrBranch = "", options) {
process.exit(0);
}
}
// Execute cleanup scripts if not skipped
if (!options.skipCleanup) {
await runCleanupScriptsSecure(targetPath, { trust: options.trust });
}
// Remove the worktree
try {
await withSpinner(`Removing worktree: ${targetPath}`, async () => {
Expand Down
4 changes: 3 additions & 1 deletion build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ program
.alias("rm")
.argument("[pathOrBranch]", "Path of the worktree or branch to remove.")
.option("-f, --force", "Force removal of worktree and deletion of the folder", false)
.description("Remove a specified worktree. Cleans up the .git/worktrees references.")
.option("-s, --skip-cleanup", "Skip cleanup scripts defined in worktrees.json", false)
.option("-t, --trust", "Trust and run cleanup commands without confirmation (for CI environments)", false)
.description("Remove a specified worktree. Runs cleanup-worktree scripts from worktrees.json before removal.")
.action(removeWorktreeHandler);
program
.command("merge")
Expand Down
75 changes: 75 additions & 0 deletions build/utils/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,78 @@ export async function runSetupScripts(worktreePath) {
return false;
}
}
/**
* Load and parse cleanup commands from worktrees.json
*/
async function loadCleanupCommands(repoRoot) {
const paths = [
join(repoRoot, ".cursor", "worktrees.json"),
join(repoRoot, "worktrees.json"),
];
for (const configPath of paths) {
try {
await stat(configPath);
const content = await readFile(configPath, "utf-8");
const data = JSON.parse(content);
if (data && typeof data === 'object' && !Array.isArray(data) && Array.isArray(data["cleanup-worktree"])) {
const commands = data["cleanup-worktree"];
if (commands.length > 0) {
return { commands, filePath: configPath };
}
}
}
catch {
// Not found, try next
}
}
return null;
}
/**
* Execute cleanup commands before worktree removal (SECURE)
*
* This function loads cleanup-worktree commands from worktrees.json and executes them
* with user confirmation (unless --trust flag is set).
*
* @param worktreePath - Path to the worktree where commands should be executed
* @param options - Execution options (trust flag bypasses confirmation)
* @returns true if cleanup commands were found and executed, false if no cleanup commands exist
*/
export async function runCleanupScriptsSecure(worktreePath, options = {}) {
const repoRoot = await getRepoRoot();
if (!repoRoot) {
return false;
}
const cleanupResult = await loadCleanupCommands(repoRoot);
if (!cleanupResult) {
return false;
}
console.log(chalk.blue(`Found cleanup config: ${cleanupResult.filePath}`));
// Show commands and ask for confirmation (unless --trust flag is set)
const shouldRun = await confirmCommands(cleanupResult.commands, {
title: "The following cleanup commands will be executed:",
trust: options.trust,
});
if (!shouldRun) {
console.log(chalk.yellow("Cleanup commands skipped."));
return false;
}
// Execute commands
const env = { ...process.env, ROOT_WORKTREE_PATH: repoRoot };
for (const command of cleanupResult.commands) {
console.log(chalk.gray(`Executing: ${command}`));
try {
await execa(command, { shell: true, cwd: worktreePath, env, stdio: "inherit" });
}
catch (cmdError) {
if (cmdError instanceof Error) {
console.error(chalk.red(`Cleanup command failed: ${command}`), cmdError.message);
}
else {
console.error(chalk.red(`Cleanup command failed: ${command}`), cmdError);
}
// Continue with other commands
}
}
console.log(chalk.green("Cleanup commands completed."));
return true;
}
8 changes: 7 additions & 1 deletion src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { resolve } from "node:path";
import { getWorktrees, findWorktreeByBranch, findWorktreeByPath, WorktreeInfo } from "../utils/git.js";
import { selectWorktree, confirm } from "../utils/tui.js";
import { withSpinner } from "../utils/spinner.js";
import { runCleanupScriptsSecure } from "../utils/setup.js";

export async function removeWorktreeHandler(
pathOrBranch: string = "",
options: { force?: boolean }
options: { force?: boolean; skipCleanup?: boolean; trust?: boolean }
) {
try {
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
Expand Down Expand Up @@ -85,6 +86,11 @@ export async function removeWorktreeHandler(
}
}

// Execute cleanup scripts if not skipped
if (!options.skipCleanup) {
await runCleanupScriptsSecure(targetPath, { trust: options.trust });
}

// Remove the worktree
try {
await withSpinner(
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,18 @@ program
"Force removal of worktree and deletion of the folder",
false
)
.option(
"-s, --skip-cleanup",
"Skip cleanup scripts defined in worktrees.json",
false
)
.option(
"-t, --trust",
"Trust and run cleanup commands without confirmation (for CI environments)",
false
)
.description(
"Remove a specified worktree. Cleans up the .git/worktrees references."
"Remove a specified worktree. Runs cleanup-worktree scripts from worktrees.json before removal."
)
.action(removeWorktreeHandler);

Expand Down
87 changes: 87 additions & 0 deletions src/utils/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { confirmCommands } from "./tui.js";

interface WorktreeSetupData {
"setup-worktree"?: string[];
"cleanup-worktree"?: string[];
[key: string]: unknown;
}

Expand Down Expand Up @@ -212,3 +213,89 @@ export async function runSetupScripts(worktreePath: string): Promise<boolean> {
return false;
}
}

/**
* Load and parse cleanup commands from worktrees.json
*/
async function loadCleanupCommands(repoRoot: string): Promise<{ commands: string[]; filePath: string } | null> {
const paths = [
join(repoRoot, ".cursor", "worktrees.json"),
join(repoRoot, "worktrees.json"),
];

for (const configPath of paths) {
try {
await stat(configPath);
const content = await readFile(configPath, "utf-8");
const data = JSON.parse(content) as WorktreeSetupData;

if (data && typeof data === 'object' && !Array.isArray(data) && Array.isArray(data["cleanup-worktree"])) {
const commands = data["cleanup-worktree"];
if (commands.length > 0) {
return { commands, filePath: configPath };
}
}
} catch {
// Not found, try next
}
}

return null;
}

/**
* Execute cleanup commands before worktree removal (SECURE)
*
* This function loads cleanup-worktree commands from worktrees.json and executes them
* with user confirmation (unless --trust flag is set).
*
* @param worktreePath - Path to the worktree where commands should be executed
* @param options - Execution options (trust flag bypasses confirmation)
* @returns true if cleanup commands were found and executed, false if no cleanup commands exist
*/
export async function runCleanupScriptsSecure(
worktreePath: string,
options: { trust?: boolean } = {}
): Promise<boolean> {
const repoRoot = await getRepoRoot();
if (!repoRoot) {
return false;
}

const cleanupResult = await loadCleanupCommands(repoRoot);

if (!cleanupResult) {
return false;
}

console.log(chalk.blue(`Found cleanup config: ${cleanupResult.filePath}`));

// Show commands and ask for confirmation (unless --trust flag is set)
const shouldRun = await confirmCommands(cleanupResult.commands, {
title: "The following cleanup commands will be executed:",
trust: options.trust,
});

if (!shouldRun) {
console.log(chalk.yellow("Cleanup commands skipped."));
return false;
}

// Execute commands
const env = { ...process.env, ROOT_WORKTREE_PATH: repoRoot };
for (const command of cleanupResult.commands) {
console.log(chalk.gray(`Executing: ${command}`));
try {
await execa(command, { shell: true, cwd: worktreePath, env, stdio: "inherit" });
} catch (cmdError: unknown) {
if (cmdError instanceof Error) {
console.error(chalk.red(`Cleanup command failed: ${command}`), cmdError.message);
} else {
console.error(chalk.red(`Cleanup command failed: ${command}`), cmdError);
}
// Continue with other commands
}
}
console.log(chalk.green("Cleanup commands completed."));
return true;
}