Skip to content

Commit 0ba8d9b

Browse files
committed
feat: add new extract command
1 parent 342b320 commit 0ba8d9b

10 files changed

Lines changed: 472 additions & 14 deletions

File tree

build/commands/extract.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { execa } from "execa";
2+
import chalk from "chalk";
3+
import { stat } from "node:fs/promises";
4+
import { resolve, join, dirname, basename } from "node:path";
5+
import { getDefaultEditor } from "../config.js";
6+
import { isWorktreeClean, isMainRepoBare } from "../utils/git.js";
7+
export async function extractWorktreeHandler(branchName, options = {}) {
8+
try {
9+
// 1. Validate we're in a git repo
10+
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
11+
console.log(chalk.blue("Checking if main worktree is clean..."));
12+
const isClean = await isWorktreeClean(".");
13+
if (!isClean) {
14+
console.error(chalk.red("❌ Error: Your main worktree is not clean."));
15+
console.error(chalk.yellow("Creating a new worktree requires a clean main worktree state."));
16+
console.error(chalk.cyan("Please commit, stash, or discard your changes. Run 'git status' to see the changes."));
17+
process.exit(1);
18+
}
19+
else {
20+
console.log(chalk.green("✅ Main worktree is clean."));
21+
}
22+
// 2. Determine which branch to extract
23+
let selectedBranch = branchName;
24+
if (!selectedBranch) {
25+
// Get current branch if no branch specified
26+
const { stdout: currentBranch } = await execa("git", ["branch", "--show-current"]);
27+
selectedBranch = currentBranch.trim();
28+
if (!selectedBranch) {
29+
console.error(chalk.red("❌ Error: Could not determine current branch (possibly in detached HEAD state)."));
30+
console.error(chalk.yellow("Please specify a branch name: wt extract <branch-name>"));
31+
process.exit(1);
32+
}
33+
console.log(chalk.blue(`No branch specified. Using current branch: ${selectedBranch}`));
34+
}
35+
// 3. Get existing worktrees to check if branch already has one
36+
const { stdout: worktrees } = await execa("git", ["worktree", "list", "--porcelain"]);
37+
const worktreeBranches = worktrees
38+
.split('\n')
39+
.filter(line => line.startsWith('branch refs/heads/'))
40+
.map(line => line.replace('branch refs/heads/', ''));
41+
if (worktreeBranches.includes(selectedBranch)) {
42+
console.error(chalk.red(`❌ Error: Branch "${selectedBranch}" already has a worktree.`));
43+
console.error(chalk.yellow("Use 'wt list' to see existing worktrees."));
44+
process.exit(1);
45+
}
46+
// 4. Verify the branch exists (either locally or remotely)
47+
const { stdout: localBranches } = await execa("git", ["branch", "--format=%(refname:short)"]);
48+
const { stdout: remoteBranches } = await execa("git", ["branch", "-r", "--format=%(refname:short)"]);
49+
const localBranchList = localBranches.split('\n').filter(b => b.trim() !== '');
50+
const remoteBranchList = remoteBranches
51+
.split('\n')
52+
.filter(b => b.trim() !== '' && b.startsWith('origin/'))
53+
.map(b => b.replace('origin/', ''));
54+
const branchExistsLocally = localBranchList.includes(selectedBranch);
55+
const branchExistsRemotely = remoteBranchList.includes(selectedBranch);
56+
if (!branchExistsLocally && !branchExistsRemotely) {
57+
console.error(chalk.red(`❌ Error: Branch "${selectedBranch}" does not exist locally or remotely.`));
58+
process.exit(1);
59+
}
60+
// 5. Build final path for the new worktree
61+
let folderName;
62+
if (options.path) {
63+
folderName = options.path;
64+
}
65+
else {
66+
// Derive the short name for the directory from the branch name
67+
const shortBranchName = selectedBranch.split('/').filter(part => part.length > 0).pop() || selectedBranch;
68+
const currentDir = process.cwd();
69+
const parentDir = dirname(currentDir);
70+
const currentDirName = basename(currentDir);
71+
// Create a sibling directory using the short branch name
72+
folderName = join(parentDir, `${currentDirName}-${shortBranchName}`);
73+
}
74+
const resolvedPath = resolve(folderName);
75+
// Check if directory already exists
76+
try {
77+
await stat(resolvedPath);
78+
console.error(chalk.red(`❌ Error: Directory already exists at: ${resolvedPath}`));
79+
console.error(chalk.yellow("Please choose a different path with --path option."));
80+
process.exit(1);
81+
}
82+
catch (error) {
83+
// Directory doesn't exist, continue with creation
84+
}
85+
// 6. Check if this is a bare repository
86+
if (await isMainRepoBare()) {
87+
console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true)."));
88+
console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:"));
89+
console.error(chalk.cyan(" git config core.bare false"));
90+
process.exit(1);
91+
}
92+
// 7. Create the worktree
93+
console.log(chalk.blue(`Extracting branch "${selectedBranch}" to worktree at: ${resolvedPath}`));
94+
// Check if we need to fetch the branch first (if it's only remote)
95+
if (!branchExistsLocally && branchExistsRemotely) {
96+
console.log(chalk.yellow(`Branch "${selectedBranch}" is remote-only. Creating local tracking branch...`));
97+
// Create worktree with remote branch
98+
await execa("git", ["worktree", "add", "--track", "-b", selectedBranch, resolvedPath, `origin/${selectedBranch}`]);
99+
}
100+
else {
101+
// Branch exists locally
102+
await execa("git", ["worktree", "add", resolvedPath, selectedBranch]);
103+
}
104+
console.log(chalk.green(`✅ Successfully extracted branch "${selectedBranch}" to worktree.`));
105+
// 8. (Optional) Install dependencies if --install flag is provided
106+
if (options.install) {
107+
console.log(chalk.blue(`Installing dependencies using ${options.install} in ${resolvedPath}...`));
108+
await execa(options.install, ["install"], { cwd: resolvedPath, stdio: "inherit" });
109+
}
110+
// 9. Open in the specified editor (or use configured default)
111+
const configuredEditor = getDefaultEditor();
112+
const editorCommand = options.editor || configuredEditor;
113+
console.log(chalk.blue(`Opening ${resolvedPath} in ${editorCommand}...`));
114+
try {
115+
await execa(editorCommand, [resolvedPath], { stdio: "inherit" });
116+
}
117+
catch (editorError) {
118+
console.error(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`));
119+
console.warn(chalk.yellow(`Continuing without opening editor.`));
120+
}
121+
console.log(chalk.green(`\n✅ Worktree extracted at ${resolvedPath}.`));
122+
if (options.install)
123+
console.log(chalk.green(`✅ Dependencies installed using ${options.install}.`));
124+
console.log(chalk.green(`✅ Attempted to open in ${editorCommand}.`));
125+
}
126+
catch (error) {
127+
if (error instanceof Error) {
128+
console.error(chalk.red("Failed to extract worktree:"), error.message);
129+
}
130+
else {
131+
console.error(chalk.red("Failed to extract worktree:"), error);
132+
}
133+
process.exit(1);
134+
}
135+
}

build/commands/merge.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execa } from "execa";
22
import chalk from "chalk";
33
import { stat, rm } from "node:fs/promises";
4+
import { isMainRepoBare } from "../utils/git.js";
45
export async function mergeWorktreeHandler(branchName, options) {
56
try {
67
// Validate that we're in a git repository
@@ -54,6 +55,12 @@ export async function mergeWorktreeHandler(branchName, options) {
5455
console.log(chalk.green(`Merged branch "${branchName}" into "${currentBranch}".`));
5556
// Step 3: Remove the worktree for the merged branch (similar to 'wt remove')
5657
console.log(chalk.blue(`Removing worktree for branch "${branchName}"...`));
58+
if (await isMainRepoBare()) {
59+
console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true)."));
60+
console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:"));
61+
console.error(chalk.cyan(" git config core.bare false"));
62+
process.exit(1);
63+
}
5764
const removeArgs = ["worktree", "remove", ...(options.force ? ["--force"] : []), targetPath];
5865
await execa("git", removeArgs);
5966
console.log(chalk.green(`Removed worktree at ${targetPath}.`));

build/commands/new.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,18 @@ import chalk from "chalk";
33
import { stat } from "node:fs/promises";
44
import { resolve, join, dirname, basename } from "node:path";
55
import { getDefaultEditor } from "../config.js";
6-
import { isWorktreeClean } from "../utils/git.js";
6+
import { isWorktreeClean, isMainRepoBare } from "../utils/git.js";
77
export async function newWorktreeHandler(branchName = "main", options) {
88
try {
99
// 1. Validate we're in a git repo
1010
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
1111
console.log(chalk.blue("Checking if main worktree is clean..."));
1212
const isClean = await isWorktreeClean(".");
1313
if (!isClean) {
14-
console.warn(chalk.yellow("⚠️ Warning: Your main worktree is not clean."));
15-
console.warn(chalk.yellow("While 'wt new' might succeed, it's generally recommended to have a clean state."));
16-
console.warn(chalk.cyan("Run 'git status' to review changes. Consider committing or stashing."));
17-
// Decide whether to exit or just warn. Warning might be sufficient here.
18-
// process.exit(1);
14+
console.error(chalk.red("❌ Error: Your main worktree is not clean."));
15+
console.error(chalk.yellow("Creating a new worktree requires a clean main worktree state."));
16+
console.error(chalk.cyan("Please commit, stash, or discard your changes. Run 'git status' to see the changes."));
17+
process.exit(1); // Exit if not clean
1918
}
2019
else {
2120
console.log(chalk.green("✅ Main worktree is clean."));
@@ -71,6 +70,12 @@ export async function newWorktreeHandler(branchName = "main", options) {
7170
}
7271
else {
7372
console.log(chalk.blue(`Creating new worktree for branch "${branchName}" at: ${resolvedPath}`));
73+
if (await isMainRepoBare()) {
74+
console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true)."));
75+
console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:"));
76+
console.error(chalk.cyan(" git config core.bare false"));
77+
process.exit(1);
78+
}
7479
if (!branchExists) {
7580
console.log(chalk.yellow(`Branch "${branchName}" doesn't exist. Creating new branch with worktree...`));
7681
// Create a new branch and worktree in one command with -b flag

build/commands/open.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { execa } from "execa";
2+
import chalk from "chalk";
3+
import { stat } from "node:fs/promises";
4+
import { resolve } from "node:path";
5+
import { getDefaultEditor } from "../config.js";
6+
export async function openWorktreeHandler(pathOrBranch = "", options) {
7+
try {
8+
// 1. Validate we're in a git repo
9+
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
10+
if (!pathOrBranch) {
11+
console.error(chalk.red("You must specify a path or branch name for the worktree."));
12+
process.exit(1);
13+
}
14+
// If the user gave us a path, we can open directly.
15+
// If user gave us a branch name, we parse `git worktree list` to find the matching path.
16+
let targetPath = pathOrBranch;
17+
// Try to see if it's a valid path
18+
let isDirectory = false;
19+
try {
20+
const stats = await stat(pathOrBranch);
21+
isDirectory = stats.isDirectory();
22+
}
23+
catch {
24+
isDirectory = false;
25+
}
26+
if (!isDirectory) {
27+
// If it's not a directory, assume it's a branch name:
28+
const { stdout } = await execa("git", [
29+
"worktree",
30+
"list",
31+
"--porcelain",
32+
]);
33+
// The --porcelain output is structured. We'll parse lines and find the "worktree <path>" and "branch refs/heads/<branchName>"
34+
const entries = stdout.split("\n");
35+
let currentPath = null;
36+
let foundPath = false;
37+
for (const line of entries) {
38+
if (line.startsWith("worktree ")) {
39+
currentPath = line.replace("worktree ", "").trim();
40+
}
41+
else if (line.startsWith("branch ")) {
42+
const fullBranchRef = line.replace("branch ", "").trim(); // e.g. refs/heads/my-branch
43+
const shortBranch = fullBranchRef.replace("refs/heads/", "");
44+
if (shortBranch === pathOrBranch && currentPath) {
45+
targetPath = currentPath;
46+
foundPath = true;
47+
break;
48+
}
49+
}
50+
}
51+
if (!foundPath) {
52+
console.error(chalk.red(`Could not find a worktree for branch "${pathOrBranch}".`));
53+
process.exit(1);
54+
}
55+
}
56+
// Verify the target path exists and is a git worktree
57+
try {
58+
await stat(targetPath);
59+
await stat(resolve(targetPath, ".git")); // Check if it's a git worktree
60+
}
61+
catch (error) {
62+
console.error(chalk.red(`The path "${targetPath}" does not exist or is not a git worktree.`));
63+
process.exit(1);
64+
}
65+
// Open in the specified editor (or use configured default)
66+
const configuredEditor = getDefaultEditor();
67+
const editorCommand = options.editor || configuredEditor;
68+
console.log(chalk.blue(`Opening ${targetPath} in ${editorCommand}...`));
69+
try {
70+
await execa(editorCommand, [targetPath], { stdio: "inherit" });
71+
console.log(chalk.green(`Successfully opened worktree in ${editorCommand}.`));
72+
}
73+
catch (editorError) {
74+
console.error(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`));
75+
process.exit(1);
76+
}
77+
}
78+
catch (error) {
79+
if (error instanceof Error) {
80+
console.error(chalk.red("Failed to open worktree:"), error.message);
81+
}
82+
else {
83+
console.error(chalk.red("Failed to open worktree:"), error);
84+
}
85+
process.exit(1);
86+
}
87+
}

build/commands/pr.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import chalk from "chalk";
33
import { stat } from "node:fs/promises";
44
import { resolve, join, dirname, basename } from "node:path";
55
import { getDefaultEditor } from "../config.js";
6-
import { getCurrentBranch, isWorktreeClean } from "../utils/git.js";
6+
import { getCurrentBranch, isWorktreeClean, isMainRepoBare } from "../utils/git.js";
77
// Helper function to get PR branch name using gh cli
88
async function getBranchNameFromPR(prNumber) {
99
try {
@@ -154,6 +154,13 @@ export async function prWorktreeHandler(prNumber, options) {
154154
// 7. Create the worktree using the PR branch (now only fetched/tracked, not checked out here)
155155
console.log(chalk.blue(`Creating new worktree for branch "${prBranchName}" at: ${resolvedPath}`));
156156
try {
157+
// >>> ADD SAFETY CHECK HERE <<<
158+
if (await isMainRepoBare()) {
159+
console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true)."));
160+
console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:"));
161+
console.error(chalk.cyan(" git config core.bare false"));
162+
process.exit(1);
163+
}
157164
// Use the PR branch name which 'gh pr checkout' fetched/tracked locally
158165
await execa("git", ["worktree", "add", resolvedPath, prBranchName]);
159166
worktreeCreated = true;

build/commands/remove.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execa } from "execa";
22
import chalk from "chalk";
33
import { stat, rm } from "node:fs/promises";
4+
import { isMainRepoBare } from "../utils/git.js";
45
export async function removeWorktreeHandler(pathOrBranch = "", options) {
56
try {
67
await execa("git", ["rev-parse", "--is-inside-work-tree"]);
@@ -41,6 +42,13 @@ export async function removeWorktreeHandler(pathOrBranch = "", options) {
4142
}
4243
}
4344
console.log(chalk.blue(`Removing worktree: ${targetPath}`));
45+
// >>> ADD SAFETY CHECK HERE <<<
46+
if (await isMainRepoBare()) {
47+
console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true)."));
48+
console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:"));
49+
console.error(chalk.cyan(" git config core.bare false"));
50+
process.exit(1);
51+
}
4452
// Pass the "--force" flag to Git if specified
4553
await execa("git", ["worktree", "remove", ...(options.force ? ["--force"] : []), targetPath]);
4654
// Optionally also remove the physical directory if it still exists

build/index.js

100644100755
Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { mergeWorktreeHandler } from "./commands/merge.js";
77
import { purgeWorktreesHandler } from "./commands/purge.js";
88
import { configHandler } from "./commands/config.js";
99
import { prWorktreeHandler } from "./commands/pr.js";
10+
import { openWorktreeHandler } from "./commands/open.js";
11+
import { extractWorktreeHandler } from "./commands/extract.js";
1012
const program = new Command();
1113
program
1214
.name("wt")
@@ -51,21 +53,33 @@ program
5153
.option("-e, --editor <editor>", "Editor to use for opening the worktree (overrides default editor)")
5254
.description("Fetch the branch for a given GitHub PR number and create a worktree.")
5355
.action(prWorktreeHandler);
56+
program
57+
.command("open")
58+
.argument("[pathOrBranch]", "Path to worktree or branch name to open")
59+
.option("-e, --editor <editor>", "Editor to use for opening the worktree (overrides default editor)")
60+
.description("Open an existing worktree in the editor.")
61+
.action(openWorktreeHandler);
62+
program
63+
.command("extract")
64+
.argument("[branchName]", "Name of the branch to extract (defaults to current branch)")
65+
.option("-p, --path <path>", "Relative path/folder name for the worktree")
66+
.option("-i, --install <packageManager>", "Package manager to use for installing dependencies (npm, pnpm, bun, etc.)")
67+
.option("-e, --editor <editor>", "Editor to use for opening the worktree (overrides default editor)")
68+
.description("Extract an existing branch as a new worktree. If no branch is specified, extracts the current branch.")
69+
.action(extractWorktreeHandler);
5470
program
5571
.command("config")
5672
.description("Manage CLI configuration settings.")
57-
.addCommand(new Command("set")
58-
.description("Set a configuration value.")
59-
.addCommand(new Command("editor")
73+
.addCommand(new Command("set").description("Set a configuration value.").addCommand(new Command("editor")
6074
.argument("<editorName>", "Name of the editor command (e.g., code, cursor, webstorm)")
6175
.description("Set the default editor to open worktrees in.")
62-
.action((editorName) => configHandler('set', 'editor', editorName))))
76+
.action((editorName) => configHandler("set", "editor", editorName))))
6377
.addCommand(new Command("get")
6478
.description("Get a configuration value.")
6579
.addCommand(new Command("editor")
6680
.description("Get the currently configured default editor.")
67-
.action(() => configHandler('get', 'editor'))))
81+
.action(() => configHandler("get", "editor"))))
6882
.addCommand(new Command("path")
6983
.description("Show the path to the configuration file.")
70-
.action(() => configHandler('path')));
84+
.action(() => configHandler("path")));
7185
program.parse(process.argv);

0 commit comments

Comments
 (0)