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, ],