diff --git a/.changeset/worktree-setup-command.md b/.changeset/worktree-setup-command.md
new file mode 100644
index 0000000..25f4232
--- /dev/null
+++ b/.changeset/worktree-setup-command.md
@@ -0,0 +1,5 @@
+---
+"worktree-cli": minor
+---
+
+Add `worktree setup` to configure `.worktreerc` interactively (base branch, worktree dir, gitignore wiring) instead of writing the file by hand
diff --git a/README.md b/README.md
index 08f7bb2..0cc55b9 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,20 @@ Or download the binary directly from [Releases](https://github.com/bhagyamudgal/
## Setup
-Create a `.worktreerc` file at your repo root and **commit it** so teammates get the same defaults:
+Run this once per repo (it writes `.worktreerc` and wires up gitignores):
+
+```bash
+worktree setup # interactive: pick base branch + worktree dir
+worktree setup --base origin/dev --yes # non-interactive (scripts/CI)
+```
+
+Then commit the result so teammates get the same defaults:
```
DEFAULT_BASE=origin/dev
```
-Add `.worktrees/` to your `.gitignore`:
+`setup` asks before adding your worktree directory to the root `.gitignore` (and always creates `
/.gitignore` inside it). Only add it by hand if you declined that prompt:
```
.worktrees/
@@ -27,6 +34,7 @@ Add `.worktrees/` to your `.gitignore`:
## Usage
```bash
+worktree setup # configure .worktreerc (interactive)
worktree create feature-auth # new branch from configured base
worktree create feature-auth --base main # override base branch
worktree create feature-auth --editor code # open in VS Code
diff --git a/src/commands/create.ts b/src/commands/create.ts
index dcf0aa7..4569981 100644
--- a/src/commands/create.ts
+++ b/src/commands/create.ts
@@ -97,7 +97,8 @@ export const createCommand = command({
} else {
if (!base) {
printError("No default base branch configured.");
- printError("Create a .worktreerc file at your repo root with:");
+ printError("Run 'worktree setup' to configure it, or");
+ printError("create a .worktreerc file at your repo root with:");
printError(" DEFAULT_BASE=origin/dev");
printError("Or use --base to specify one.");
process.exit(EXIT_CODES.ERROR);
diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts
new file mode 100644
index 0000000..eaa97e2
--- /dev/null
+++ b/src/commands/setup.test.ts
@@ -0,0 +1,227 @@
+import { afterEach, describe, expect, it, setDefaultTimeout } from "bun:test";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { run } from "../lib/shell";
+
+type TestRepository = {
+ directory: string;
+ root: string;
+};
+
+const cleanupDirectories: string[] = [];
+const cliPath = path.resolve(import.meta.dir, "../index.ts");
+const INTEGRATION_TEST_TIMEOUT_MS = 20_000;
+
+setDefaultTimeout(INTEGRATION_TEST_TIMEOUT_MS);
+
+async function createTestRepository(): Promise {
+ const directory = await fs.mkdtemp(
+ path.join(os.tmpdir(), "worktree-setup-test-")
+ );
+ cleanupDirectories.push(directory);
+
+ const remote = path.join(directory, "remote.git");
+ const root = path.join(directory, "repository");
+ await fs.mkdir(root);
+
+ await run("git", ["init", "--bare", "-b", "main", remote]);
+ await run("git", ["init", "-b", "main"], { cwd: root });
+ await run("git", ["config", "user.name", "Test User"], { cwd: root });
+ await run("git", ["config", "user.email", "test@example.com"], {
+ cwd: root,
+ });
+
+ await fs.writeFile(path.join(root, "app.ts"), "export const value = 1;\n");
+ await run("git", ["add", "."], { cwd: root });
+ await run("git", ["commit", "-m", "initial"], { cwd: root });
+ await run("git", ["remote", "add", "origin", remote], { cwd: root });
+ await run("git", ["push", "-u", "origin", "main"], { cwd: root });
+ await run("git", ["remote", "set-head", "origin", "-a"], { cwd: root });
+
+ return { directory, root };
+}
+
+async function runSetup(
+ root: string,
+ args: string[]
+): Promise<{ stderr: string; exitCode: number }> {
+ const cliProcess = Bun.spawn(
+ [process.execPath, "run", cliPath, "setup", ...args],
+ {
+ cwd: root,
+ env: { ...Bun.env, WORKTREE_NO_UPDATE: "1", NO_COLOR: "1" },
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ }
+ );
+ const [stderr, exitCode] = await Promise.all([
+ new Response(cliProcess.stderr).text(),
+ cliProcess.exited,
+ ]);
+ return { stderr, exitCode };
+}
+
+async function readFile(root: string, name: string): Promise {
+ return fs.readFile(path.join(root, name), "utf8").catch(() => null);
+}
+
+afterEach(async () => {
+ await Promise.all(
+ cleanupDirectories.splice(0).map(function (directory) {
+ return fs.rm(directory, { recursive: true, force: true });
+ })
+ );
+});
+
+describe("setup command", () => {
+ it("writes .worktreerc and wires up gitignores on first run", async () => {
+ const { root } = await createTestRepository();
+
+ const result = await runSetup(root, ["--base", "main", "--yes"]);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr).toContain("Setup complete.");
+ const rc = await readFile(root, ".worktreerc");
+ expect(rc).toContain("DEFAULT_BASE=origin/main");
+ expect(rc).toContain("WORKTREE_DIR=.worktrees");
+ expect(await readFile(root, ".worktrees/.gitignore")).toBe("*\n");
+ expect(await readFile(root, ".gitignore")).toContain(".worktrees/\n");
+ });
+
+ it("resolves origin/HEAD when --yes has no configured base", async () => {
+ const { root } = await createTestRepository();
+
+ const result = await runSetup(root, ["--yes"]);
+
+ expect(result.exitCode).toBe(0);
+ expect(await readFile(root, ".worktreerc")).toContain(
+ "DEFAULT_BASE=origin/main"
+ );
+ });
+
+ it("rejects an unknown base without writing config", async () => {
+ const { root } = await createTestRepository();
+
+ const result = await runSetup(root, ["--base", "nope", "--yes"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBeNull();
+ expect(
+ await fs.stat(path.join(root, ".worktrees")).catch(() => null)
+ ).toBeNull();
+ });
+
+ it("rejects a stale configured base in --yes mode", async () => {
+ const { root } = await createTestRepository();
+ await fs.writeFile(
+ path.join(root, ".worktreerc"),
+ "DEFAULT_BASE=origin/stale\n"
+ );
+
+ const result = await runSetup(root, ["--yes"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBe(
+ "DEFAULT_BASE=origin/stale\n"
+ );
+ });
+
+ it("preserves unknown keys and comments when rewriting", async () => {
+ const { root } = await createTestRepository();
+ await fs.writeFile(
+ path.join(root, ".worktreerc"),
+ "# team defaults\nCUSTOM=keepme\nDEFAULT_BASE=origin/main\n"
+ );
+
+ const result = await runSetup(root, ["--yes"]);
+
+ expect(result.exitCode).toBe(0);
+ const rc = await readFile(root, ".worktreerc");
+ expect(rc).toContain("# team defaults");
+ expect(rc).toContain("CUSTOM=keepme");
+ expect(rc).toContain("DEFAULT_BASE=origin/main");
+ });
+
+ it("rejects a directory that escapes the repository", async () => {
+ const { directory, root } = await createTestRepository();
+
+ const result = await runSetup(root, [
+ "--base",
+ "main",
+ "--worktree-dir",
+ "../evil",
+ "--yes",
+ ]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBeNull();
+ expect(
+ await fs.stat(path.join(directory, "evil")).catch(() => null)
+ ).toBeNull();
+ });
+
+ it("rejects the repository root as the worktree directory", async () => {
+ const { root } = await createTestRepository();
+
+ const result = await runSetup(root, [
+ "--base",
+ "main",
+ "--worktree-dir",
+ ".",
+ "--yes",
+ ]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBeNull();
+ });
+
+ it("fails before writing config when the directory cannot be created", async () => {
+ const { root } = await createTestRepository();
+ await fs.writeFile(path.join(root, ".worktrees"), "in the way\n");
+
+ const result = await runSetup(root, ["--base", "main", "--yes"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBeNull();
+ });
+
+ it("rejects a worktree directory symlinked outside the repository", async () => {
+ const { directory, root } = await createTestRepository();
+ const external = path.join(directory, "external");
+ await fs.mkdir(external);
+ await fs.symlink(external, path.join(root, ".worktrees"));
+
+ const result = await runSetup(root, ["--base", "main", "--yes"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(await readFile(root, ".worktreerc")).toBeNull();
+ expect(await readFile(external, ".gitignore")).toBeNull();
+ });
+
+ it("honors a custom directory and stays idempotent on rerun", async () => {
+ const { root } = await createTestRepository();
+
+ const first = await runSetup(root, [
+ "--base",
+ "main",
+ "--worktree-dir",
+ ".wt",
+ "--yes",
+ ]);
+ expect(first.exitCode).toBe(0);
+ expect(await readFile(root, ".wt/.gitignore")).toBe("*\n");
+
+ const second = await runSetup(root, ["--yes"]);
+ expect(second.exitCode).toBe(0);
+
+ const gitignore = await readFile(root, ".gitignore");
+ expect(
+ gitignore?.split("\n").filter((line) => line === ".wt/")
+ ).toHaveLength(1);
+ expect(await readFile(root, ".worktreerc")).toContain(
+ "WORKTREE_DIR=.wt"
+ );
+ });
+});
diff --git a/src/commands/setup.ts b/src/commands/setup.ts
new file mode 100644
index 0000000..0dca71e
--- /dev/null
+++ b/src/commands/setup.ts
@@ -0,0 +1,368 @@
+import { boolean, command, string } from "@drizzle-team/brocli";
+import * as p from "@clack/prompts";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { getDefaultBranch, getGitRoot, gitRevParseVerify } from "../lib/git";
+import { loadConfig, parseConfigContent } from "../lib/config";
+import { DEFAULT_WORKTREE_DIR, EXIT_CODES } from "../lib/constants";
+import {
+ printError,
+ printHeader,
+ printInfo,
+ printSuccess,
+ printWarn,
+} from "../lib/logger";
+import { tryCatch } from "../lib/try-catch";
+
+const CUSTOM_OPTION = "__custom__";
+const COMMON_BRANCHES = ["main", "master", "dev", "develop"] as const;
+
+function formatValue(value: string): string {
+ if (/[\s#']/.test(value)) return `"${value}"`;
+ return value;
+}
+
+function validateDirValue(dir: string): string | null {
+ if (dir === "") return "Directory name is required.";
+ if (dir.includes('"') || dir.includes("\n")) {
+ return "Directory name cannot contain quotes or newlines.";
+ }
+ if (dir.startsWith("/") || dir.includes("..") || dir.includes(path.sep)) {
+ return "Use a plain directory name (e.g. .worktrees).";
+ }
+ return null;
+}
+
+function resolveDirInRoot(root: string, dir: string): string | null {
+ const resolved = path.resolve(root, dir);
+ if (resolved === root || !resolved.startsWith(root + path.sep)) {
+ return null;
+ }
+ return resolved;
+}
+
+async function normalizeBase(input: string): Promise {
+ const trimmed = input.trim();
+ if (trimmed === "") return null;
+ if (!trimmed.startsWith("origin/")) {
+ const originRef = `origin/${trimmed}`;
+ if (await gitRevParseVerify(originRef)) return originRef;
+ }
+ if (await gitRevParseVerify(trimmed)) return trimmed;
+ return null;
+}
+
+async function collectBaseCandidates(
+ currentBase: string | undefined
+): Promise {
+ const candidates: string[] = [];
+ function push(ref: string): void {
+ if (!candidates.includes(ref)) candidates.push(ref);
+ }
+
+ if (currentBase) {
+ const normalized = await normalizeBase(currentBase);
+ if (normalized) push(normalized);
+ }
+
+ const originHead = await getDefaultBranch();
+ if (originHead) {
+ const ref = `origin/${originHead}`;
+ if (await gitRevParseVerify(ref)) push(ref);
+ }
+
+ for (const name of COMMON_BRANCHES) {
+ const originRef = `origin/${name}`;
+ if (await gitRevParseVerify(originRef)) {
+ push(originRef);
+ } else if (await gitRevParseVerify(name)) {
+ push(name);
+ }
+ }
+
+ return candidates;
+}
+
+async function resolveOriginHeadBase(): Promise {
+ const head = await getDefaultBranch();
+ if (!head) return null;
+ return normalizeBase(`origin/${head}`);
+}
+
+async function promptForBase(
+ candidates: string[],
+ currentBase: string | undefined
+): Promise {
+ if (candidates.length > 0) {
+ const initial =
+ currentBase && candidates.includes(currentBase)
+ ? currentBase
+ : candidates[0];
+ const selected = await p.select({
+ message: "Select default base branch",
+ options: [
+ ...candidates.map((ref) => ({ value: ref, label: ref })),
+ { value: CUSTOM_OPTION, label: "Custom..." },
+ ],
+ initialValue: initial,
+ });
+ if (p.isCancel(selected)) {
+ printInfo("Cancelled.");
+ process.exit(EXIT_CODES.SUCCESS);
+ }
+ if (selected !== CUSTOM_OPTION) return selected;
+ }
+
+ const entered = await p.text({
+ message: "Enter default base branch",
+ initialValue: currentBase ?? candidates[0] ?? "",
+ validate: function (value) {
+ if (value.trim() === "") return "Base branch is required.";
+ return undefined;
+ },
+ });
+ if (p.isCancel(entered)) {
+ printInfo("Cancelled.");
+ process.exit(EXIT_CODES.SUCCESS);
+ }
+ return entered;
+}
+
+async function promptForDir(currentDir: string): Promise {
+ const entered = await p.text({
+ message: "Worktree directory name",
+ initialValue: currentDir,
+ validate: function (value) {
+ return validateDirValue(value.trim()) ?? undefined;
+ },
+ });
+ if (p.isCancel(entered)) {
+ printInfo("Cancelled.");
+ process.exit(EXIT_CODES.SUCCESS);
+ }
+ return entered.trim();
+}
+
+function isGitignoreCovered(content: string, dir: string): boolean {
+ const wanted = [`${dir}/`, dir, `/${dir}/`, `/${dir}`];
+ return content.split("\n").some((line) => wanted.includes(line.trim()));
+}
+
+export const setupCommand = command({
+ name: "setup",
+ desc: "Configure .worktreerc for this repo (interactive)",
+ options: {
+ base: string("base").desc(
+ "Default base branch (e.g. origin/main, skips prompt)"
+ ),
+ dir: string("worktree-dir").desc(
+ `Worktree directory name (default: ${DEFAULT_WORKTREE_DIR})`
+ ),
+ yes: boolean().desc("Skip confirmation prompts"),
+ },
+ handler: async (opts) => {
+ const root = await getGitRoot();
+ const rcPath = path.join(root, ".worktreerc");
+
+ const { data: rcContent } = await tryCatch(fs.readFile(rcPath, "utf8"));
+ const raw = rcContent === null ? {} : parseConfigContent(rcContent);
+ const config = await loadConfig(root);
+ const currentBase = raw.DEFAULT_BASE;
+ const currentDir = raw.WORKTREE_DIR ?? config.WORKTREE_DIR;
+
+ printHeader("Repo setup");
+ if (currentBase) printInfo(` Current base: ${currentBase}`);
+ else printInfo(" No DEFAULT_BASE configured yet.");
+ printInfo(` Worktree dir: ${currentDir}`);
+ console.error("");
+
+ let base: string;
+ if (opts.base !== undefined) {
+ const normalized = await normalizeBase(opts.base);
+ if (!normalized) {
+ printError(
+ `Base branch '${opts.base}' not found locally or on origin.`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ base = normalized;
+ } else if (opts.yes) {
+ if (currentBase) {
+ const normalized = await normalizeBase(currentBase);
+ if (!normalized) {
+ printError(
+ `Configured DEFAULT_BASE '${currentBase}' not found locally or on origin. Re-run with --base .`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ base = normalized;
+ } else {
+ const fallback = await resolveOriginHeadBase();
+ if (!fallback) {
+ printError(
+ "No DEFAULT_BASE configured. Re-run with --base ."
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ base = fallback;
+ }
+ } else {
+ if (process.stdin.isTTY !== true) {
+ printError(
+ "No --base given and stdin is not interactive. Re-run with --base ."
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ const candidates = await collectBaseCandidates(currentBase);
+ base = await promptForBase(candidates, currentBase);
+ const normalized = await normalizeBase(base);
+ if (!normalized) {
+ printError(
+ `Base branch '${base}' not found locally or on origin.`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ base = normalized;
+ }
+
+ let dir: string;
+ if (opts.dir !== undefined) {
+ dir = opts.dir.trim();
+ } else if (opts.yes || process.stdin.isTTY !== true) {
+ dir = currentDir;
+ } else {
+ dir = await promptForDir(currentDir);
+ }
+ const dirError = validateDirValue(dir);
+ const worktreeBaseDir =
+ dirError === null ? resolveDirInRoot(root, dir) : null;
+ if (dirError !== null || worktreeBaseDir === null) {
+ printError(
+ `Invalid worktree directory '${dir}'. Use a plain name like ${DEFAULT_WORKTREE_DIR} inside the repository.`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+
+ const { error: mkdirError } = await tryCatch(
+ fs.mkdir(worktreeBaseDir, { recursive: true })
+ );
+ if (mkdirError) {
+ printError(
+ `Could not create ${dir} directory: ${mkdirError.message}`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ const { data: realRoot, error: realRootError } = await tryCatch(
+ fs.realpath(root)
+ );
+ const { data: realDir, error: realDirError } = await tryCatch(
+ fs.realpath(worktreeBaseDir)
+ );
+ if (
+ realRootError ||
+ realDirError ||
+ realDir === realRoot ||
+ !realDir.startsWith(realRoot + path.sep)
+ ) {
+ printError(
+ `Worktree directory '${dir}' resolves outside the repository. Use a real directory inside it.`
+ );
+ process.exit(EXIT_CODES.ERROR);
+ }
+ await fs
+ .writeFile(path.join(worktreeBaseDir, ".gitignore"), "*\n", {
+ flag: "wx",
+ })
+ .catch((error: unknown) => {
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
+ printWarn(` Could not create ${dir}/.gitignore.`);
+ }
+ });
+
+ const preservedLines: string[] = [];
+ if (rcContent !== null) {
+ const sourceLines = rcContent.split("\n");
+ if (sourceLines[sourceLines.length - 1] === "") {
+ sourceLines.pop();
+ }
+ for (const line of sourceLines) {
+ const trimmed = line.trim();
+ const eqIndex = trimmed.indexOf("=");
+ const key =
+ trimmed === "" || trimmed.startsWith("#") || eqIndex === -1
+ ? ""
+ : trimmed.slice(0, eqIndex).trim();
+ if (key === "DEFAULT_BASE" || key === "WORKTREE_DIR") continue;
+ preservedLines.push(line);
+ }
+ }
+ const lines = [
+ `DEFAULT_BASE=${formatValue(base)}`,
+ `WORKTREE_DIR=${formatValue(dir)}`,
+ ...preservedLines,
+ ];
+ const { error: writeError } = await tryCatch(
+ fs.writeFile(rcPath, `${lines.join("\n")}\n`)
+ );
+ if (writeError) {
+ printError(`Could not write ${rcPath}: ${writeError.message}`);
+ process.exit(EXIT_CODES.ERROR);
+ }
+ printSuccess(`Wrote ${rcPath}`);
+
+ const gitignorePath = path.join(root, ".gitignore");
+ const { data: gitignoreContent } = await tryCatch(
+ fs.readFile(gitignorePath, "utf8")
+ );
+ if (
+ gitignoreContent !== null &&
+ isGitignoreCovered(gitignoreContent, dir)
+ ) {
+ printInfo(` .gitignore already covers ${dir}/.`);
+ } else {
+ let append = opts.yes;
+ if (!opts.yes) {
+ if (process.stdin.isTTY !== true) {
+ printWarn(
+ ` Skipping .gitignore update (non-interactive). Add '${dir}/' manually.`
+ );
+ } else {
+ const confirmed = await p.confirm({
+ message: `Add '${dir}/' to .gitignore?`,
+ });
+ if (p.isCancel(confirmed)) {
+ printInfo("Cancelled.");
+ process.exit(EXIT_CODES.SUCCESS);
+ }
+ append = confirmed;
+ }
+ }
+ if (append) {
+ const prefix =
+ gitignoreContent === null || gitignoreContent === ""
+ ? ""
+ : gitignoreContent.endsWith("\n")
+ ? ""
+ : "\n";
+ const { error: appendError } = await tryCatch(
+ fs.appendFile(gitignorePath, `${prefix}${dir}/\n`)
+ );
+ if (appendError) {
+ printWarn(
+ ` Could not update .gitignore: ${appendError.message}`
+ );
+ } else {
+ printSuccess(` Added '${dir}/' to .gitignore.`);
+ }
+ } else if (process.stdin.isTTY === true) {
+ printInfo(
+ ` Left .gitignore unchanged. Add '${dir}/' manually.`
+ );
+ }
+ }
+
+ console.error("");
+ printSuccess("Setup complete.");
+ printInfo(" Commit .worktreerc so teammates get the same defaults.");
+ },
+});
diff --git a/src/index.ts b/src/index.ts
index 389dd6b..1da84c5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@ import { internalUpdateCheckCommand } from "./commands/internal-update-check";
import { listCommand } from "./commands/list";
import { openCommand } from "./commands/open";
import { removeCommand } from "./commands/remove";
+import { setupCommand } from "./commands/setup";
import { updateCommand } from "./commands/update";
import {
appendBackgroundCheckPanic,
@@ -54,6 +55,7 @@ run(
listCommand,
openCommand,
removeCommand,
+ setupCommand,
updateCommand,
internalUpdateCheckCommand,
],