From 3f94ec4e000889a4efadc786a3d84cffa2d75306 Mon Sep 17 00:00:00 2001 From: Evgeniy Podivilov Date: Tue, 21 Jul 2026 22:03:45 +0100 Subject: [PATCH 1/3] test(create): add command-level tests covering rollback path --- src/cli/commands/create.test.ts | 609 ++++++++++++++++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 src/cli/commands/create.test.ts diff --git a/src/cli/commands/create.test.ts b/src/cli/commands/create.test.ts new file mode 100644 index 0000000..59d1a6e --- /dev/null +++ b/src/cli/commands/create.test.ts @@ -0,0 +1,609 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { CONFIG_FILENAME, LOCAL_CONFIG_FILENAME } from "../../domain/constants.ts"; +import type { Worktree } from "../../domain/entities/worktree.ts"; +import type { FilesystemPort } from "../../domain/ports/filesystem-port.ts"; +import type { GitPort } from "../../domain/ports/git-port.ts"; +import type { ShellPort } from "../../domain/ports/shell-port.ts"; +import type { UiPort } from "../../domain/ports/ui-port.ts"; +import type { Container } from "../../infrastructure/container.ts"; +import { Result } from "../../shared/result.ts"; +import { createFakeFilesystem } from "../../test-utils/fake-filesystem.ts"; +import { createFakeGit } from "../../test-utils/fake-git.ts"; +import { createFakeShell, type FakeShell } from "../../test-utils/fake-shell.ts"; +import { EXIT_CANCEL, EXIT_FAILURE } from "../exit-codes.ts"; +import { createCommand } from "./create.ts"; + +const ROOT = "/fake/project"; +const WORKTREES_DIR = ".worktrees"; +const FEATURE_PATH = `${ROOT}/${WORKTREES_DIR}/feature`; +const CONFIG_PATH = `${ROOT}/${CONFIG_FILENAME}`; + +const CONFIG = JSON.stringify({ + rootDir: WORKTREES_DIR, + copy: [".env"], + symlinks: ["node_modules"], + hooks: { "post-create": ["pnpm install"] }, +}); + +interface FakeUiLog { + info: string[]; + success: string[]; + warn: string[]; + error: string[]; + outro: string[]; +} + +interface FakeSpinnerLog { + start: string[]; + message: string[]; + stop: string[]; +} + +interface FakeUiOptions { + nonInteractive?: boolean; + /** Response for ui.select (value to return). */ + select?: string; + /** Response for ui.text (value to return). */ + text?: string; +} + +function createFakeUi(opts: FakeUiOptions = {}): { + ui: UiPort; + log: FakeUiLog; + spinnerLog: FakeSpinnerLog; + selectCalls: { message: string; values: string[] }[]; +} { + const log: FakeUiLog = { info: [], success: [], warn: [], error: [], outro: [] }; + const spinnerLog: FakeSpinnerLog = { start: [], message: [], stop: [] }; + const selectCalls: { message: string; values: string[] }[] = []; + + const ui = { + nonInteractive: opts.nonInteractive ?? false, + intro() {}, + outro(message: string) { + log.outro.push(message); + }, + info(message: string) { + log.info.push(message); + }, + success(message: string) { + log.success.push(message); + }, + warn(message: string) { + log.warn.push(message); + }, + error(message: string) { + log.error.push(message); + }, + async spinner(_message: string, fn: () => Promise): Promise { + return fn(); + }, + createSpinner() { + return { + start(message: string) { + spinnerLog.start.push(message); + }, + message(message: string) { + spinnerLog.message.push(message); + }, + stop(message?: string) { + if (message !== undefined) spinnerLog.stop.push(message); + }, + }; + }, + createMultiSpinner() { + return { update() {}, complete() {}, fail() {}, stop() {} }; + }, + async text() { + return opts.text ?? ""; + }, + async confirm() { + return true; + }, + async select(options: { message: string; options: Array<{ value: T; label: string }> }) { + selectCalls.push({ message: options.message, values: options.options.map((o) => String(o.value)) }); + return (opts.select ?? options.options[0]?.value) as T; + }, + async multiselect() { + return [] as never; + }, + isCancel(_value: unknown): _value is symbol { + return false; + }, + cancel() {}, + } satisfies UiPort; + + return { ui, log, spinnerLog, selectCalls }; +} + +function buildContainer(ui: UiPort, git: GitPort, fs: FilesystemPort, shell: ShellPort): Container { + return { + ui, + git, + fs, + shell, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }; +} + +class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +let exitSpy: typeof process.exit; +let recordedExit: number | null; + +beforeEach(() => { + exitSpy = process.exit; + recordedExit = null; + process.exit = ((code?: number): never => { + if (recordedExit === null) recordedExit = code ?? 0; + throw new ExitSignal(code ?? 0); + }) as typeof process.exit; +}); + +afterEach(() => { + process.exit = exitSpy; +}); + +async function runCreate(container: Container, args: Record): Promise { + const cmd = createCommand(container); + const run = cmd.run as (ctx: { args: Record; cmd: unknown; rawArgs: string[] }) => Promise; + try { + await run({ args, cmd, rawArgs: [] }); + return recordedExit ?? 0; + } catch (err) { + if (err instanceof ExitSignal) return recordedExit ?? err.code; + throw err; + } +} + +const mainWt: Worktree = { path: ROOT, branch: "main", head: "aaa", isMain: true, isPrunable: false }; + +interface ScenarioOptions { + /** Repo config content; `null` omits the config file entirely. */ + config?: string | null; + files?: Record; + directories?: string[]; + worktrees?: Worktree[]; + branches?: string[]; + remoteBranches?: string[]; +} + +function scenario(opts: ScenarioOptions = {}): { fs: FilesystemPort; git: GitPort; shell: FakeShell } { + const files: Record = { + [`${ROOT}/.env`]: "SECRET=1", + ...(opts.config === null ? {} : { [CONFIG_PATH]: opts.config ?? CONFIG }), + ...opts.files, + }; + const fs = createFakeFilesystem({ + files, + directories: [ROOT, `${ROOT}/${WORKTREES_DIR}`, `${ROOT}/node_modules`, ...(opts.directories ?? [])], + }); + const git = createFakeGit({ + root: ROOT, + mainRoot: ROOT, + worktrees: opts.worktrees ?? [mainWt], + branches: opts.branches ?? ["main"], + remoteBranches: opts.remoteBranches ?? [], + }); + return { fs, git, shell: createFakeShell() }; +} + +/** Wraps a fake git so the worktree-creation calls can be inspected. */ +function spyCreateCalls(git: GitPort): { + git: GitPort; + createCalls: { branch: string; path: string; baseBranch: string | undefined }[]; + fromRemoteCalls: { branch: string; path: string }[]; +} { + const createCalls: { branch: string; path: string; baseBranch: string | undefined }[] = []; + const fromRemoteCalls: { branch: string; path: string }[] = []; + return { + git: { + ...git, + async createWorktree(branch, path, baseBranch) { + createCalls.push({ branch, path, baseBranch }); + return git.createWorktree(branch, path, baseBranch); + }, + async createWorktreeFromRemote(branch, path, remote) { + fromRemoteCalls.push({ branch, path }); + return git.createWorktreeFromRemote(branch, path, remote); + }, + }, + createCalls, + fromRemoteCalls, + }; +} + +function args(overrides: Record = {}): Record { + return { branch: "feature", base: "main", "dry-run": false, ...overrides }; +} + +async function listPaths(git: GitPort): Promise { + const result = await git.listWorktrees(); + return result.success ? result.data.map((w) => w.path) : []; +} + +describe("create — happy path", () => { + test("creates the worktree, links config, copies files, symlinks and runs hooks", async () => { + const { fs, git: baseGit, shell } = scenario(); + const { git, createCalls } = spyCreateCalls(baseGit); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(createCalls).toEqual([{ branch: "feature", path: FEATURE_PATH, baseBranch: "main" }]); + expect(await listPaths(git)).toContain(FEATURE_PATH); + + // Config symlink + declared symlink + copied file all landed in the worktree. + expect(await fs.isSymlink(`${FEATURE_PATH}/${CONFIG_FILENAME}`)).toBe(true); + expect(await fs.isSymlink(`${FEATURE_PATH}/node_modules`)).toBe(true); + const copied = await fs.readFile(`${FEATURE_PATH}/.env`); + expect(copied.success && copied.data).toBe("SECRET=1"); + + // post-create hook ran inside the worktree with the documented env. + expect(shell.calls).toHaveLength(1); + expect(shell.calls[0]?.command).toBe("pnpm install"); + expect(shell.calls[0]?.options.cwd).toBe(FEATURE_PATH); + expect(shell.calls[0]?.options.env).toEqual({ + WORKTREE_PATH: FEATURE_PATH, + WORKTREE_BRANCH: "feature", + REPO_ROOT: ROOT, + BASE_BRANCH: "main", + }); + + expect(spinnerLog.stop.some((m) => m.includes("Worktree created"))).toBe(true); + expect(spinnerLog.stop.some((m) => m.includes("Hooks completed"))).toBe(true); + expect(log.success.some((m) => m.includes(`Created worktree for branch: feature at ${FEATURE_PATH}`))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + expect(log.error).toEqual([]); + }); + + test("no post-create hooks → shell is never touched, still reports success", async () => { + const { fs, git, shell } = scenario({ config: JSON.stringify({ rootDir: WORKTREES_DIR }) }); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(shell.calls).toEqual([]); + expect(spinnerLog.stop.some((m) => m.includes("Hooks completed"))).toBe(false); + expect(log.outro).toEqual(["Done!"]); + }); + + test("no base flag → prompts for the source branch and passes the pick to git", async () => { + const { fs, git: baseGit, shell } = scenario({ branches: ["main", "develop"] }); + const { git, createCalls } = spyCreateCalls(baseGit); + const { ui, selectCalls } = createFakeUi({ select: "develop" }); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ base: undefined })); + + expect(code).toBe(0); + expect(selectCalls.map((c) => c.message)).toContain("Select source branch"); + expect(createCalls[0]?.baseBranch).toBe("develop"); + }); + + test("existing local branch → no base resolution, worktree checked out as-is", async () => { + const { fs, git: baseGit, shell } = scenario({ branches: ["main", "feature"] }); + const { git, createCalls, fromRemoteCalls } = spyCreateCalls(baseGit); + const { ui, selectCalls } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ base: undefined })); + + expect(code).toBe(0); + expect(selectCalls).toEqual([]); + expect(createCalls).toEqual([{ branch: "feature", path: FEATURE_PATH, baseBranch: undefined }]); + expect(fromRemoteCalls).toEqual([]); + }); + + test("remote-only branch → checked out from the remote, no base resolution", async () => { + const { fs, git: baseGit, shell } = scenario({ remoteBranches: ["feature"] }); + const { git, createCalls, fromRemoteCalls } = spyCreateCalls(baseGit); + const { ui, selectCalls } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ base: undefined })); + + expect(code).toBe(0); + expect(selectCalls).toEqual([]); + expect(fromRemoteCalls).toEqual([{ branch: "feature", path: FEATURE_PATH }]); + expect(createCalls).toEqual([]); + }); + + test("local config present → both config symlinks are created", async () => { + const { fs, git, shell } = scenario({ + files: { [`${ROOT}/${LOCAL_CONFIG_FILENAME}`]: JSON.stringify({ rootDir: WORKTREES_DIR }) }, + }); + const { ui } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(await fs.isSymlink(`${FEATURE_PATH}/${CONFIG_FILENAME}`)).toBe(true); + expect(await fs.isSymlink(`${FEATURE_PATH}/${LOCAL_CONFIG_FILENAME}`)).toBe(true); + }); +}); + +describe("create --dry-run", () => { + test("previews the plan and touches nothing", async () => { + const { fs, git: baseGit, shell } = scenario(); + const { git, createCalls, fromRemoteCalls } = spyCreateCalls(baseGit); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ "dry-run": true })); + + expect(code).toBe(0); + expect(createCalls).toEqual([]); + expect(fromRemoteCalls).toEqual([]); + expect(await listPaths(git)).toEqual([ROOT]); + expect(await fs.exists(`${FEATURE_PATH}/.env`)).toBe(false); + expect(shell.calls).toEqual([]); + + expect(log.info).toContain(`Would create worktree at ${FEATURE_PATH}`); + expect(log.info).toContain("Branch: feature (new, from main)"); + expect(log.info.some((m) => m.startsWith("Would symlink config:"))).toBe(true); + expect(log.info).toContain("Would copy 1 file(s):"); + expect(log.info).toContain(" file: .env"); + expect(log.info).toContain("Would create 1 symlink(s):"); + expect(log.info).toContain(" link: node_modules"); + expect(log.info).toContain("Would run 1 hook(s):"); + expect(log.info).toContain(" pnpm install"); + expect(spinnerLog.stop).toContain("Preview"); + expect(log.outro).toEqual(["Dry run — no changes made"]); + }); + + test("existing branch is previewed as existing, without a base", async () => { + const { fs, git, shell } = scenario({ branches: ["main", "feature"] }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ base: undefined, "dry-run": true })); + + expect(code).toBe(0); + expect(log.info).toContain("Branch: feature (existing)"); + }); +}); + +describe("create — failure modes", () => { + test("branch already has a worktree → exits with failure and no rollback noise", async () => { + const featureWt: Worktree = { + path: FEATURE_PATH, + branch: "feature", + head: "bbb", + isMain: false, + isPrunable: false, + }; + const { fs, git, shell } = scenario({ worktrees: [mainWt, featureWt], branches: ["main", "feature"] }); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ base: undefined })); + + expect(code).toBe(EXIT_FAILURE); + expect(spinnerLog.stop.some((m) => m.includes("Failed"))).toBe(true); + expect(log.error.some((m) => m.includes("already exists at"))).toBe(true); + expect(log.outro).toEqual([]); + }); + + test("target directory exists but is not a worktree → exits with failure", async () => { + const { fs, git, shell } = scenario({ directories: [FEATURE_PATH] }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(EXIT_FAILURE); + expect(log.error.some((m) => m.includes("already exists but is not a worktree"))).toBe(true); + }); + + test("git refuses to create the worktree → error surfaced, nothing copied", async () => { + const { fs, git: baseGit, shell } = scenario(); + const git: GitPort = { + ...baseGit, + async createWorktree() { + return Result.err({ code: "UNKNOWN", message: "fatal: could not create work tree" }); + }, + }; + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(EXIT_FAILURE); + expect(spinnerLog.stop.some((m) => m.includes("Failed"))).toBe(true); + expect(log.error.some((m) => m.includes("fatal: could not create work tree"))).toBe(true); + expect(await fs.exists(`${FEATURE_PATH}/.env`)).toBe(false); + expect(shell.calls).toEqual([]); + }); + + test("non-interactive without a branch name → usage error", async () => { + const { fs, git, shell } = scenario(); + const { ui, log } = createFakeUi({ nonInteractive: true }); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args({ branch: undefined, base: undefined })); + + expect(code).not.toBe(0); + expect(log.error.some((m) => m.includes("Branch name is required in non-interactive mode"))).toBe(true); + }); +}); + +describe("create — degraded steps stay non-fatal", () => { + test("config symlink failure only warns", async () => { + const { fs: baseFs, git, shell } = scenario(); + const fs: FilesystemPort = { + ...baseFs, + async createSymlink(target, linkPath) { + if (linkPath.endsWith(CONFIG_FILENAME)) { + return Result.err({ code: "UNKNOWN", message: "permission denied", path: linkPath }); + } + return baseFs.createSymlink(target, linkPath); + }, + }; + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Failed to symlink config: permission denied"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("copy failure only warns", async () => { + const { fs: baseFs, git, shell } = scenario(); + const fs: FilesystemPort = { + ...baseFs, + async copyFile(source) { + return Result.err({ code: "UNKNOWN", message: "disk full", path: source }); + }, + }; + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Failed to copy .env: disk full"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("symlink failure only warns", async () => { + const { fs: baseFs, git, shell } = scenario(); + const fs: FilesystemPort = { + ...baseFs, + async createSymlink(target, linkPath) { + if (linkPath.endsWith("node_modules")) { + return Result.err({ code: "UNKNOWN", message: "loop detected", path: linkPath }); + } + return baseFs.createSymlink(target, linkPath); + }, + }; + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Failed to create symlink") && m.includes("loop detected"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("failing hook only warns and the command still completes", async () => { + const { fs, git } = scenario(); + const shell = createFakeShell({ + results: new Map([["pnpm install", Result.err({ code: "EXECUTION_FAILED", message: "exit 1" })]]), + }); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes('Hook failed: "pnpm install" - exit 1'))).toBe(true); + expect(spinnerLog.stop.some((m) => m.includes("Hooks completed"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("missing config → warns and falls back to defaults", async () => { + const { fs, git, shell } = scenario({ config: null }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Config not found at"))).toBe(true); + expect(log.warn.some((m) => m.includes("Config not found, using defaults"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); +}); + +describe("create — SIGINT rollback", () => { + // The command registers a CleanupHandle right after the worktree exists, so an + // interrupt mid-setup force-removes the half-built worktree. The rollback's + // Result is deliberately ignored: a failing rollback must not mask the cancel. + async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + test("interrupt while copying → worktree is force-removed and the process cancels", async () => { + const { fs: baseFs, git: baseGit, shell } = scenario(); + const removeCalls: { path: string; force: boolean | undefined }[] = []; + const git: GitPort = { + ...baseGit, + async removeWorktree(path, options) { + removeCalls.push({ path, force: options?.force }); + // Rollback itself fails — create.ts must swallow it. + return Result.err({ code: "UNKNOWN", message: "rollback failed" }); + }, + }; + + let releaseCopy: () => void = () => {}; + let signalCopyStarted: () => void = () => {}; + const copyStarted = new Promise((resolve) => { + signalCopyStarted = resolve; + }); + const copyGate = new Promise((resolve) => { + releaseCopy = resolve; + }); + const fs: FilesystemPort = { + ...baseFs, + async copyFile(source, destination) { + signalCopyStarted(); + await copyGate; + return baseFs.copyFile(source, destination); + }, + }; + + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + // The SIGINT handler exits from inside a `.finally()`; a throwing process.exit + // stub would surface there as an unhandled rejection, so record instead. + const exitCodes: number[] = []; + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + }) as unknown as typeof process.exit; + + const before = process.listeners("SIGINT"); + const run = runCreate(container, args()); + await copyStarted; + + const added = process.listeners("SIGINT").filter((listener) => !before.includes(listener)); + expect(added).toHaveLength(1); + (added[0] as () => void)(); + await flush(); + + expect(removeCalls).toEqual([{ path: FEATURE_PATH, force: true }]); + expect(exitCodes).toContain(EXIT_CANCEL); + + releaseCopy(); + await run; + + // The failed rollback is swallowed: nothing about it reaches the user. + expect(log.error).toEqual([]); + expect(log.warn.some((m) => m.includes("rollback failed"))).toBe(false); + }); + + test("successful run clears the interrupt handler", async () => { + const { fs, git, shell } = scenario(); + const { ui } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const before = process.listenerCount("SIGINT"); + const code = await runCreate(container, args()); + + expect(code).toBe(0); + expect(process.listenerCount("SIGINT")).toBe(before); + }); +}); From 588d8b9710e8501d4737c7127ec563bfc4f0ffa0 Mon Sep 17 00:00:00 2001 From: Evgeniy Podivilov Date: Tue, 21 Jul 2026 22:08:56 +0100 Subject: [PATCH 2/3] test(sync): add command-level tests for sync plan, hooks and failures --- src/cli/commands/sync.test.ts | 450 ++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 src/cli/commands/sync.test.ts diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts new file mode 100644 index 0000000..08df50b --- /dev/null +++ b/src/cli/commands/sync.test.ts @@ -0,0 +1,450 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { CONFIG_FILENAME, LEGACY_CONFIG_FILENAME } from "../../domain/constants.ts"; +import type { Worktree } from "../../domain/entities/worktree.ts"; +import type { FilesystemPort } from "../../domain/ports/filesystem-port.ts"; +import type { GitPort } from "../../domain/ports/git-port.ts"; +import type { ShellPort } from "../../domain/ports/shell-port.ts"; +import type { UiPort } from "../../domain/ports/ui-port.ts"; +import type { Container } from "../../infrastructure/container.ts"; +import { Result } from "../../shared/result.ts"; +import { createFakeFilesystem, type FakeFilesystemOptions } from "../../test-utils/fake-filesystem.ts"; +import { createFakeGit } from "../../test-utils/fake-git.ts"; +import { createFakeShell, type FakeShell } from "../../test-utils/fake-shell.ts"; +import { EXIT_FAILURE } from "../exit-codes.ts"; +import { syncCommand } from "./sync.ts"; + +const ROOT = "/fake/project"; +const WORKTREES_DIR = ".worktrees"; +const FEATURE_PATH = `${ROOT}/${WORKTREES_DIR}/feature`; +const FEATURE2_PATH = `${ROOT}/${WORKTREES_DIR}/feature2`; +const CONFIG_PATH = `${ROOT}/${CONFIG_FILENAME}`; + +const CONFIG = JSON.stringify({ + rootDir: WORKTREES_DIR, + copy: [".env"], + symlinks: ["node_modules"], +}); + +interface FakeUiLog { + info: string[]; + success: string[]; + warn: string[]; + error: string[]; + outro: string[]; +} + +interface FakeSpinnerLog { + start: string[]; + stop: string[]; +} + +function createFakeUi(): { ui: UiPort; log: FakeUiLog; spinnerLog: FakeSpinnerLog } { + const log: FakeUiLog = { info: [], success: [], warn: [], error: [], outro: [] }; + const spinnerLog: FakeSpinnerLog = { start: [], stop: [] }; + + const ui = { + nonInteractive: false, + intro() {}, + outro(message: string) { + log.outro.push(message); + }, + info(message: string) { + log.info.push(message); + }, + success(message: string) { + log.success.push(message); + }, + warn(message: string) { + log.warn.push(message); + }, + error(message: string) { + log.error.push(message); + }, + async spinner(_message: string, fn: () => Promise): Promise { + return fn(); + }, + createSpinner() { + return { + start(message: string) { + spinnerLog.start.push(message); + }, + message() {}, + stop(message?: string) { + if (message !== undefined) spinnerLog.stop.push(message); + }, + }; + }, + createMultiSpinner() { + return { update() {}, complete() {}, fail() {}, stop() {} }; + }, + async text() { + return ""; + }, + async confirm() { + return true; + }, + async select() { + return undefined as never; + }, + async multiselect() { + return [] as never; + }, + isCancel(_value: unknown): _value is symbol { + return false; + }, + cancel() {}, + } satisfies UiPort; + + return { ui, log, spinnerLog }; +} + +function buildContainer(ui: UiPort, git: GitPort, fs: FilesystemPort, shell: ShellPort): Container { + return { + ui, + git, + fs, + shell, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }; +} + +class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +let exitSpy: typeof process.exit; +let recordedExit: number | null; + +beforeEach(() => { + exitSpy = process.exit; + recordedExit = null; + process.exit = ((code?: number): never => { + if (recordedExit === null) recordedExit = code ?? 0; + throw new ExitSignal(code ?? 0); + }) as typeof process.exit; +}); + +afterEach(() => { + process.exit = exitSpy; +}); + +async function runSync(container: Container, args: Record): Promise { + const cmd = syncCommand(container); + const run = cmd.run as (ctx: { args: Record; cmd: unknown; rawArgs: string[] }) => Promise; + try { + await run({ args, cmd, rawArgs: [] }); + return recordedExit ?? 0; + } catch (err) { + if (err instanceof ExitSignal) return recordedExit ?? err.code; + throw err; + } +} + +const mainWt: Worktree = { path: ROOT, branch: "main", head: "aaa", isMain: true, isPrunable: false }; +const featureWt: Worktree = { path: FEATURE_PATH, branch: "feature", head: "bbb", isMain: false, isPrunable: false }; +const feature2Wt: Worktree = { path: FEATURE2_PATH, branch: "feature2", head: "ccc", isMain: false, isPrunable: false }; + +interface ScenarioOptions { + /** Repo config content; `null` omits the config file entirely. */ + config?: string | null; + configFilename?: string; + files?: Record; + directories?: string[]; + symlinks?: FakeFilesystemOptions["symlinks"]; + brokenSymlinks?: string[]; + worktrees?: Worktree[]; + isRepo?: boolean; + shell?: FakeShell; +} + +function scenario(opts: ScenarioOptions = {}): { fs: FilesystemPort; git: GitPort; shell: FakeShell } { + const configFile = `${ROOT}/${opts.configFilename ?? CONFIG_FILENAME}`; + const files: Record = { + [`${ROOT}/.env`]: "SECRET=1", + ...(opts.config === null ? {} : { [configFile]: opts.config ?? CONFIG }), + ...opts.files, + }; + const fs = createFakeFilesystem({ + files, + directories: [ + ROOT, + `${ROOT}/${WORKTREES_DIR}`, + `${ROOT}/node_modules`, + FEATURE_PATH, + FEATURE2_PATH, + ...(opts.directories ?? []), + ], + symlinks: opts.symlinks ?? {}, + brokenSymlinks: opts.brokenSymlinks ?? [], + }); + const git = createFakeGit({ + isRepo: opts.isRepo ?? true, + root: ROOT, + mainRoot: ROOT, + worktrees: opts.worktrees ?? [mainWt, featureWt], + branches: ["main", "feature", "feature2"], + }); + return { fs, git, shell: opts.shell ?? createFakeShell() }; +} + +function args(overrides: Record = {}): Record { + return { branch: undefined, "dry-run": false, force: false, ...overrides }; +} + +describe("sync — happy path", () => { + test("applies config symlink, declared symlinks and copies to every worktree", async () => { + const { fs, git, shell } = scenario({ worktrees: [mainWt, featureWt, feature2Wt] }); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + for (const path of [FEATURE_PATH, FEATURE2_PATH]) { + expect(await fs.isSymlink(`${path}/${CONFIG_FILENAME}`)).toBe(true); + expect(await fs.isSymlink(`${path}/node_modules`)).toBe(true); + const copied = await fs.readFile(`${path}/.env`); + expect(copied.success && copied.data).toBe("SECRET=1"); + } + + expect(log.success).toHaveLength(2); + expect(log.success.some((m) => m.includes("feature") && m.includes(`${WORKTREES_DIR}/feature`))).toBe(true); + expect(log.success.every((m) => m.includes("add 2 symlink(s)") && m.includes("copy 1 file(s)"))).toBe(true); + expect(spinnerLog.start).toEqual(["Syncing worktrees..."]); + expect(spinnerLog.stop.some((m) => m.includes("Done"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("nothing to change → worktree reported as up to date", async () => { + const { fs, git, shell } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR }), + symlinks: { [`${FEATURE_PATH}/${CONFIG_FILENAME}`]: CONFIG_PATH }, + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.success).toEqual([]); + expect(log.info.some((m) => m.includes("feature") && m.includes("up to date"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("broken config symlink is recreated", async () => { + const { fs, git, shell } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR }), + brokenSymlinks: [`${FEATURE_PATH}/${CONFIG_FILENAME}`], + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.success.some((m) => m.includes("recreate 1 broken symlink(s)"))).toBe(true); + expect(await fs.isSymlinkBroken(`${FEATURE_PATH}/${CONFIG_FILENAME}`)).toBe(false); + }); + + test("branch argument limits the sync to that worktree", async () => { + const { fs, git, shell } = scenario({ worktrees: [mainWt, featureWt, feature2Wt] }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args({ branch: "feature" })); + + expect(code).toBe(0); + expect(log.success).toHaveLength(1); + expect(log.success[0]).toContain("feature"); + expect(await fs.exists(`${FEATURE_PATH}/.env`)).toBe(true); + expect(await fs.exists(`${FEATURE2_PATH}/.env`)).toBe(false); + }); + + test("no worktrees besides main → reports nothing to sync", async () => { + const { fs, git, shell } = scenario({ worktrees: [mainWt] }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.info).toContain("No worktrees to sync"); + expect(log.outro).toEqual(["Done!"]); + }); +}); + +describe("sync --dry-run", () => { + test("reports the plan with 'would' verbs and writes nothing", async () => { + const { fs, git, shell } = scenario(); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args({ "dry-run": true })); + + expect(code).toBe(0); + expect(log.success.some((m) => m.includes("would add 2 symlink(s)") && m.includes("would copy 1 file(s)"))).toBe( + true, + ); + expect(await fs.exists(`${FEATURE_PATH}/.env`)).toBe(false); + expect(await fs.isSymlink(`${FEATURE_PATH}/${CONFIG_FILENAME}`)).toBe(false); + expect(spinnerLog.start).toEqual(["Resolving sync plan..."]); + expect(spinnerLog.stop.some((m) => m.includes("Plan ready"))).toBe(true); + expect(log.outro).toEqual(["Dry run — no changes made"]); + }); + + test("post-sync hooks never run in dry-run", async () => { + const { fs, git, shell } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR, hooks: { "post-sync": ["pnpm install"] } }), + }); + const { ui } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args({ "dry-run": true })); + + expect(code).toBe(0); + expect(shell.calls).toEqual([]); + }); +}); + +describe("sync — existing destinations", () => { + test("existing file is skipped with a --force hint", async () => { + const { fs, git, shell } = scenario({ files: { [`${FEATURE_PATH}/.env`]: "OLD=1" } }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("1 file(s) already exist at destination"))).toBe(true); + expect(log.warn.some((m) => m.includes("--force"))).toBe(true); + const kept = await fs.readFile(`${FEATURE_PATH}/.env`); + expect(kept.success && kept.data).toBe("OLD=1"); + }); + + test("--force overwrites the destination", async () => { + const { fs, git, shell } = scenario({ files: { [`${FEATURE_PATH}/.env`]: "OLD=1" } }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args({ force: true })); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("already exist at destination"))).toBe(false); + expect(log.success.some((m) => m.includes("overwrite 1 file(s)"))).toBe(true); + const overwritten = await fs.readFile(`${FEATURE_PATH}/.env`); + expect(overwritten.success && overwritten.data).toBe("SECRET=1"); + }); + + test("a real file where a symlink belongs is left alone with a warning", async () => { + const { fs, git, shell } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR }), + files: { [`${FEATURE_PATH}/${CONFIG_FILENAME}`]: "{}" }, + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("exists but is not a symlink"))).toBe(true); + expect(await fs.isSymlink(`${FEATURE_PATH}/${CONFIG_FILENAME}`)).toBe(false); + }); +}); + +describe("sync — post-sync hooks", () => { + test("hooks run per worktree with the documented env", async () => { + const { fs, git, shell } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR, hooks: { "post-sync": ["pnpm install"] } }), + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(shell.calls).toHaveLength(1); + expect(shell.calls[0]?.command).toBe("pnpm install"); + expect(shell.calls[0]?.options.cwd).toBe(FEATURE_PATH); + expect(shell.calls[0]?.options.env).toEqual({ + WORKTREE_PATH: FEATURE_PATH, + WORKTREE_BRANCH: "feature", + REPO_ROOT: ROOT, + }); + expect(log.outro).toEqual(["Done!"]); + }); + + test("failing hook is reported as a warning, exit stays 0", async () => { + const shell = createFakeShell({ + results: new Map([["pnpm install", Result.err({ code: "EXECUTION_FAILED", message: "exit 1" })]]), + }); + const { fs, git } = scenario({ + config: JSON.stringify({ rootDir: WORKTREES_DIR, hooks: { "post-sync": ["pnpm install"] } }), + shell, + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes('Hook failed: "pnpm install" - exit 1'))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); +}); + +describe("sync — failure modes", () => { + test("unknown branch → failure exit with the spinner marked failed", async () => { + const { fs, git, shell } = scenario(); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args({ branch: "nope" })); + + expect(code).toBe(EXIT_FAILURE); + expect(spinnerLog.stop.some((m) => m.includes("Failed"))).toBe(true); + expect(log.error.some((m) => m.includes('Branch "nope" not found in worktrees'))).toBe(true); + expect(log.outro).toEqual([]); + }); + + test("outside a git repository → failure exit before any config load", async () => { + const { fs, git, shell } = scenario({ isRepo: false }); + const { ui, log, spinnerLog } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(EXIT_FAILURE); + expect(log.error.some((m) => m.includes("Not inside a git repository"))).toBe(true); + expect(spinnerLog.start).toEqual([]); + }); + + test("missing config → warns and syncs with defaults", async () => { + const { fs, git, shell } = scenario({ config: null }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Config not found at"))).toBe(true); + expect(log.info.some((m) => m.includes("up to date"))).toBe(true); + expect(log.outro).toEqual(["Done!"]); + }); + + test("legacy config → migration warning, sync still runs", async () => { + const { fs, git, shell } = scenario({ + configFilename: LEGACY_CONFIG_FILENAME, + config: JSON.stringify({ rootDir: WORKTREES_DIR, copy: [".env"] }), + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs, shell); + + const code = await runSync(container, args()); + + expect(code).toBe(0); + expect(log.warn.some((m) => m.includes("Using legacy .worktreekitrc config"))).toBe(true); + expect(await fs.exists(`${FEATURE_PATH}/.env`)).toBe(true); + }); +}); From 7b988dd5891cd11c6ced13a358617d6b05aeac4d Mon Sep 17 00:00:00 2001 From: Evgeniy Podivilov Date: Tue, 21 Jul 2026 22:50:12 +0100 Subject: [PATCH 3/3] test(config): add command-level tests for the config show subcommand --- src/cli/commands/config.test.ts | 317 ++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 src/cli/commands/config.test.ts diff --git a/src/cli/commands/config.test.ts b/src/cli/commands/config.test.ts new file mode 100644 index 0000000..7232f5d --- /dev/null +++ b/src/cli/commands/config.test.ts @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { CONFIG_FILENAME, LOCAL_CONFIG_FILENAME } from "../../domain/constants.ts"; +import type { FilesystemPort } from "../../domain/ports/filesystem-port.ts"; +import type { GitPort } from "../../domain/ports/git-port.ts"; +import type { UiPort } from "../../domain/ports/ui-port.ts"; +import type { Container } from "../../infrastructure/container.ts"; +import { resolveGlobalConfigPath } from "../../shared/xdg-paths.ts"; +import { createFakeFilesystem } from "../../test-utils/fake-filesystem.ts"; +import { createFakeGit } from "../../test-utils/fake-git.ts"; +import { EXIT_FAILURE } from "../exit-codes.ts"; +import { configCommand } from "./config.ts"; + +const ROOT = "/fake/project"; +const CONFIG_PATH = `${ROOT}/${CONFIG_FILENAME}`; +const LOCAL_CONFIG_PATH = `${ROOT}/${LOCAL_CONFIG_FILENAME}`; +const GLOBAL_CONFIG_PATH = resolveGlobalConfigPath(); + +const ESC = String.fromCharCode(27); +const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); + +/** Output is colorized through picocolors; assertions compare on the plain text. */ +function plain(value: string | undefined): string { + return (value ?? "").replace(ANSI_PATTERN, ""); +} + +interface FakeUiLog { + info: string[]; + error: string[]; + outro: string[]; +} + +function createFakeUi(): { ui: UiPort; log: FakeUiLog } { + const log: FakeUiLog = { info: [], error: [], outro: [] }; + + const ui = { + nonInteractive: false, + intro() {}, + outro(message: string) { + log.outro.push(message); + }, + info(message: string) { + log.info.push(message); + }, + success() {}, + warn() {}, + error(message: string) { + log.error.push(message); + }, + async spinner(_message: string, fn: () => Promise): Promise { + return fn(); + }, + createSpinner() { + return { start() {}, message() {}, stop() {} }; + }, + createMultiSpinner() { + return { update() {}, complete() {}, fail() {}, stop() {} }; + }, + async text() { + return ""; + }, + async confirm() { + return true; + }, + async select() { + return undefined as never; + }, + async multiselect() { + return [] as never; + }, + isCancel(_value: unknown): _value is symbol { + return false; + }, + cancel() {}, + } satisfies UiPort; + + return { ui, log }; +} + +function buildContainer(ui: UiPort, git: GitPort, fs: FilesystemPort): Container { + return { + ui, + git, + fs, + shell: {} as never, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }; +} + +class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +let exitSpy: typeof process.exit; +let recordedExit: number | null; + +beforeEach(() => { + exitSpy = process.exit; + recordedExit = null; + process.exit = ((code?: number): never => { + if (recordedExit === null) recordedExit = code ?? 0; + throw new ExitSignal(code ?? 0); + }) as typeof process.exit; +}); + +afterEach(() => { + process.exit = exitSpy; +}); + +interface CommandLike { + run: (ctx: { args: Record; cmd: unknown; rawArgs: string[] }) => Promise; +} + +async function resolveShow(container: Container): Promise { + const subCommands = configCommand(container).subCommands as Record | undefined; + const show = subCommands?.show; + const resolved = typeof show === "function" ? await (show as () => unknown)() : await show; + return resolved as CommandLike; +} + +interface RunResult { + code: number; + stdout: string[]; + stderr: string[]; +} + +async function runConfigShow(container: Container, args: Record): Promise { + const cmd = await resolveShow(container); + const stdout: string[] = []; + const stderr: string[] = []; + const originalStdout = process.stdout.write; + const originalStderr = process.stderr.write; + process.stdout.write = ((chunk: unknown): boolean => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown): boolean => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + await cmd.run({ args, cmd, rawArgs: [] }); + return { code: recordedExit ?? 0, stdout, stderr }; + } catch (err) { + if (err instanceof ExitSignal) return { code: recordedExit ?? err.code, stdout, stderr }; + throw err; + } finally { + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + } +} + +interface ScenarioOptions { + /** Repo config content; `null` omits the config file entirely. */ + config?: string | null; + local?: string; + global?: string; +} + +function scenario(opts: ScenarioOptions = {}): { fs: FilesystemPort; git: GitPort } { + const files: Record = { + ...(opts.config === null + ? {} + : { [CONFIG_PATH]: opts.config ?? JSON.stringify({ rootDir: ".worktrees", copy: [".env"] }) }), + ...(opts.local === undefined ? {} : { [LOCAL_CONFIG_PATH]: opts.local }), + ...(opts.global === undefined ? {} : { [GLOBAL_CONFIG_PATH]: opts.global }), + }; + const fs = createFakeFilesystem({ files, directories: [ROOT] }); + const git = createFakeGit({ root: ROOT, mainRoot: ROOT, worktrees: [], branches: ["main"] }); + return { fs, git }; +} + +/** Finds the provenance line for a single config path in the rendered block. */ +function fieldLine(block: string | undefined, path: string): string | undefined { + return plain(block) + .split("\n") + .find((line) => line.startsWith(`${path}:`)); +} + +describe("config show — human output", () => { + test("renders the sources header and per-field provenance", async () => { + const { fs, git } = scenario(); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code, stdout } = await runConfigShow(container, { json: false }); + + expect(code).toBe(0); + expect(stdout).toEqual([]); + expect(log.info).toHaveLength(2); + + const header = plain(log.info[0]); + expect(header).toContain("Sources:"); + expect(header).toContain(`repo: ${CONFIG_PATH}`); + expect(header).toContain(`(not found: ${LOCAL_CONFIG_FILENAME})`); + + const body = log.info[1]; + expect(fieldLine(body, "rootDir")).toContain('rootDir: ".worktrees"'); + expect(fieldLine(body, "rootDir")).toContain("← repo"); + expect(fieldLine(body, "copy")).toContain("← repo"); + expect(fieldLine(body, "symlinks")).toContain("← default"); + expect(fieldLine(body, "create.base")).toContain("create.base: (unset)"); + expect(fieldLine(body, "create.base")).toContain("← default"); + expect(log.outro).toEqual(["Done!"]); + }); + + test("local overrides win and are attributed to the local file", async () => { + const { fs, git } = scenario({ local: JSON.stringify({ rootDir: ".wt" }) }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code } = await runConfigShow(container, { json: false }); + + expect(code).toBe(0); + expect(plain(log.info[0])).toContain(`local: ${LOCAL_CONFIG_PATH}`); + expect(fieldLine(log.info[1], "rootDir")).toContain('rootDir: ".wt"'); + expect(fieldLine(log.info[1], "rootDir")).toContain("← local"); + }); + + test("global values are attributed to the global file", async () => { + const { fs, git } = scenario({ global: JSON.stringify({ defaultBase: "default" }) }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code } = await runConfigShow(container, { json: false }); + + expect(code).toBe(0); + expect(plain(log.info[0])).toContain("global:"); + expect(fieldLine(log.info[1], "defaultBase")).toContain('defaultBase: "default"'); + expect(fieldLine(log.info[1], "defaultBase")).toContain("← global"); + }); + + test("repo config overrides the global one for the same field", async () => { + const { fs, git } = scenario({ + config: JSON.stringify({ rootDir: ".worktrees", defaultBase: "current" }), + global: JSON.stringify({ defaultBase: "default" }), + }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code } = await runConfigShow(container, { json: false }); + + expect(code).toBe(0); + expect(fieldLine(log.info[1], "defaultBase")).toContain('defaultBase: "current"'); + expect(fieldLine(log.info[1], "defaultBase")).toContain("← repo"); + }); + + test("missing config → error exit, nothing rendered", async () => { + const { fs, git } = scenario({ config: null }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code } = await runConfigShow(container, { json: false }); + + expect(code).toBe(EXIT_FAILURE); + expect(log.error.some((m) => m.includes("Config not found at"))).toBe(true); + expect(log.info).toEqual([]); + expect(log.outro).toEqual([]); + }); + + test("invalid config → error exit with the parse message", async () => { + const { fs, git } = scenario({ config: "{ not json" }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code } = await runConfigShow(container, { json: false }); + + expect(code).toBe(EXIT_FAILURE); + expect(log.error.some((m) => m.includes("Invalid JSONC in"))).toBe(true); + }); +}); + +describe("config show --json", () => { + test("writes machine-readable provenance to stdout and skips the prompts UI", async () => { + const { fs, git } = scenario({ local: JSON.stringify({ copy: [".env.local"] }) }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code, stdout, stderr } = await runConfigShow(container, { json: true }); + + expect(code).toBe(0); + expect(stderr).toEqual([]); + expect(stdout).toHaveLength(1); + expect(stdout[0]?.endsWith("\n")).toBe(true); + + const payload = JSON.parse(stdout[0] ?? "") as { + fields: Record; + sources: { global: string | null; repo: string; local: string | null }; + }; + + expect(payload.sources).toEqual({ global: null, repo: CONFIG_PATH, local: LOCAL_CONFIG_PATH }); + expect(payload.fields.rootDir).toEqual({ value: ".worktrees", source: "repo", sourcePath: CONFIG_PATH }); + expect(payload.fields.copy).toEqual({ value: [".env.local"], source: "local", sourcePath: LOCAL_CONFIG_PATH }); + // `undefined` is serialized as null so every leaf stays present in the payload. + expect(payload.fields["create.base"]).toEqual({ value: null, source: "default", sourcePath: null }); + + // The JSON branch bypasses the interactive UI entirely. + expect(log.info).toEqual([]); + expect(log.outro).toEqual([]); + }); + + test("missing config → JSON error on stderr and a failure exit", async () => { + const { fs, git } = scenario({ config: null }); + const { ui, log } = createFakeUi(); + const container = buildContainer(ui, git, fs); + + const { code, stdout, stderr } = await runConfigShow(container, { json: true }); + + expect(code).toBe(EXIT_FAILURE); + expect(stdout).toEqual([]); + const first = JSON.parse(stderr[0] ?? "") as { error: string }; + expect(first.error).toContain("Config not found at"); + expect(log.error).toEqual([]); + }); +});