From ec2858cb1b5fa2837e32910f9e8b44aa8bfeea8f Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 19:08:47 -0700 Subject: [PATCH 1/9] feat(cli): support zero-config standalone workspaces --- README.md | 1 + contracts/cli-commands.json | 106 +++ docs/standalone.md | 20 + scripts/test/run-tests.ts | 12 +- src/commands/add.ts | 2 + src/commands/clone.ts | 12 +- src/commands/create.ts | 39 ++ src/commands/doctor.ts | 92 ++- src/commands/exec.ts | 11 +- src/commands/handoff.ts | 86 ++- src/commands/init.ts | 76 +++ src/commands/list.ts | 35 + src/commands/move.ts | 28 +- src/commands/prune.ts | 62 +- src/commands/pull.ts | 11 +- src/commands/push.ts | 11 +- src/commands/remove.ts | 131 +++- src/commands/setup.ts | 11 +- src/commands/status.ts | 69 +- src/commands/switch.ts | 21 +- src/commands/sync.ts | 5 +- src/contracts/cli-commands.ts | 138 +++- src/core/repository.ts | 22 + src/lib/hooks.ts | 23 +- src/lib/standalone.ts | 242 +++++++ src/lib/workspace-context.ts | 246 +++++++ src/lib/zero-config-bootstrap.ts | 256 +++++++ tests/integration/init.zero-config.test.ts | 280 ++++++++ .../integration/standalone-lifecycle.test.ts | 645 ++++++++++++++++++ tests/integration/workspace-context.test.ts | 199 ++++++ tests/unit/cli-contract.test.ts | 32 + 31 files changed, 2855 insertions(+), 69 deletions(-) create mode 100644 docs/standalone.md create mode 100644 src/lib/standalone.ts create mode 100644 src/lib/workspace-context.ts create mode 100644 src/lib/zero-config-bootstrap.ts create mode 100644 tests/integration/init.zero-config.test.ts create mode 100644 tests/integration/standalone-lifecycle.test.ts create mode 100644 tests/integration/workspace-context.test.ts diff --git a/README.md b/README.md index 477000b..f6e7aba 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ pnpm run build Arashi currently provides these commands: - `arashi init` +- [`arashi init --zero-config`](docs/standalone.md) for a single repository with `.worktrees/` paths and no persisted Arashi configuration - `arashi install` - `arashi update [--check] [--dry-run] [--yes]` - `arashi add ` diff --git a/contracts/cli-commands.json b/contracts/cli-commands.json index 98f8f44..e55b6ad 100644 --- a/contracts/cli-commands.json +++ b/contracts/cli-commands.json @@ -55,6 +55,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Adding child repositories requires persisted configuration." + }, "vscode": { "expectation": "required" } @@ -93,6 +97,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Cloning configured child repositories requires persisted configuration." + }, "vscode": { "expectation": "required" } @@ -229,6 +237,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -259,6 +270,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "excluded", "reason": "Diagnostics remain a terminal-focused maintenance workflow." @@ -332,6 +346,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Cross-repository execution requires persisted repository metadata." + }, "vscode": { "expectation": "excluded", "reason": "Arbitrary cross-repository process execution is intentionally terminal-only." @@ -405,6 +423,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "excluded", "reason": "Agent handoff generation is intentionally terminal-only." @@ -473,6 +494,13 @@ "required": true, "optional": false, "variadic": false + }, + { + "flags": "--zero-config", + "description": "Prepare implicit standalone mode without creating configuration", + "required": false, + "optional": false, + "variadic": false } ], "semantics": { @@ -485,8 +513,32 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "conditional", + "reason": "Only init --zero-config prepares standalone mode; ordinary init creates configured mode." + }, "vscode": { "expectation": "required" + }, + "zeroConfig": { + "compatibleOptions": ["--dry-run", "--json", "--verbose"], + "dryRun": { + "finalState": "unchanged", + "supported": true + }, + "incompatibleOptions": [ + "--force", + "--ignore-scope", + "--no-discover", + "--repos-dir", + "--worktrees-dir" + ], + "json": { + "singleEnvelope": true, + "supported": true, + "suppressesHumanStdout": true + }, + "option": "--zero-config" } } }, @@ -517,6 +569,10 @@ "expectation": "excluded", "reason": "The skill assumes Arashi is already installed; bootstrap guidance belongs in installation docs." }, + "standalone": { + "support": "not-applicable", + "reason": "Installation does not consume workspace context." + }, "vscode": { "expectation": "required" } @@ -568,6 +624,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "represented", "reason": "The worktree panel represents the CLI list workflow." @@ -613,6 +672,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -657,6 +719,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -708,6 +773,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Coordinated pull requires persisted repository metadata." + }, "vscode": { "expectation": "required" } @@ -766,6 +835,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Coordinated push requires persisted repository metadata." + }, "vscode": { "expectation": "excluded", "reason": "Push remains explicit terminal source-control behavior." @@ -847,6 +920,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -898,6 +974,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Repository setup coordination requires persisted repository metadata." + }, "vscode": { "expectation": "required" } @@ -921,6 +1001,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "not-applicable", + "reason": "This command does not consume Arashi workspace context." + }, "vscode": { "expectation": "required" } @@ -961,6 +1045,10 @@ "expectation": "represented", "reason": "Shell initialization is covered as part of the shell workflow." }, + "standalone": { + "support": "not-applicable", + "reason": "Shell initialization does not consume workspace context." + }, "vscode": { "expectation": "excluded", "reason": "Shell initialization configures terminals and is not an editor command." @@ -987,6 +1075,10 @@ "expectation": "represented", "reason": "Shell installation is covered as part of the shell workflow." }, + "standalone": { + "support": "not-applicable", + "reason": "Shell installation does not consume workspace context." + }, "vscode": { "expectation": "excluded", "reason": "Shell configuration installation is outside extension scope." @@ -1039,6 +1131,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -1147,6 +1242,9 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "full" + }, "vscode": { "expectation": "required" } @@ -1198,6 +1296,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "configured-only", + "reason": "Repository synchronization requires persisted repository metadata." + }, "vscode": { "expectation": "required" } @@ -1250,6 +1352,10 @@ "skills": { "expectation": "required" }, + "standalone": { + "support": "not-applicable", + "reason": "This command does not consume Arashi workspace context." + }, "vscode": { "expectation": "required" } diff --git a/docs/standalone.md b/docs/standalone.md new file mode 100644 index 0000000..df20054 --- /dev/null +++ b/docs/standalone.md @@ -0,0 +1,20 @@ +# Zero-config standalone workspaces + +Arashi can manage one ordinary, non-bare Git repository without `.arashi/config.json`. Bootstrap the repository-local convention explicitly: + +```sh +arashi init --zero-config +arashi create feat/example +arashi list +arashi status +arashi switch feat/example +arashi remove feat/example +``` + +Bootstrap creates the root `.worktrees/` directory and, only when Git does not already ignore the convention, appends the literal `.worktrees/` rule to the repository's common `info/exclude`. It never edits `.gitignore`, global Git configuration, or `.arashi/`. Use `--dry-run`, `--verbose`, and `--json` to preview or automate bootstrap; configuration-producing init options are incompatible with `--zero-config`. + +Standalone worktrees use the exact path `.worktrees/`, including natural nested directories for branch names such as `feat/example`. Before create—even during dry-run—Arashi asks Git whether that exact destination is effectively ignored. If it is exposed by a missing or negated rule, run `arashi init --zero-config` or add `.worktrees/` to the repository-local exclude file. + +Standalone mode supports `create`, `list`, `status`, `switch`, `remove`, `prune`, `doctor`, `move`, and `handoff`. Repository/group selection and coordinated child-repository commands require persisted configuration. Run ordinary `arashi init` to upgrade when you need child repositories, groups, custom worktree locations, configured defaults, or workspace/repository hooks. + +The same workspace is discovered from its main worktree and linked worktrees. Existing configuration always takes precedence, and malformed or unsupported configuration is reported rather than hidden by standalone fallback. diff --git a/scripts/test/run-tests.ts b/scripts/test/run-tests.ts index f1edd43..911efbd 100644 --- a/scripts/test/run-tests.ts +++ b/scripts/test/run-tests.ts @@ -1,7 +1,8 @@ import { dirname, join, relative } from "path"; -import { readFileSync, readdirSync } from "fs"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "url"; +import { tmpdir } from "os"; const currentFilePath = fileURLToPath(import.meta.url); const currentDir = dirname(currentFilePath); @@ -116,6 +117,7 @@ function getHostTestFiles(): string[] { async function main(): Promise { const selectedFiles = getHostTestFiles(); + const isolatedHome = mkdtempSync(join(tmpdir(), "arashi-test-home-")); if (selectedFiles.length === 0) { throw new Error("No test files matched the current platform."); @@ -126,6 +128,13 @@ async function main(): Promise { [join(workspaceRoot, "node_modules", "vitest", "vitest.mjs"), "run", ...selectedFiles], { cwd: workspaceRoot, + env: { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "commit.gpgsign", + GIT_CONFIG_VALUE_0: "false", + HOME: isolatedHome, + }, stdio: "inherit", }, ); @@ -133,6 +142,7 @@ async function main(): Promise { proc.once("error", reject); proc.once("exit", (code) => resolve(code ?? 1)); }); + rmSync(isolatedHome, { force: true, recursive: true }); process.exit(exitCode); } diff --git a/src/commands/add.ts b/src/commands/add.ts index e32435f..2cb941c 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -30,6 +30,7 @@ import { unknownErrorToJsonError, writeJsonEnvelope, } from "../lib/json-output.ts"; +import { throwIfStandaloneWorkspace } from "../lib/workspace-context.ts"; type RepoConfig = Awaited>["repos"][string]; @@ -405,6 +406,7 @@ const executeAdd = async ( // Step 1: Validate workspace is initialized const hasConfig = await configExists(workspaceRoot); if (!hasConfig) { + await throwIfStandaloneWorkspace("add", workspaceRoot); throw new AddCommandError( 'Workspace not initialized. Run "arashi init" first.', AddCommandErrorCode.CONFIG_UPDATE_FAILED, diff --git a/src/commands/clone.ts b/src/commands/clone.ts index 44ea132..8271c1c 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -11,13 +11,7 @@ import { unsupportedJsonModeError, writeJsonEnvelope, } from "../lib/json-output.ts"; -import { - findWorkspaceRoot, - loadConfig, - normalizeConfig, - repairRepositoryGitUrls, - saveConfig, -} from "../lib/config.ts"; +import { loadConfig, normalizeConfig, repairRepositoryGitUrls, saveConfig } from "../lib/config.ts"; import { info, error as logError, spinner, success, warn } from "../lib/logger.ts"; import { join, resolve } from "path"; import { @@ -35,6 +29,7 @@ import { type ManagedIgnoreReconciliation, } from "../lib/managed-ignore.ts"; import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; +import { findConfiguredWorkspaceRoot } from "../lib/workspace-context.ts"; interface Choice { value: T; @@ -139,7 +134,8 @@ export async function executeClone( options: CloneCommandOptions, deps: CloneCommandDependencies = {}, ): Promise { - const resolveWorkspaceRoot = deps.findWorkspaceRoot ?? findWorkspaceRoot; + const resolveWorkspaceRoot = + deps.findWorkspaceRoot ?? (() => findConfiguredWorkspaceRoot("clone")); const readConfig = deps.loadConfig ?? loadConfig; const writeConfig = deps.saveConfig ?? saveConfig; const repairGitUrls = deps.repairRepositoryGitUrls ?? repairRepositoryGitUrls; diff --git a/src/commands/create.ts b/src/commands/create.ts index c3bafcd..f731264 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -51,6 +51,11 @@ import { type ManagedIgnoreReconciliation, } from "../lib/managed-ignore.ts"; import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { + createStandaloneWorktree, + StandaloneDestinationNotIgnoredError, +} from "../lib/standalone.ts"; type LoadedConfig = Awaited>; type Config = LoadedConfig["config"]; @@ -119,6 +124,9 @@ const createCommandErrorCode = (createError: unknown): string => { if (createError instanceof CreateSetupError) { return "WORKSPACE_CONFIG_NOT_FOUND"; } + if (createError instanceof StandaloneDestinationNotIgnoredError) { + return createError.code; + } if (createError instanceof ConflictAbortedError) { return "BRANCH_CONFLICT"; } @@ -133,6 +141,9 @@ const createCommandErrorCode = (createError: unknown): string => { }; const createCommandErrorDetails = (createError: unknown): Record | undefined => { + if (createError instanceof StandaloneDestinationNotIgnoredError) { + return createError.details; + } if (createError instanceof InvalidBranchNameError) { return { branchName: createError.branchName, reason: createError.reason }; } @@ -697,6 +708,34 @@ export async function executeCreate( throw new EmptyRepositoryFiltersError(emptyFilters); } + const workspaceContext = await resolveWorkspaceContext(); + if (workspaceContext.mode === "standalone") { + if (options.only || options.group || options.interactive) { + throw new CreateSetupError( + "Repository selection is not meaningful in standalone mode; omit --only, --group, and --interactive.", + ); + } + const standaloneResult = await createStandaloneWorktree( + workspaceContext, + branchName, + options.dryRun === true, + { + quiet: options.json === true, + skipHooks: options.noHooks === true || options.hooks === false, + }, + ); + if (options.json) writeJsonEnvelope(createJsonSuccessEnvelope("create", standaloneResult)); + else { + info("Workspace mode: standalone"); + success( + options.dryRun + ? `Would create worktree at ${standaloneResult.worktreePath}` + : `Created worktree at ${standaloneResult.worktreePath}`, + ); + } + return ZERO; + } + const resolveInvocationContext = deps.resolveCreateInvocationContext ?? resolveCreateInvocationContext; const resolveIgnoreWorkspaceRoot = diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 94cb132..e126151 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,11 +1,21 @@ import { createJsonErrorEnvelope, createJsonSuccessEnvelope, + unknownErrorToJsonError, writeJsonEnvelope, } from "../lib/json-output.ts"; import { Command } from "commander"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { exec } from "../lib/git.ts"; import chalk from "chalk"; -import { runDoctor } from "../lib/doctor.ts"; +import { resolve } from "path"; +import { + repositoryStatusToDoctorFindings, + runDoctor, + summarizeDoctorFindings, +} from "../lib/doctor.ts"; +import { checkRepoStatus } from "./status.ts"; +import { discoverPrunableWorktrees } from "../core/remove.ts"; const ZERO = 0; const ERROR_EXIT_CODE = 1; @@ -83,21 +93,97 @@ export const formatDoctorHumanOutput = (result: DoctorResult): string => { }; export const executeDoctor = async (options: DoctorOptions = {}): Promise => { + let context; + try { + context = await resolveWorkspaceContext(); + } catch (error) { + if (options.json) + writeJsonEnvelope( + createJsonErrorEnvelope("doctor", unknownErrorToJsonError(error, "CONFIG_LOAD_FAILED")), + ); + else console.error(error instanceof Error ? error.message : String(error)); + return ERROR_EXIT_CODE; + } + if (context?.mode === "standalone") { + let ignored = true; + try { + await exec( + ["check-ignore", "--no-index", ".worktrees/.arashi-ignore-probe"], + context.mainRoot, + ); + } catch { + ignored = false; + } + const repositoryStatus = await checkRepoStatus(context.repository.name, context.mainRoot); + const pruneResults = await discoverPrunableWorktrees([context.repository]); + const findings = [ + ...(!ignored + ? [ + { + category: "configuration" as const, + code: "STANDALONE_WORKTREES_NOT_IGNORED", + message: ".worktrees is not effectively ignored", + scope: context.mainRoot, + severity: "warning" as const, + suggestedCommands: ["arashi init --zero-config"], + }, + ] + : []), + ...repositoryStatusToDoctorFindings(repositoryStatus), + ...pruneResults.flatMap((repository) => + repository.prunable.map((worktree) => ({ + category: "worktree" as const, + code: "WORKTREE_STALE_METADATA", + details: { + path: worktree.path, + pruneReason: worktree.pruneReason, + repository: repository.name, + }, + message: `Repository '${repository.name}' has stale worktree metadata for ${worktree.path}.`, + scope: `repository:${repository.name}`, + severity: "warning" as const, + suggestedCommands: ["arashi prune --dry-run", "arashi prune"], + })), + ), + ]; + const data = { + checkedCategories: ["workspace", "repository", "worktree"] as const, + findings, + mode: "standalone", + repositoryPath: context.mainRoot, + summary: summarizeDoctorFindings(findings), + workspaceRoot: context.mainRoot, + }; + if (options.json) writeJsonEnvelope(createJsonSuccessEnvelope("doctor", data)); + else + console.log( + `Arashi workspace doctor\nWorkspace mode: standalone\nWorkspace: ${context.mainRoot}\n${findings.length ? findings.map((finding) => finding.message).join("\n") : "No workspace health findings were detected."}`, + ); + return ZERO; + } const result = await runDoctor(); const hasBlockingFindings = result.summary.error > ZERO; + const configuredData = { + ...result, + mode: "configured" as const, + worktreesBase: + context.mode === "configured" + ? resolve(context.workspaceRoot, context.config.worktreesDir ?? "../.worktrees") + : undefined, + }; if (options.json) { if (hasBlockingFindings) { writeJsonEnvelope( createJsonErrorEnvelope("doctor", { code: "DOCTOR_BLOCKING_FINDINGS", - details: result as unknown as Record, + details: configuredData as unknown as Record, message: `${result.summary.error} blocking doctor finding(s) detected`, }), ); } else { writeJsonEnvelope( - createJsonSuccessEnvelope("doctor", result as unknown as Record), + createJsonSuccessEnvelope("doctor", configuredData as unknown as Record), ); } } else { diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 7a76008..582992a 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -11,7 +11,11 @@ import { unknownErrorToJsonError, writeJsonEnvelope, } from "../lib/json-output.ts"; -import { findWorkspaceRoot, loadWorkspaceRepositories } from "../lib/config.ts"; +import { loadWorkspaceRepositories } from "../lib/config.ts"; +import { + ConfiguredWorkspaceRequiredError, + findConfiguredWorkspaceRoot, +} from "../lib/workspace-context.ts"; import { Command } from "commander"; import { EmptyRepositoryFiltersError, filterRepositories } from "../lib/repo-filter.ts"; import { normalizeSpawnEnvironment } from "../lib/shell-directives.ts"; @@ -266,8 +270,9 @@ const executeExec = async ( const jobs = parseJobs(options.jobs); let workspaceRoot = ""; try { - workspaceRoot = await findWorkspaceRoot(); - } catch { + workspaceRoot = await findConfiguredWorkspaceRoot("exec"); + } catch (error) { + if (error instanceof ConfiguredWorkspaceRequiredError) throw error; throw new CliUsageError( 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', ); diff --git a/src/commands/handoff.ts b/src/commands/handoff.ts index 58125da..f6a88fa 100644 --- a/src/commands/handoff.ts +++ b/src/commands/handoff.ts @@ -5,7 +5,12 @@ * coordinated Arashi workspace. */ -import { checkAllRepos, collectStatusWarnings, summarizeStatuses } from "./status.ts"; +import { + checkAllRepos, + checkRepoStatus, + collectStatusWarnings, + summarizeStatuses, +} from "./status.ts"; import { createJsonErrorEnvelope, createJsonSuccessEnvelope, @@ -13,8 +18,11 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { standaloneWorktrees } from "../lib/standalone.ts"; import { info, error as logError } from "../lib/logger.ts"; -import { relative, resolve } from "path"; +import { basename, join, relative, resolve } from "path"; +import { realpath } from "fs/promises"; import { Command } from "commander"; type JsonWarning = NonNullable[2]>[number]; @@ -54,12 +62,15 @@ interface HandoffRepositorySummary { } interface HandoffData { + callerWorktree?: string; context: HandoffContext; currentRepository: { name: string; path: string } | null; effectiveOptions: { format: "json" | "markdown"; }; generatedNextCommands: string[]; + mode: "configured" | "standalone"; + repositoryPath?: string; repositories: HandoffRepositorySummary[]; summary: ReturnType & { touchedCount: number; @@ -68,6 +79,8 @@ interface HandoffData { branch: string; path: string; }; + workspaceRoot?: string; + worktreesBase?: string; } interface BuildHandoffDataInput { @@ -196,6 +209,7 @@ const buildHandoffData = ({ format: options.json ? "json" : "markdown", }, generatedNextCommands: collectGeneratedNextCommands(statuses), + mode: "configured", repositories: statuses.map((status) => summarizeRepo(status)), summary: { ...summary, @@ -205,6 +219,7 @@ const buildHandoffData = ({ branch: mainRepository?.branch.localBranch || "unknown", path: workspaceRoot, }, + workspaceRoot, }; }; @@ -262,8 +277,10 @@ export const renderMarkdownReport = (data: HandoffData): string => { ## Workspace +- Workspace mode: ${data.mode} - Path: ${data.workspace.path} - Branch: ${data.workspace.branch} +- Caller worktree: ${data.callerWorktree ?? "not applicable"} - Current repository: ${data.currentRepository ? `${data.currentRepository.name} (${data.currentRepository.path})` : "not resolved"} ## Summary @@ -297,6 +314,70 @@ ${commandBlock ? `${commandBlock}\n` : "- No next commands suggested.\n"} }; const runHandoff = async (options: HandoffOptions): Promise => { + let context; + try { + context = await resolveWorkspaceContext(); + } catch (error) { + if (options.json) + writeJsonEnvelope(createJsonErrorEnvelope("handoff", unknownErrorToJsonError(error))); + else logError(error instanceof Error ? error.message : String(error)); + process.exit(ERROR_EXIT_CODE); + } + if (context.mode === "standalone") { + try { + const worktrees = await standaloneWorktrees(context); + const statuses = await Promise.all( + worktrees.map((worktree) => + checkRepoStatus(worktree.branch ?? basename(worktree.path), worktree.path), + ), + ); + const callerWorktree = process.cwd(); + const canonicalCaller = await realpath(callerWorktree); + const canonicalStatusPaths = await Promise.all( + statuses.map(async (status) => await realpath(status.path)), + ); + const callerStatus = statuses[canonicalStatusPaths.indexOf(canonicalCaller)]; + const selectedCallerStatus = + callerStatus ?? + statuses.find((status) => resolve(status.path) === resolve(callerWorktree)) ?? + statuses[ZERO]; + const data: HandoffData = { + ...buildHandoffData({ + cwd: canonicalCaller, + options, + statuses, + workspaceRoot: context.mainRoot, + }), + callerWorktree, + currentRepository: selectedCallerStatus + ? { name: selectedCallerStatus.name, path: selectedCallerStatus.path } + : null, + mode: "standalone", + repositoryPath: context.mainRoot, + workspace: { + branch: selectedCallerStatus?.branch.localBranch || "unknown", + path: context.mainRoot, + }, + worktreesBase: join(context.mainRoot, ".worktrees"), + }; + if (options.json) + writeJsonEnvelope( + createJsonSuccessEnvelope( + "handoff", + data as unknown as Record, + collectStatusWarnings(statuses), + ), + ); + else process.stdout.write(renderMarkdownReport(data)); + if (statuses.some((status) => status.error !== null)) process.exit(ERROR_EXIT_CODE); + return; + } catch (error) { + if (options.json) + writeJsonEnvelope(createJsonErrorEnvelope("handoff", unknownErrorToJsonError(error))); + else logError(error instanceof Error ? error.message : String(error)); + process.exit(ERROR_EXIT_CODE); + } + } let workspaceRoot = ""; try { workspaceRoot = await findWorkspaceRoot(); @@ -325,6 +406,7 @@ const runHandoff = async (options: HandoffOptions): Promise => { statuses, workspaceRoot, }); + data.worktreesBase = resolve(workspaceRoot, config.worktreesDir ?? "../.worktrees"); const warnings: JsonWarning[] = collectStatusWarnings(statuses); if (options.json) { diff --git a/src/commands/init.ts b/src/commands/init.ts index e95e008..b838b43 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -48,6 +48,10 @@ import { type ManagedIgnoreReconciliation, type ManagedIgnoreScope, } from "../lib/managed-ignore.ts"; +import { + bootstrapZeroConfig, + type ZeroConfigBootstrapResult, +} from "../lib/zero-config-bootstrap.ts"; type Config = Parameters[1]; type RepoConfig = Config["repos"][string]; @@ -132,6 +136,8 @@ const previewBootstrapManagedIgnore = ( // ============================================================================ interface InitOptions { + /** Prepare the implicit standalone .worktrees convention only */ + zeroConfig?: boolean; /** Custom location for managed repositories */ reposDir?: string; @@ -1263,6 +1269,7 @@ export function createCommand(): Command { .option("--repos-dir ", "Custom location for managed repositories") .option("--worktrees-dir ", "Custom base location for managed worktrees") .option("--ignore-scope ", "Managed Git ignore scope: local (default), tracked, or none") + .option("--zero-config", "Prepare implicit standalone mode without creating configuration") .option("--force", "Overwrite existing configuration if present") .option("--no-discover", "Skip automatic repository discovery") .option("--dry-run", "Show what would be done without making changes") @@ -1272,6 +1279,7 @@ export function createCommand(): Command { "after", ` Examples: + $ arashi init --zero-config # Standalone .worktrees/ convention only $ arashi init # Local info/exclude rules (default) $ arashi init --ignore-scope tracked # Team-owned .gitignore block $ arashi init --ignore-scope none # Manual ignore management @@ -1281,6 +1289,74 @@ Existing effective tracked, local, or global rules are honored. Arashi never mod `, ) .action(async (options: InitOptions & { discover?: boolean; json?: boolean }) => { + if (options.zeroConfig) { + const conflicts = [ + options.reposDir !== undefined ? "--repos-dir" : undefined, + options.worktreesDir !== undefined ? "--worktrees-dir" : undefined, + options.ignoreScope !== undefined ? "--ignore-scope" : undefined, + options.force ? "--force" : undefined, + options.discover === false ? "--no-discover" : undefined, + ].filter((value): value is string => value !== undefined); + + if (conflicts.length > ZERO) { + const conflictError = new Error( + `--zero-config is incompatible with ${conflicts.join(", ")}.`, + ); + if (options.json) { + writeJsonEnvelope( + createJsonErrorEnvelope("init", { + code: "ZERO_CONFIG_INCOMPATIBLE_OPTIONS", + details: { conflicts, mode: "standalone" }, + message: conflictError.message, + }), + ); + } else { + logError(conflictError.message); + } + process.exit(ExitCode.INVALID_PATH); + } + + let zeroConfigResult: ZeroConfigBootstrapResult; + try { + zeroConfigResult = await bootstrapZeroConfig(process.cwd(), { + dryRun: options.dryRun, + }); + } catch (bootstrapError) { + if (options.json) { + writeJsonEnvelope( + createJsonErrorEnvelope( + "init", + unknownErrorToJsonError(bootstrapError, "ZERO_CONFIG_BOOTSTRAP_FAILED"), + ), + ); + } else { + logError((bootstrapError as Error).message); + } + process.exit(ExitCode.NOT_GIT_REPOSITORY); + } + + if (options.json) { + writeJsonEnvelope( + createJsonSuccessEnvelope( + "init", + zeroConfigResult as unknown as Record, + ), + ); + } else if (options.dryRun) { + info("Standalone zero-config bootstrap (dry run)"); + console.log(` • .worktrees directory: ${zeroConfigResult.worktreesDirectory.path}`); + console.log(` • Local exclude rule: ${zeroConfigResult.localExclude.rule}`); + console.log("No changes were made."); + } else { + success("Initialized standalone zero-config workspace"); + console.log(` • Worktrees: ${zeroConfigResult.worktreesDirectory.path}`); + console.log(` • Local exclude: ${zeroConfigResult.localExclude.path}`); + console.log(" • Create a worktree: arashi create "); + console.log(" • Upgrade for coordination: arashi init"); + } + process.exit(ExitCode.SUCCESS); + } + // Commander converts --no-discover to discover: false const normalizedOptions: InitOptions = { dryRun: options.dryRun, diff --git a/src/commands/list.ts b/src/commands/list.ts index f470350..41b8176 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -14,11 +14,14 @@ import { } from "../types/list.ts"; import { createJsonErrorEnvelope, + createJsonSuccessEnvelope, unknownErrorToJsonError, writeJsonEnvelope, } from "../lib/json-output.ts"; import { listCommand } from "../core/list.ts"; import { error as logError } from "../lib/logger.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { standaloneWorktrees } from "../lib/standalone.ts"; type ListCommandOptions = Parameters[0]; @@ -71,6 +74,38 @@ Examples: ) .action(async (options: CliOptions) => { try { + const context = await resolveWorkspaceContext(); + if (context.mode === "standalone") { + const worktrees = await standaloneWorktrees(context); + if (options.json) { + writeJsonEnvelope( + createJsonSuccessEnvelope("list", { + mode: "standalone", + repositoryPath: context.mainRoot, + workspaceRoot: context.mainRoot, + worktrees, + }), + ); + } else { + if (options.table) { + console.log("BRANCH\tHEAD\tWORKTREE"); + for (const worktree of worktrees) + console.log( + `${worktree.branch ?? "(detached)"}\t${worktree.head}\t${worktree.path}`, + ); + } else if (options.verbose) { + console.log(`Workspace mode: standalone\nMain repository: ${context.mainRoot}`); + for (const worktree of worktrees) { + console.log( + `\nWorktree: ${worktree.path}\n Branch: ${worktree.branch ?? "(detached)"}\n HEAD: ${worktree.head}`, + ); + } + } else { + for (const worktree of worktrees) console.log(worktree.path); + } + } + process.exit(0); + } await executeList(options); } catch (error) { if (options.json) { diff --git a/src/commands/move.ts b/src/commands/move.ts index afd1491..08e3f70 100644 --- a/src/commands/move.ts +++ b/src/commands/move.ts @@ -20,11 +20,11 @@ import { unsupportedJsonModeError, writeJsonEnvelope, } from "../lib/json-output.ts"; -import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; +import { resolveWorkspaceContext, requireAvailableWorkspace } from "../lib/workspace-context.ts"; import { info, error as logError, success, warn } from "../lib/logger.ts"; import { Command } from "commander"; import { select as promptSelect } from "../lib/prompts.ts"; -import { resolve } from "path"; +import { join, resolve } from "path"; interface MoveCommandOptions { from?: string; @@ -149,10 +149,12 @@ export async function executeMove( promptHandlers: MovePromptHandlers = DEFAULT_PROMPT_HANDLERS, ): Promise { const invocationPath = resolve("."); - const workspaceRoot = await findWorkspaceRoot(invocationPath); - const config = await loadConfig(workspaceRoot); + const context = await resolveWorkspaceContext(invocationPath); + requireAvailableWorkspace(context); + const workspaceRoot = context.workspaceRoot; + const config = context.config; const repositories = buildRepositoryTargets(workspaceRoot, config.repos); - const currentWorkspace = await findWorkspaceByPath(repositories, workspaceRoot); + const currentWorkspace = await findWorkspaceByPath(repositories, invocationPath); const workspaces = await discoverWorkspaces(repositories); let source: WorkspaceSelection | null = options.from @@ -199,12 +201,26 @@ export async function executeMove( const plan = buildMovePlan(source, target); const summary = await executeMovePlan(plan); + const data = + context.mode === "standalone" + ? { + ...summary, + mode: "standalone" as const, + repositoryPath: context.mainRoot, + workspaceRoot: context.mainRoot, + worktreesBase: join(context.mainRoot, ".worktrees"), + } + : { ...summary, mode: "configured" as const, workspaceRoot }; if (options.json) { writeJsonEnvelope( - createJsonSuccessEnvelope("move", summary as unknown as Record), + createJsonSuccessEnvelope("move", data as unknown as Record), ); } else { + if (context.mode === "standalone") { + info(`Workspace mode: standalone`); + info(`Main repository: ${context.mainRoot}`); + } printSummary(summary); } diff --git a/src/commands/prune.ts b/src/commands/prune.ts index d3504cf..11e7569 100644 --- a/src/commands/prune.ts +++ b/src/commands/prune.ts @@ -15,6 +15,7 @@ import { discoverPrunableWorktrees, pruneRepositoryWorktrees } from "../core/rem import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; import { info, error as logError } from "../lib/logger.ts"; import { Command } from "commander"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; const ZERO = 0; const ONE = 1; @@ -125,6 +126,61 @@ const formatHumanOutput = (data: PruneData): string => { }; export const executePrune = async (options: PruneOptions): Promise => { + let context; + try { + context = await resolveWorkspaceContext(); + } catch (error) { + if (options.json) + writeJsonEnvelope( + createJsonErrorEnvelope("prune", unknownErrorToJsonError(error, "CONFIG_LOAD_FAILED")), + ); + else logError(error instanceof Error ? error.message : String(error)); + return ONE; + } + if (context.mode === "standalone") { + const dryRun = options.dryRun === true; + const expire = options.expire ?? "now"; + const repositories = await discoverPrunableWorktrees([context.repository]); + if (dryRun) { + for (const repo of repositories) if (repo.status !== "failed") repo.status = "skipped"; + } else { + for (const repo of repositories) { + if (repo.status === "failed" || repo.prunable.length === ZERO) { + if (repo.status !== "failed") repo.status = "skipped"; + continue; + } + try { + await pruneRepositoryWorktrees(repo.path, expire); + repo.prunedCount = repo.prunable.length; + repo.status = "pruned"; + } catch (error) { + repo.error = error instanceof Error ? error.message : String(error); + repo.status = "failed"; + } + } + } + const summarized = summarize({ dryRun, expire, repositories, workspaceRoot: context.mainRoot }); + const data = { + ...summarized, + mode: "standalone" as const, + repositoryPath: context.mainRoot, + }; + if (options.json) { + if (data.totalFailed > ZERO) + writeJsonEnvelope( + createJsonErrorEnvelope("prune", { + code: "PRUNE_FAILED", + details: data as unknown as Record, + message: "The standalone repository failed to prune", + }), + ); + else writeJsonEnvelope(createJsonSuccessEnvelope("prune", data)); + } else + console.log( + `Workspace mode: standalone\nMain repository: ${context.mainRoot}\n${formatHumanOutput(data)}`, + ); + return data.totalFailed > ZERO ? ONE : ZERO; + } let workspaceRoot = ""; try { workspaceRoot = await findWorkspaceRoot(); @@ -177,7 +233,11 @@ export const executePrune = async (options: PruneOptions): Promise => { ); } - const data = summarize({ dryRun, expire, repositories: results, workspaceRoot }); + const data = { + ...summarize({ dryRun, expire, repositories: results, workspaceRoot }), + mode: "configured" as const, + worktreesBase: resolve(workspaceRoot, config.worktreesDir ?? "../.worktrees"), + }; if (options.json) { const hasFailures = data.totalFailed > ZERO; if (hasFailures) { diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 41fe73e..36ecc0c 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -18,7 +18,7 @@ import { unknownErrorToJsonError, writeJsonEnvelope, } from "../lib/json-output.ts"; -import { findWorkspaceRoot, loadWorkspaceRepositories } from "../lib/config.ts"; +import { loadWorkspaceRepositories } from "../lib/config.ts"; import { Command } from "commander"; import { checkRemoteChanges } from "../lib/git-remote.ts"; import { EmptyRepositoryFiltersError, filterRepositories } from "../lib/repo-filter.ts"; @@ -27,6 +27,10 @@ import { runPullWithRollback } from "../lib/pull-runner.ts"; import { reconcileManagedIgnore } from "../lib/managed-ignore.ts"; import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; import { fileExists } from "../lib/filesystem.ts"; +import { + ConfiguredWorkspaceRequiredError, + findConfiguredWorkspaceRoot, +} from "../lib/workspace-context.ts"; const ZERO = 0; const ONE = 1; @@ -79,8 +83,9 @@ const excludeWorkspaceRoot = ( const executePull = async (options: PullCommandOptions): Promise => { let workspaceRoot = ""; try { - workspaceRoot = await findWorkspaceRoot(); - } catch { + workspaceRoot = await findConfiguredWorkspaceRoot("pull"); + } catch (error) { + if (error instanceof ConfiguredWorkspaceRequiredError) throw error; throw new CliUsageError( 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', ); diff --git a/src/commands/push.ts b/src/commands/push.ts index 4c8eb13..6e8669e 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -12,7 +12,11 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { executePushPlan, planPush } from "../lib/push-runner.ts"; -import { findWorkspaceRoot, loadWorkspaceRepositories } from "../lib/config.ts"; +import { loadWorkspaceRepositories } from "../lib/config.ts"; +import { + ConfiguredWorkspaceRequiredError, + findConfiguredWorkspaceRoot, +} from "../lib/workspace-context.ts"; import { Command } from "commander"; import { EmptyRepositoryFiltersError, filterRepositories } from "../lib/repo-filter.ts"; import { info } from "../lib/logger.ts"; @@ -35,8 +39,9 @@ export interface PushCommandOptions { export const executePush = async (options: PushCommandOptions): Promise => { let workspaceRoot = ""; try { - workspaceRoot = await findWorkspaceRoot(); - } catch { + workspaceRoot = await findConfiguredWorkspaceRoot("push"); + } catch (error) { + if (error instanceof ConfiguredWorkspaceRequiredError) throw error; throw new CliUsageError( 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', ); diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 907da47..b7a8c61 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -46,12 +46,14 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { runStandaloneGlobalHooks, standaloneWorktrees } from "../lib/standalone.ts"; +import { exec as standaloneGitExec, getDefaultBranch } from "../lib/git.ts"; import { info, error as logError, spinner, warn } from "../lib/logger.ts"; import { confirm as promptConfirm, multiSelect as promptMultiSelect } from "../lib/prompts.ts"; import { Command } from "commander"; import chalk from "chalk"; import { existsSync } from "fs"; -import { getDefaultBranch } from "../lib/git.ts"; interface Choice { value: T; @@ -99,6 +101,21 @@ interface CliOptions { dryRun?: boolean; } +class StandaloneRemovePartialFailure extends Error { + readonly code = "STANDALONE_REMOVE_PARTIAL_FAILURE"; + readonly details: { + finalState: { branchExists: boolean; worktreeExists: boolean }; + hookFailures: { hookName: string; message: string }[]; + operationFailures: { message: string; operation: string }[]; + }; + + constructor(details: StandaloneRemovePartialFailure["details"]) { + super("Standalone remove completed with one or more operation or finalization failures"); + this.details = details; + this.name = "StandaloneRemovePartialFailure"; + } +} + const loadWorkspaceConfig = async (workspaceRoot: string): Promise => { try { return await loadConfig(workspaceRoot); @@ -187,6 +204,118 @@ export async function executeRemove( ): Promise { const startTime = Date.now(); + const workspaceContext = await resolveWorkspaceContext(); + if (workspaceContext.mode === "standalone") { + if (!branchArg) { + throw new RemoveCommandError( + "A branch or worktree path is required in non-interactive standalone mode", + RemoveCommandErrorCode.NON_INTERACTIVE, + ); + } + const worktrees = await standaloneWorktrees(workspaceContext); + const target = worktrees.find((entry) => + options.path ? resolve(entry.path) === resolve(branchArg) : entry.branch === branchArg, + ); + if (!target || target.path === workspaceContext.mainRoot) { + throw new RemoveCommandError( + `Standalone worktree not found: ${branchArg}`, + RemoveCommandErrorCode.BRANCH_NOT_FOUND, + ); + } + if (!options.dryRun) { + await runStandaloneGlobalHooks( + workspaceContext, + "pre-remove", + target.branch ?? branchArg, + target.path, + false, + options.json === true, + ); + } + const operationFailures: { message: string; operation: string }[] = []; + const hookFailures: { hookName: string; message: string }[] = []; + if (!options.dryRun && !options.keepWorktrees) { + try { + await standaloneGitExec( + [ + "worktree", + "remove", + ...(options.force || options.checkDirty === false ? ["--force"] : []), + target.path, + ], + workspaceContext.mainRoot, + ); + } catch (error) { + operationFailures.push({ + message: error instanceof Error ? error.message : String(error), + operation: "remove-worktree", + }); + } + } + if (!options.dryRun && !options.keepBranches && target.branch) { + try { + await standaloneGitExec(["branch", "-D", target.branch], workspaceContext.mainRoot); + } catch (error) { + operationFailures.push({ + message: error instanceof Error ? error.message : String(error), + operation: "delete-branch", + }); + } + } + if (!options.dryRun) { + try { + await runStandaloneGlobalHooks( + workspaceContext, + "post-remove", + target.branch ?? branchArg, + target.path, + false, + options.json === true, + ); + } catch (error) { + hookFailures.push({ + hookName: "post-remove", + message: error instanceof Error ? error.message : String(error), + }); + } + if (operationFailures.length > ZERO || hookFailures.length > ZERO) { + let finalBranchExists = false; + if (target.branch) { + try { + await standaloneGitExec( + ["show-ref", "--verify", `refs/heads/${target.branch}`], + workspaceContext.mainRoot, + ); + finalBranchExists = true; + } catch { + finalBranchExists = false; + } + } + throw new StandaloneRemovePartialFailure({ + finalState: { + branchExists: finalBranchExists, + worktreeExists: existsSync(target.path), + }, + hookFailures, + operationFailures, + }); + } + } + const data = { + branch: target.branch, + dryRun: options.dryRun === true, + mode: "standalone", + repositoryPath: workspaceContext.mainRoot, + worktreePath: target.path, + }; + if (options.json) writeJsonEnvelope(createJsonSuccessEnvelope("remove", data)); + else + console.log( + `${options.dryRun ? "Would remove" : "Removed"} standalone worktree ${target.path}`, + ); + return ZERO; + } + if (options.json && !branchArg) { writeJsonEnvelope(unsupportedJsonModeError("remove", "interactive-selection")); return ONE; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 90a1f3a..96e4c98 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -16,11 +16,15 @@ import { isExecutableTarget, orderSetupTargets, } from "../lib/setup-targets.ts"; -import { findWorkspaceRoot, loadWorkspaceRepositories } from "../lib/config.ts"; +import { loadWorkspaceRepositories } from "../lib/config.ts"; import { info, error as logError } from "../lib/logger.ts"; import { Command } from "commander"; import { EmptyRepositoryFiltersError, filterRepositories } from "../lib/repo-filter.ts"; import { runSetupTarget } from "../lib/setup-runner.ts"; +import { + ConfiguredWorkspaceRequiredError, + findConfiguredWorkspaceRoot, +} from "../lib/workspace-context.ts"; const ZERO = 0; const ONE = 1; @@ -40,8 +44,9 @@ export interface SetupCommandOptions { const executeSetup = async (options: SetupCommandOptions): Promise => { let workspaceRoot = ""; try { - workspaceRoot = await findWorkspaceRoot(); - } catch { + workspaceRoot = await findConfiguredWorkspaceRoot("setup"); + } catch (error) { + if (error instanceof ConfiguredWorkspaceRequiredError) throw error; throw new CliUsageError( 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', ); diff --git a/src/commands/status.ts b/src/commands/status.ts index a364358..fc4e108 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -18,13 +18,15 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { standaloneWorktrees } from "../lib/standalone.ts"; import { getFullGitStatus, getGitStatus } from "../lib/git.ts"; import { info, error as logError, spinner } from "../lib/logger.ts"; import { Command } from "commander"; import { filterRepositories } from "../lib/config/filter-repos.ts"; import { EmptyRepositoryFiltersError } from "../lib/repo-filter.ts"; -import { resolve } from "path"; -import { stat } from "fs/promises"; +import { basename, join, resolve } from "path"; +import { realpath, stat } from "fs/promises"; type DefaultBranchComparison = Awaited>; type Config = Awaited>; @@ -782,6 +784,67 @@ const statusCommand = async (options: StatusOptions): Promise => { process.exit(USAGE_EXIT_CODE); } + let workspaceContext; + try { + workspaceContext = await resolveWorkspaceContext(); + } catch (error) { + if (options.json) + writeJsonEnvelope( + createJsonErrorEnvelope("status", unknownErrorToJsonError(error, "CONFIG_LOAD_FAILED")), + ); + else logError(error instanceof Error ? error.message : String(error)); + process.exit(USAGE_EXIT_CODE); + } + if (workspaceContext.mode === "standalone") { + if (options.group) { + const message = "--group is not meaningful in standalone mode"; + if (options.json) + writeJsonEnvelope( + createJsonErrorEnvelope("status", { code: "STANDALONE_FILTER_UNSUPPORTED", message }), + ); + else logError(message); + process.exit(USAGE_EXIT_CODE); + } + const worktrees = await standaloneWorktrees(workspaceContext); + const statuses = await Promise.all( + worktrees.map((worktree) => + checkRepoStatus(worktree.branch ?? basename(worktree.path), worktree.path, { + verbose: options.verbose === true, + }), + ), + ); + const callerWorktree = await realpath(process.cwd()); + const currentStatus = statuses.find((status) => resolve(status.path) === callerWorktree); + const summary = summarizeStatuses(statuses); + if (options.json) + writeJsonEnvelope( + createJsonSuccessEnvelope( + "status", + { + callerWorktree, + currentBranch: currentStatus?.branch.localBranch ?? null, + mode: "standalone", + repositoryPath: workspaceContext.mainRoot, + summary, + workspaceRoot: workspaceContext.mainRoot, + worktrees: statuses, + worktreesBase: join(workspaceContext.mainRoot, ".worktrees"), + }, + collectStatusWarnings(statuses), + ), + ); + else { + console.log(`Workspace mode: standalone\nMain repository: ${workspaceContext.mainRoot}`); + if (callerWorktree !== workspaceContext.mainRoot) + console.log(`Caller worktree: ${callerWorktree}`); + let output = formatDefaultOutput(statuses); + if (options.verbose) output = formatVerboseOutput(statuses); + else if (options.short) output = formatShortOutput(statuses); + console.log(output); + } + process.exit(ZERO); + } + let workspaceRoot = ""; try { workspaceRoot = await findWorkspaceRoot(); @@ -889,9 +952,11 @@ const statusCommand = async (options: StatusOptions): Promise => { "status", { filters: filterResult.filters, + mode: "configured", repositories: statuses, summary, workspaceRoot, + worktreesBase: resolve(workspaceRoot, config.worktreesDir ?? "../.worktrees"), }, collectStatusWarnings(statuses), ), diff --git a/src/commands/switch.ts b/src/commands/switch.ts index 045e3e3..85793e8 100644 --- a/src/commands/switch.ts +++ b/src/commands/switch.ts @@ -14,6 +14,7 @@ import { Command } from "commander"; import { exec } from "../lib/git.ts"; import { launchSwitchTarget } from "../lib/switch-launcher.ts"; import { resolveDefaultWithPrecedence } from "../lib/default-resolution.ts"; +import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; type LoadWorkspaceRepositoriesResult = Awaited>; type Config = NonNullable; @@ -175,8 +176,24 @@ export async function executeSwitch( const augmentAllCandidates = deps.augmentAllScopeCandidates ?? augmentAllScopeCandidates; const launchCandidate = deps.launchSwitchTarget ?? launchSwitchTarget; - const workspaceRoot = await resolveWorkspaceRoot(); - const workspace = await resolveWorkspaceRepositories(workspaceRoot); + const context = await resolveWorkspaceContext(); + if (context.mode === "standalone" && (options.repos || options.all)) { + throw new SwitchCommandError( + "--repos and --all are not meaningful in standalone mode; switch already uses this repository's worktrees.", + SwitchCommandErrorCode.CONFLICTING_SWITCH_OPTIONS, + { mode: "standalone" }, + ); + } + if (context.mode === "standalone") { + info(`Workspace mode: standalone`); + info(`Main repository: ${context.mainRoot}`); + } + const workspaceRoot = + context.mode === "standalone" ? context.mainRoot : await resolveWorkspaceRoot(); + const workspace = + context.mode === "standalone" + ? { config: context.config, repositories: [context.repository] } + : await resolveWorkspaceRepositories(workspaceRoot); const scope = resolveSwitchScope(options); const targetRepositories = filterRepositoriesByScope( scope, diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 5e8150e..a1f8007 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,7 +6,7 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { createRollbackTracker, recordCreatedBranch } from "../lib/git/sync-rollback.ts"; -import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; +import { loadConfig } from "../lib/config.ts"; import { info, error as logError, spinner, success } from "../lib/logger.ts"; import { Command } from "commander"; import { alignRepositoryBranch } from "../lib/git/sync-branch.ts"; @@ -14,6 +14,7 @@ import { exec } from "../lib/git.ts"; import { filterRepositories } from "../lib/config/filter-repos.ts"; import { EmptyRepositoryFiltersError } from "../lib/repo-filter.ts"; import { resolve } from "path"; +import { findConfiguredWorkspaceRoot } from "../lib/workspace-context.ts"; type Config = Awaited>; type SyncBranchOutcome = Awaited>; @@ -60,7 +61,7 @@ export function createCommand(): Command { } export async function executeSync(options: SyncCommandOptions): Promise { - const workspaceRoot = await findWorkspaceRoot(); + const workspaceRoot = await findConfiguredWorkspaceRoot("sync"); const config = await loadConfig(workspaceRoot); const { repositories, emptyFilters, missing, unknownGroups, emptyIntersection } = diff --git a/src/contracts/cli-commands.ts b/src/contracts/cli-commands.ts index 063d51e..6c06d84 100644 --- a/src/contracts/cli-commands.ts +++ b/src/contracts/cli-commands.ts @@ -7,11 +7,23 @@ export type JsonPolicy = export type SurfacePolicy = | { expectation: "required" } | { expectation: "represented" | "excluded"; reason: string }; +export type StandalonePolicy = + | { support: "full" } + | { support: "conditional" | "configured-only" | "not-applicable"; reason: string }; +export interface ZeroConfigCommandPolicy { + compatibleOptions: string[]; + dryRun: { finalState: "unchanged"; supported: true }; + incompatibleOptions: string[]; + json: { singleEnvelope: true; supported: true; suppressesHumanStdout: true }; + option: "--zero-config"; +} export interface CommandSemanticMetadata { json: JsonPolicy; docs: SurfacePolicy; skills: SurfacePolicy; + standalone: StandalonePolicy; vscode: SurfacePolicy; + zeroConfig?: ZeroConfigCommandPolicy; } export type CommandSemantics = Record; @@ -19,37 +31,83 @@ const unsupported = (reason: string): JsonPolicy => ({ support: "unsupported", r const required = (): SurfacePolicy => ({ expectation: "required" }); const excluded = (reason: string): SurfacePolicy => ({ expectation: "excluded", reason }); const represented = (reason: string): SurfacePolicy => ({ expectation: "represented", reason }); +const standalone = (): StandalonePolicy => ({ support: "full" }); +const configuredOnly = (reason: string): StandalonePolicy => ({ + support: "configured-only", + reason, +}); +const notApplicable = (reason: string): StandalonePolicy => ({ support: "not-applicable", reason }); +const conditionalStandalone = (reason: string): StandalonePolicy => ({ + support: "conditional", + reason, +}); const standard = ( json: JsonPolicy = unsupported("This interactive command has no machine-readable output mode."), + standalonePolicy: StandalonePolicy = notApplicable( + "This command does not consume Arashi workspace context.", + ), ): CommandSemanticMetadata => ({ json, docs: required(), skills: required(), + standalone: standalonePolicy, vscode: required(), }); export const commandSemantics: CommandSemantics = { - add: standard({ support: "full" }), - clone: standard(), - create: standard({ - support: "conditional", - reason: "JSON is available only for non-interactive create operations.", - }), + add: standard( + { support: "full" }, + configuredOnly("Adding child repositories requires persisted configuration."), + ), + clone: standard( + undefined, + configuredOnly("Cloning configured child repositories requires persisted configuration."), + ), + create: standard( + { + support: "conditional", + reason: "JSON is available only for non-interactive create operations.", + }, + standalone(), + ), doctor: { - ...standard({ support: "full" }), + ...standard({ support: "full" }, standalone()), vscode: excluded("Diagnostics remain a terminal-focused maintenance workflow."), }, exec: { - ...standard({ support: "full" }), + ...standard( + { support: "full" }, + configuredOnly("Cross-repository execution requires persisted repository metadata."), + ), vscode: excluded( "Arbitrary cross-repository process execution is intentionally terminal-only.", ), }, handoff: { - ...standard({ support: "full" }), + ...standard({ support: "full" }, standalone()), vscode: excluded("Agent handoff generation is intentionally terminal-only."), }, - init: standard({ support: "full" }), + init: { + ...standard( + { support: "full" }, + conditionalStandalone( + "Only init --zero-config prepares standalone mode; ordinary init creates configured mode.", + ), + ), + zeroConfig: { + compatibleOptions: ["--dry-run", "--json", "--verbose"], + dryRun: { finalState: "unchanged", supported: true }, + incompatibleOptions: [ + "--force", + "--ignore-scope", + "--no-discover", + "--repos-dir", + "--worktrees-dir", + ], + json: { singleEnvelope: true, supported: true, suppressesHumanStdout: true }, + option: "--zero-config", + }, + }, install: { json: { support: "full" }, docs: excluded( @@ -58,24 +116,37 @@ export const commandSemantics: CommandSemantics = { skills: excluded( "The skill assumes Arashi is already installed; bootstrap guidance belongs in installation docs.", ), + standalone: notApplicable("Installation does not consume workspace context."), vscode: required(), }, list: { - ...standard({ support: "full" }), + ...standard({ support: "full" }, standalone()), vscode: represented("The worktree panel represents the CLI list workflow."), }, - move: standard({ support: "full" }), - prune: standard({ support: "full" }), - pull: standard({ support: "full" }), + move: standard({ support: "full" }, standalone()), + prune: standard({ support: "full" }, standalone()), + pull: standard( + { support: "full" }, + configuredOnly("Coordinated pull requires persisted repository metadata."), + ), push: { - ...standard({ support: "full" }), + ...standard( + { support: "full" }, + configuredOnly("Coordinated push requires persisted repository metadata."), + ), vscode: excluded("Push remains explicit terminal source-control behavior."), }, - remove: standard({ - support: "conditional", - reason: "JSON mode requires an explicit branch and is non-interactive.", - }), - setup: standard({ support: "full" }), + remove: standard( + { + support: "conditional", + reason: "JSON mode requires an explicit branch and is non-interactive.", + }, + standalone(), + ), + setup: standard( + { support: "full" }, + configuredOnly("Repository setup coordination requires persisted repository metadata."), + ), shell: standard(unsupported("Shell integration emits shell code rather than JSON.")), "shell init": { json: unsupported( @@ -83,19 +154,25 @@ export const commandSemantics: CommandSemantics = { ), docs: excluded("This subcommand is documented on the parent shell command page."), skills: represented("Shell initialization is covered as part of the shell workflow."), + standalone: notApplicable("Shell initialization does not consume workspace context."), vscode: excluded("Shell initialization configures terminals and is not an editor command."), }, "shell install": { json: unsupported("Shell installation mutates shell configuration and has no JSON mode."), docs: excluded("This subcommand is documented on the parent shell command page."), skills: represented("Shell installation is covered as part of the shell workflow."), + standalone: notApplicable("Shell installation does not consume workspace context."), vscode: excluded("Shell configuration installation is outside extension scope."), }, - status: standard({ support: "full" }), + status: standard({ support: "full" }, standalone()), switch: standard( unsupported("Switch launches a shell; --json only returns an unsupported-mode error."), + standalone(), + ), + sync: standard( + { support: "full" }, + configuredOnly("Repository synchronization requires persisted repository metadata."), ), - sync: standard({ support: "full" }), update: standard({ support: "conditional", reason: "JSON cannot be combined with interactive confirmation options.", @@ -115,6 +192,10 @@ export function validateCommandSemantics(paths: string[], metadata: CommandSeman const item = metadata[path]; if (item.json.support !== "full" && !item.json.reason.trim()) errors.push(`Command "${path}" ${item.json.support} JSON support requires a reason`); + if (item.standalone.support !== "full" && !item.standalone.reason.trim()) + errors.push( + `Command "${path}" ${item.standalone.support} standalone support requires a reason`, + ); for (const surface of ["docs", "skills", "vscode"] as const) { const policy = item[surface]; if (policy.expectation !== "required" && !policy.reason.trim()) @@ -192,5 +273,16 @@ export function generateCommandContract( } export function serializeCommandContract(contract: CliCommandContract): string { - return `${JSON.stringify(contract, null, 2)}\n`; + const formatted = JSON.stringify(contract, null, 2).replace( + /^(\s*"[^"]+": )\[\n((?:\s+"(?:[^"\\]|\\.)*",?\n)+)\s*\]/gm, + (block, prefix: string, entries: string) => { + const inline = `${prefix}[${entries + .trim() + .split("\n") + .map((entry) => entry.trim().replace(/,$/, "")) + .join(", ")}]`; + return inline.length <= 100 ? inline : block; + }, + ); + return `${formatted}\n`; } diff --git a/src/core/repository.ts b/src/core/repository.ts index 7ab4d2e..3a7af96 100644 --- a/src/core/repository.ts +++ b/src/core/repository.ts @@ -543,6 +543,28 @@ const classifyError = (path: string, error: unknown): DiscoveryError => { * @throws {RepositoryInvalidError} If default branch cannot be determined */ export const detectDefaultBranch = async (repositoryPath: string): Promise => { + // The common non-bare layout can be resolved without spawning Git. Discovery + // may inspect many repositories at once, so avoiding two subprocess rounds per + // repository keeps large workspaces responsive. Unusual Git layouts continue + // through the authoritative command-based fallback below. + const gitDirectory = join(repositoryPath, ".git"); + const remoteHeadPath = join(gitDirectory, "refs", "remotes", "origin", "HEAD"); + try { + const remoteHead = await runtime.file(remoteHeadPath).text(); + const match = remoteHead.trim().match(/^ref: refs\/remotes\/origin\/(.+)$/); + if (match?.[ONE]) return match[ONE].trim(); + } catch {} + + for (const branch of COMMON_BRANCHES) { + if (await runtime.file(join(gitDirectory, "refs", "heads", branch)).exists()) return branch; + } + + try { + const head = await runtime.file(join(gitDirectory, "HEAD")).text(); + const match = head.trim().match(/^ref: refs\/heads\/(.+)$/); + if (match?.[ONE]) return match[ONE].trim(); + } catch {} + try { const result = await execGit(["symbolic-ref", "refs/remotes/origin/HEAD"], repositoryPath); diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index b4b2ed2..0184da5 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -92,6 +92,7 @@ export interface HookExecutionOptions { scriptPath: string; context: HookContext; timeout?: number; + quiet?: boolean; } interface RunLifecycleHookOptions { @@ -313,7 +314,11 @@ const buildEnvironment = (context: HookContext): Record => { /** * Streams and prefixes output from a ReadableStream. */ -const streamOutput = async (stream: ReadableStream, prefix: string): Promise => { +const streamOutput = async ( + stream: ReadableStream, + prefix: string, + quiet = false, +): Promise => { const decoder = new TextDecoder(); const lines: string[] = []; let buffer = ""; @@ -324,13 +329,17 @@ const streamOutput = async (stream: ReadableStream, prefix: string): Promise> \"$(git rev-parse --git-path info/exclude)\"", + ], + }; + this.name = "StandaloneDestinationNotIgnoredError"; + } +} + +export class StandaloneHookError extends Error { + readonly code = "STANDALONE_HOOK_FAILED"; + readonly details: { hookName: string; scriptPath: string }; + + constructor(hookName: string, scriptPath: string) { + super(`Standalone ${hookName} hook failed: ${scriptPath}`); + this.details = { hookName, scriptPath }; + this.name = "StandaloneHookError"; + } +} + +export async function runStandaloneGlobalHooks( + context: StandaloneWorkspaceContext, + hookName: string, + branch: string, + worktreePath: string, + skipHooks: boolean, + quiet = false, +): Promise { + if (skipHooks) return; + const hooks = await resolveScopedLifecycleHooks({ + hookName, + targetRepositories: [context.repository], + workspaceRoot: context.mainRoot, + }); + for (const hook of hooks.filter((candidate) => candidate.scope.startsWith("global-"))) { + const result = await executeHook({ + context: { + hookName, + hookScope: hook.scope, + operationData: { + BRANCH_NAME: branch, + REPO_NAME: context.repository.name, + WORKSPACE_MODE: "standalone", + WORKTREE_PATH: worktreePath, + }, + repoPath: context.mainRoot, + sourceScriptPath: hook.sourceScriptPath, + targetRepoName: context.repository.name, + targetRepoPath: context.mainRoot, + }, + hookName, + quiet, + scriptPath: hook.scriptPath, + }); + if (!result.success) throw new StandaloneHookError(hookName, hook.scriptPath); + } +} + +export async function standaloneWorktrees(context: StandaloneWorkspaceContext) { + const result = await exec( + ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"], + context.mainRoot, + ); + const records: Array<{ branch: string | null; head: string; path: string }> = []; + let current: { branch: string | null; head: string; path: string } | null = null; + for (const line of result.stdout.split(/\r?\n/)) { + if (line.startsWith("worktree ")) { + current = { branch: null, head: "", path: line.slice(9) }; + records.push(current); + } else if (current && line.startsWith("HEAD ")) current.head = line.slice(5); + else if (current && line.startsWith("branch refs/heads/")) current.branch = line.slice(18); + } + return records; +} + +export interface EffectiveIgnoreEvidence { + ignored: boolean; + line?: number; + pattern: string | null; + source: string | null; +} + +export async function inspectStandaloneIgnore( + context: StandaloneWorkspaceContext, + destination: string, +): Promise { + try { + const result = await exec( + ["check-ignore", "--no-index", "--verbose", destination], + context.mainRoot, + ); + const metadata = result.stdout.trim().split("\t", 1)[0] ?? ""; + const match = metadata.match(/^(.*):(\d+):(.*)$/); + return { + ignored: true, + ...(match + ? { line: Number(match[2]), pattern: match[3], source: match[1] } + : { pattern: null, source: metadata || null }), + }; + } catch { + return { ignored: false, pattern: null, source: null }; + } +} + +const missingDestinationParents = async (base: string, destination: string): Promise => { + const branchParent = dirname(destination); + const branchRelative = relative(base, branchParent); + if (!branchRelative || branchRelative.startsWith("..")) return []; + const missing: string[] = []; + let current = base; + for (const segment of branchRelative.split(/[\\/]/)) { + current = resolve(current, segment); + try { + await access(current); + } catch { + missing.push(current); + } + } + return missing; +}; + +export async function createStandaloneWorktree( + context: StandaloneWorkspaceContext, + branch: string, + dryRun = false, + options: { quiet?: boolean; skipHooks?: boolean } = {}, +) { + await exec(["check-ref-format", "--branch", branch], context.mainRoot); + const destination = join(context.mainRoot, ".worktrees", ...branch.split("/")); + const effectiveIgnore = await inspectStandaloneIgnore(context, destination); + if (!effectiveIgnore.ignored) throw new StandaloneDestinationNotIgnoredError(destination); + let existing = true; + try { + await exec(["show-ref", "--verify", `refs/heads/${branch}`], context.mainRoot); + } catch { + existing = false; + } + let branchSource: string | null = existing ? branch : null; + let reusedRemoteBranch = false; + if (!existing) { + const remotes = await exec( + ["for-each-ref", "--format=%(refname:short)", `refs/remotes/*/${branch}`], + context.mainRoot, + ); + const remoteBranches = remotes.stdout.split(/\r?\n/).filter(Boolean); + branchSource = + remoteBranches.find((candidate) => candidate === `origin/${branch}`) ?? + remoteBranches[0] ?? + null; + reusedRemoteBranch = branchSource !== null; + } + const ownedParents = await missingDestinationParents( + join(context.mainRoot, ".worktrees"), + destination, + ); + if (!dryRun) { + await runStandaloneGlobalHooks( + context, + "pre-create", + branch, + destination, + options.skipHooks === true, + options.quiet === true, + ); + try { + await exec( + existing + ? ["worktree", "add", destination, branch] + : branchSource + ? ["worktree", "add", "-b", branch, destination, branchSource] + : ["worktree", "add", "-b", branch, destination], + context.mainRoot, + ); + await runStandaloneGlobalHooks( + context, + "post-create", + branch, + destination, + options.skipHooks === true, + options.quiet === true, + ); + } catch (error) { + try { + await exec(["worktree", "remove", "--force", destination], context.mainRoot); + } catch { + // The worktree may not have been created. + } + if (!existing) { + try { + await exec(["branch", "-D", branch], context.mainRoot); + } catch { + // The branch may not have been created. + } + } + for (const parent of ownedParents.toReversed()) { + try { + await rmdir(parent); + } catch { + // Preserve non-empty, pre-existing, or surviving-worktree parent paths. + } + } + throw error; + } + } + return { + branchName: branch, + branchSource, + dryRun, + mode: "standalone" as const, + repositoryPath: context.mainRoot, + reusedRemoteBranch, + workspaceRoot: context.mainRoot, + worktreePath: destination, + effectiveIgnore, + }; +} diff --git a/src/lib/workspace-context.ts b/src/lib/workspace-context.ts new file mode 100644 index 0000000..458625a --- /dev/null +++ b/src/lib/workspace-context.ts @@ -0,0 +1,246 @@ +import { basename, isAbsolute, resolve } from "path"; +import { stat } from "fs/promises"; +import { + ConfigNotFoundError, + ConfigParseError, + CURRENT_CONFIG_VERSION, + findWorkspaceRoot, + loadConfig, + type Config, + type WorkspaceRepository, +} from "./config.ts"; +import { exec } from "./git.ts"; +import { createJsonErrorEnvelope, writeJsonEnvelope } from "./json-output.ts"; +import { error as logError } from "./logger.ts"; + +export { ConfigParseError } from "./config.ts"; + +interface WorkspaceContextBase { + invocationPath: string; + workspaceRoot: string; +} + +export interface ConfiguredWorkspaceContext extends WorkspaceContextBase { + config: Config; + mode: "configured"; +} + +export interface StandaloneWorkspaceContext extends WorkspaceContextBase { + config: Config; + mainRoot: string; + mode: "standalone"; + repository: WorkspaceRepository; +} + +export interface UnavailableWorkspaceContext { + invocationPath: string; + mode: "unavailable"; + reason: "bare-repository" | "not-git-repository" | "missing-worktrees-directory"; +} + +export type WorkspaceContext = + | ConfiguredWorkspaceContext + | StandaloneWorkspaceContext + | UnavailableWorkspaceContext; + +const standaloneConfig = (): Config => ({ + repos: {}, + reposDir: "./repos", + version: CURRENT_CONFIG_VERSION, + worktreesDir: ".worktrees", +}); + +async function discoverConfigured(startPath: string): Promise { + try { + const workspaceRoot = await findWorkspaceRoot(startPath); + return { + config: await loadConfig(workspaceRoot), + invocationPath: startPath, + mode: "configured", + workspaceRoot, + }; + } catch (error) { + if (error instanceof ConfigNotFoundError) return null; + throw error; + } +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** Resolve the primary, non-bare worktree recorded by Git for an invocation repository. */ +export async function resolveGitMainWorktree(invocationPath: string): Promise { + const absoluteInvocationPath = resolve(invocationPath); + try { + const bare = await exec(["rev-parse", "--is-bare-repository"], absoluteInvocationPath); + if (bare.stdout.trim() === "true") return null; + + // Resolve the common directory as an absolute path as part of validating that + // this is a shared non-bare repository. This also handles relative output and + // repositories created with --separate-git-dir. + const common = await exec(["rev-parse", "--git-common-dir"], absoluteInvocationPath); + const commonDir = common.stdout.trim(); + const absoluteCommonDir = isAbsolute(commonDir) + ? commonDir + : resolve(absoluteInvocationPath, commonDir); + await stat(absoluteCommonDir); + + const gitDirectory = await exec(["rev-parse", "--git-dir"], absoluteInvocationPath); + const rawGitDirectory = gitDirectory.stdout.trim(); + const absoluteGitDirectory = isAbsolute(rawGitDirectory) + ? rawGitDirectory + : resolve(absoluteInvocationPath, rawGitDirectory); + if (resolve(absoluteGitDirectory) === resolve(absoluteCommonDir)) { + const topLevel = await exec(["rev-parse", "--show-toplevel"], absoluteInvocationPath); + return resolve(topLevel.stdout.trim()); + } + + const listing = await exec( + ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"], + absoluteInvocationPath, + ); + const firstWorktree = listing.stdout + .split(/\r?\n/) + .find((line) => line.startsWith("worktree ")) + ?.slice("worktree ".length); + if (!firstWorktree) return null; + const listedRoot = resolve(firstWorktree); + if (listedRoot === resolve(absoluteCommonDir)) { + const topLevel = await exec(["rev-parse", "--show-toplevel"], absoluteInvocationPath); + return resolve(topLevel.stdout.trim()); + } + return listedRoot; + } catch { + return null; + } +} + +/** + * Resolve configured, implicit standalone, or unavailable workspace state. + * Existing configuration is always loaded (and any error propagated) before + * the zero-config convention is considered. + */ +export async function resolveWorkspaceContext( + invocationPath: string = process.cwd(), +): Promise { + const absoluteInvocationPath = resolve(invocationPath); + const invocationConfigured = await discoverConfigured(absoluteInvocationPath); + if (invocationConfigured) return invocationConfigured; + + const mainRoot = await resolveGitMainWorktree(absoluteInvocationPath); + if (!mainRoot) { + let reason: UnavailableWorkspaceContext["reason"] = "not-git-repository"; + try { + const bare = await exec(["rev-parse", "--is-bare-repository"], absoluteInvocationPath); + if (bare.stdout.trim() === "true") reason = "bare-repository"; + } catch { + // The default reason is correct when Git cannot inspect the invocation. + } + return { invocationPath: absoluteInvocationPath, mode: "unavailable", reason }; + } + + const mainConfigured = await discoverConfigured(mainRoot); + if (mainConfigured) return { ...mainConfigured, invocationPath: absoluteInvocationPath }; + + if (!(await isDirectory(resolve(mainRoot, ".worktrees")))) { + return { + invocationPath: absoluteInvocationPath, + mode: "unavailable", + reason: "missing-worktrees-directory", + }; + } + + return { + config: standaloneConfig(), + invocationPath: absoluteInvocationPath, + mainRoot, + mode: "standalone", + repository: { name: basename(mainRoot), path: mainRoot }, + workspaceRoot: mainRoot, + }; +} + +export class ConfiguredWorkspaceRequiredError extends Error { + readonly code = "CONFIGURED_WORKSPACE_REQUIRED"; + readonly details: { command: string; mode: "standalone" }; + + constructor(commandName: string) { + super( + `arashi ${commandName} requires a configured workspace. Run "arashi init" (without --zero-config) to enable repository coordination.`, + ); + this.details = { command: commandName, mode: "standalone" }; + this.name = "ConfiguredWorkspaceRequiredError"; + } +} + +export async function assertConfiguredWorkspaceForCommand( + commandName: string, + invocationPath: string = process.cwd(), +): Promise { + const context = await resolveWorkspaceContext(invocationPath); + requireConfiguredWorkspace(context, commandName); + return context; +} + +/** Preserve configured discovery cost while enriching only its failure path for standalone mode. */ +export async function findConfiguredWorkspaceRoot( + commandName: string, + invocationPath: string = process.cwd(), +): Promise { + try { + return await findWorkspaceRoot(invocationPath); + } catch (error) { + const context = await resolveWorkspaceContext(invocationPath); + if (context.mode === "standalone") throw new ConfiguredWorkspaceRequiredError(commandName); + throw error; + } +} + +export async function throwIfStandaloneWorkspace( + commandName: string, + invocationPath: string = process.cwd(), +): Promise { + const context = await resolveWorkspaceContext(invocationPath); + if (context.mode === "standalone") throw new ConfiguredWorkspaceRequiredError(commandName); +} + +export function requireAvailableWorkspace( + context: WorkspaceContext, +): asserts context is Exclude { + if (context.mode === "unavailable") { + throw new ConfigNotFoundError(`${context.invocationPath}/.arashi/config.json`); + } +} + +export function requireConfiguredWorkspace( + context: WorkspaceContext, + commandName: string, +): asserts context is ConfiguredWorkspaceContext { + requireAvailableWorkspace(context); + if (context.mode !== "configured") throw new ConfiguredWorkspaceRequiredError(commandName); +} + +/** Reject a coordination-only command when the implicit standalone trigger is active. */ +export async function rejectStandaloneForConfiguredCommand( + commandName: string, + json = false, +): Promise { + const context = await resolveWorkspaceContext(); + if (context.mode !== "standalone") return false; + const configuredError = new ConfiguredWorkspaceRequiredError(commandName); + if (json) { + writeJsonEnvelope( + createJsonErrorEnvelope(commandName, { + code: configuredError.code, + details: { mode: "standalone", workspaceRoot: context.mainRoot }, + message: configuredError.message, + }), + ); + } else logError(configuredError.message); + return true; +} diff --git a/src/lib/zero-config-bootstrap.ts b/src/lib/zero-config-bootstrap.ts new file mode 100644 index 0000000..328f229 --- /dev/null +++ b/src/lib/zero-config-bootstrap.ts @@ -0,0 +1,256 @@ +import { access, lstat, mkdir, readFile, rmdir, unlink, writeFile } from "fs/promises"; +import { dirname, isAbsolute, join, resolve } from "path"; +import { configExists } from "./config.ts"; +import { exec } from "./git.ts"; +import { resolveGitMainWorktree } from "./workspace-context.ts"; + +const RULE = ".worktrees/"; +const PROBE = ".worktrees/.arashi-ignore-probe"; + +export interface ZeroConfigBootstrapResult { + attempted: { localExclude: boolean; worktreesDirectory: boolean }; + changed: boolean; + dryRun: boolean; + finalState: { localExcludeChanged: boolean; worktreesDirectoryChanged: boolean }; + localExclude: { + changed: boolean; + path: string; + planned: boolean; + rule: typeof RULE; + source?: string; + }; + mode: "standalone"; + restored: boolean; + workspaceRoot: string; + worktreesDirectory: { changed: boolean; path: string; planned: boolean }; +} + +export class ZeroConfigBootstrapError extends Error { + readonly code = "ZERO_CONFIG_BOOTSTRAP_FAILED"; + readonly details: ZeroConfigBootstrapErrorDetails; + + constructor(message: string, details: Partial = {}) { + super(message); + this.details = { + attempted: details.attempted ?? { localExclude: false, worktreesDirectory: false }, + finalState: details.finalState ?? { + localExcludeChanged: false, + worktreesDirectoryChanged: false, + }, + mode: "standalone", + originalFailure: details.originalFailure ?? message, + restorationWarnings: details.restorationWarnings ?? [], + restored: details.restored ?? { localExclude: false, worktreesDirectory: false }, + ...(details.workspaceRoot ? { workspaceRoot: details.workspaceRoot } : {}), + }; + this.name = "ZeroConfigBootstrapError"; + } +} + +export interface ZeroConfigBootstrapErrorDetails { + attempted: { localExclude: boolean; worktreesDirectory: boolean }; + finalState: { localExcludeChanged: boolean; worktreesDirectoryChanged: boolean }; + mode: "standalone"; + originalFailure: string; + restorationWarnings: string[]; + restored: { localExclude: boolean; worktreesDirectory: boolean }; + workspaceRoot?: string; +} + +export interface ZeroConfigBootstrapDependencies { + effectiveIgnore: typeof effectiveIgnore; + lstat: typeof lstat; + mkdir: typeof mkdir; + readFile: typeof readFile; + rmdir: typeof rmdir; + unlink: typeof unlink; + writeFile: typeof writeFile; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function effectiveIgnore( + root: string, + path: string, +): Promise<{ ignored: boolean; source?: string }> { + try { + const result = await exec(["check-ignore", "--no-index", "-v", path], root); + const line = result.stdout.trim(); + return { ignored: line.length > 0, ...(line ? { source: line.split("\t", 1)[0] } : {}) }; + } catch { + return { ignored: false }; + } +} + +async function localExcludePath(root: string): Promise { + const result = await exec(["rev-parse", "--git-path", "info/exclude"], root); + const path = result.stdout.trim(); + return isAbsolute(path) ? path : resolve(root, path); +} + +function appendRule(original: Buffer): Buffer { + const text = original.toString("utf8"); + const newline = text.includes("\r\n") ? "\r\n" : "\n"; + const boundary = text.length > 0 && !text.endsWith("\n") ? newline : ""; + return Buffer.from(`${text}${boundary}${RULE}${newline}`); +} + +export async function bootstrapZeroConfig( + invocationPath: string, + options: { + dependencies?: Partial; + dryRun?: boolean; + } = {}, +): Promise { + const dependencies: ZeroConfigBootstrapDependencies = { + effectiveIgnore, + lstat, + mkdir, + readFile, + rmdir, + unlink, + writeFile, + ...options.dependencies, + }; + const mainRoot = await resolveGitMainWorktree(invocationPath); + if (!mainRoot) { + throw new ZeroConfigBootstrapError( + "Zero-config initialization requires an existing non-bare Git repository.", + ); + } + if (await configExists(mainRoot)) { + throw new ZeroConfigBootstrapError( + "A configured Arashi workspace already exists; zero-config standalone mode cannot replace it.", + ); + } + + const worktreesPath = join(mainRoot, ".worktrees"); + const excludePath = await localExcludePath(mainRoot); + const directoryExists = await exists(worktreesPath); + const initialIgnore = await dependencies.effectiveIgnore(mainRoot, PROBE); + const needsRule = !initialIgnore.ignored; + const result: ZeroConfigBootstrapResult = { + attempted: { localExclude: false, worktreesDirectory: false }, + changed: !options.dryRun && (!directoryExists || needsRule), + dryRun: options.dryRun === true, + finalState: { + localExcludeChanged: !options.dryRun && needsRule, + worktreesDirectoryChanged: !options.dryRun && !directoryExists, + }, + localExclude: { + changed: !options.dryRun && needsRule, + path: excludePath, + planned: needsRule, + rule: RULE, + ...(initialIgnore.source ? { source: initialIgnore.source } : {}), + }, + mode: "standalone", + restored: false, + workspaceRoot: mainRoot, + worktreesDirectory: { + changed: !options.dryRun && !directoryExists, + path: worktreesPath, + planned: !directoryExists, + }, + }; + if (options.dryRun) { + return result; + } + + let originalExclude: Buffer | null = null; + let excludeExisted = false; + let directoryCreated = false; + let excludeWritten = false; + try { + if (!directoryExists) { + result.attempted.worktreesDirectory = true; + await dependencies.mkdir(worktreesPath); + directoryCreated = true; + } + if (needsRule) { + result.attempted.localExclude = true; + try { + const metadata = await dependencies.lstat(excludePath); + if (metadata.isSymbolicLink()) { + throw new ZeroConfigBootstrapError( + `Refusing to modify symlinked repository-local exclude file: ${excludePath}`, + ); + } + originalExclude = await dependencies.readFile(excludePath); + excludeExisted = true; + } catch (error) { + if (error instanceof ZeroConfigBootstrapError) { + throw error; + } + originalExclude = Buffer.alloc(0); + } + await dependencies.mkdir(dirname(excludePath), { recursive: true }); + await dependencies.writeFile(excludePath, appendRule(originalExclude)); + excludeWritten = true; + const verified = await dependencies.effectiveIgnore(mainRoot, PROBE); + if (!verified.ignored) { + throw new ZeroConfigBootstrapError( + "The local .worktrees/ exclude is defeated by a higher-precedence Git ignore rule; restore ignore safety manually.", + ); + } + result.localExclude.source = verified.source; + } + return result; + } catch (error) { + const restorationFailures: string[] = []; + const restored = { localExclude: false, worktreesDirectory: false }; + if (excludeWritten && originalExclude !== null) { + try { + if (excludeExisted) { + await dependencies.writeFile(excludePath, originalExclude); + } else { + await dependencies.unlink(excludePath); + } + result.restored = true; + restored.localExclude = true; + } catch (restoreError) { + restorationFailures.push(`exclude restoration failed: ${(restoreError as Error).message}`); + } + } + if (directoryCreated) { + try { + await dependencies.rmdir(worktreesPath); + restored.worktreesDirectory = true; + } catch (restoreError) { + restorationFailures.push( + `directory restoration failed: ${(restoreError as Error).message}`, + ); + } + } + let localExcludeChanged = false; + try { + if (!result.attempted.localExclude) { + localExcludeChanged = false; + } else if (excludeExisted && originalExclude) { + localExcludeChanged = !(await dependencies.readFile(excludePath)).equals(originalExclude); + } else { + localExcludeChanged = await exists(excludePath); + } + } catch { + localExcludeChanged = excludeExisted; + } + const worktreesDirectoryChanged = !directoryExists && (await exists(worktreesPath)); + const originalFailure = error instanceof Error ? error.message : String(error); + const suffix = restorationFailures.length > 0 ? ` (${restorationFailures.join("; ")})` : ""; + throw new ZeroConfigBootstrapError(`${originalFailure}${suffix}`, { + attempted: result.attempted, + finalState: { localExcludeChanged, worktreesDirectoryChanged }, + originalFailure, + restorationWarnings: restorationFailures, + restored, + workspaceRoot: mainRoot, + }); + } +} diff --git a/tests/integration/init.zero-config.test.ts b/tests/integration/init.zero-config.test.ts new file mode 100644 index 0000000..6bb4fad --- /dev/null +++ b/tests/integration/init.zero-config.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { spawn } from "../helpers/node-runtime.ts"; +import { + ZeroConfigBootstrapError, + bootstrapZeroConfig, +} from "../../src/lib/zero-config-bootstrap.ts"; + +const roots: string[] = []; + +async function run(cwd: string, args: string[]) { + const child = spawn(args, { cwd, stderr: "pipe", stdout: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stderr, stdout }; +} + +async function repository(): Promise { + const root = await mkdtemp(join(tmpdir(), "arashi-zero-init-")); + roots.push(root); + expect((await run(root, ["git", "init"])).exitCode).toBe(0); + return root; +} + +async function arashi(root: string, args: string[]) { + return await run(root, [ + process.execPath, + join(import.meta.dirname, "../../src/index.ts"), + ...args, + ]); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("init --zero-config", () => { + test("creates only the convention and literal repository-local exclude rule", async () => { + const root = await repository(); + const result = await arashi(root, ["init", "--zero-config"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("standalone"); + expect(await readFile(join(root, ".git", "info", "exclude"), "utf8")).toContain( + "\n.worktrees/\n", + ); + await expect(access(join(root, ".worktrees"))).resolves.toBeUndefined(); + await expect(access(join(root, ".arashi"))).rejects.toThrow(); + await expect(access(join(root, ".gitignore"))).rejects.toThrow(); + }); + + test("dry-run JSON is isolated and leaves both actions unapplied", async () => { + const root = await repository(); + const before = await readFile(join(root, ".git", "info", "exclude")); + const result = await arashi(root, [ + "init", + "--zero-config", + "--dry-run", + "--json", + "--verbose", + ]); + const envelope = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(envelope).toMatchObject({ + data: { + dryRun: true, + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + attempted: { localExclude: false, worktreesDirectory: false }, + mode: "standalone", + worktreesDirectory: { changed: false, planned: true }, + localExclude: { changed: false, planned: true, rule: ".worktrees/" }, + }, + ok: true, + }); + expect(await readFile(join(root, ".git", "info", "exclude"))).toEqual(before); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test("reports structured rollback evidence after injected verification failure", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const before = await readFile(exclude); + + let bootstrapError: ZeroConfigBootstrapError | undefined; + try { + await bootstrapZeroConfig(root, { + dependencies: { + effectiveIgnore: async () => ({ ignored: false }), + }, + }); + } catch (error) { + bootstrapError = error as ZeroConfigBootstrapError; + } + + expect(bootstrapError).toBeInstanceOf(ZeroConfigBootstrapError); + expect(bootstrapError?.details).toMatchObject({ + attempted: { localExclude: true, worktreesDirectory: true }, + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + originalFailure: expect.stringContaining("higher-precedence"), + restorationWarnings: [], + restored: { localExclude: true, worktreesDirectory: true }, + }); + expect(await readFile(exclude)).toEqual(before); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test("reports unchanged state after an injected directory creation failure", async () => { + const root = await repository(); + let bootstrapError: ZeroConfigBootstrapError | undefined; + + try { + await bootstrapZeroConfig(root, { + dependencies: { + mkdir: async (path, options) => { + if (String(path).endsWith(".worktrees")) { + throw new Error("injected directory failure"); + } + await mkdir(path, options); + return undefined; + }, + }, + }); + } catch (error) { + bootstrapError = error as ZeroConfigBootstrapError; + } + + expect(bootstrapError?.details).toMatchObject({ + attempted: { localExclude: false, worktreesDirectory: true }, + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + originalFailure: expect.stringContaining("injected directory failure"), + }); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test("restores the directory after an injected exclude write failure", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const before = await readFile(exclude); + let bootstrapError: ZeroConfigBootstrapError | undefined; + + try { + await bootstrapZeroConfig(root, { + dependencies: { + writeFile: async () => { + throw new Error("injected exclude write failure"); + }, + }, + }); + } catch (error) { + bootstrapError = error as ZeroConfigBootstrapError; + } + + expect(bootstrapError?.details).toMatchObject({ + attempted: { localExclude: true, worktreesDirectory: true }, + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + originalFailure: expect.stringContaining("injected exclude write failure"), + restored: { localExclude: false, worktreesDirectory: true }, + }); + expect(await readFile(exclude)).toEqual(before); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test("keeps original failure and reports an injected restoration failure separately", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const before = await readFile(exclude); + let writes = 0; + + let bootstrapError: ZeroConfigBootstrapError | undefined; + try { + await bootstrapZeroConfig(root, { + dependencies: { + effectiveIgnore: async () => ({ ignored: false }), + writeFile: async (path, contents) => { + writes += 1; + if (writes === 2) { + throw new Error("injected restoration failure"); + } + await writeFile(path, contents); + }, + }, + }); + } catch (error) { + bootstrapError = error as ZeroConfigBootstrapError; + } + + expect(bootstrapError?.details).toMatchObject({ + finalState: { localExcludeChanged: true, worktreesDirectoryChanged: false }, + originalFailure: expect.stringContaining("higher-precedence"), + restorationWarnings: [expect.stringContaining("injected restoration failure")], + restored: { localExclude: false, worktreesDirectory: true }, + }); + expect(await readFile(exclude)).not.toEqual(before); + }); + + test("preserves no-final-newline content and is idempotent", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + await writeFile(exclude, "existing"); + + expect((await arashi(root, ["init", "--zero-config"])).exitCode).toBe(0); + const once = await readFile(exclude, "utf8"); + expect(once).toBe("existing\n.worktrees/\n"); + const repeated = await arashi(root, ["init", "--zero-config", "--json"]); + expect(JSON.parse(repeated.stdout).data).toMatchObject({ changed: false, mode: "standalone" }); + expect(await readFile(exclude, "utf8")).toBe(once); + }); + + test("honors an existing tracked effective rule without changing local exclude", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const before = await readFile(exclude); + await writeFile(join(root, ".gitignore"), ".worktrees/\n"); + + const result = await arashi(root, ["init", "--zero-config", "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data.localExclude).toMatchObject({ + changed: false, + planned: false, + }); + expect(await readFile(exclude)).toEqual(before); + }); + + test("preserves CRLF local exclude formatting", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + await writeFile(exclude, "first\r\nsecond"); + + expect((await arashi(root, ["init", "--zero-config"])).exitCode).toBe(0); + + expect(await readFile(exclude, "utf8")).toBe("first\r\nsecond\r\n.worktrees/\r\n"); + }); + + test("refuses a symlinked local exclude and rolls back the new directory", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const target = join(root, "outside-exclude"); + await writeFile(target, "unchanged\n"); + await unlink(exclude); + await symlink(target, exclude); + + const result = await arashi(root, ["init", "--zero-config"]); + + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("symlinked"); + expect(await readFile(target, "utf8")).toBe("unchanged\n"); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test.each([ + "--repos-dir=x", + "--worktrees-dir=x", + "--ignore-scope=none", + "--force", + "--no-discover", + ])("rejects incompatible option %s before mutation", async (option) => { + const root = await repository(); + const result = await arashi(root, ["init", "--zero-config", option]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("incompatible"); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + + test("rejects existing configured state before mutation", async () => { + const root = await repository(); + await mkdir(join(root, ".arashi")); + await writeFile(join(root, ".arashi", "config.json"), "{"); + const result = await arashi(root, ["init", "--zero-config"]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("configured"); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); +}); diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts new file mode 100644 index 0000000..a790bf0 --- /dev/null +++ b/tests/integration/standalone-lifecycle.test.ts @@ -0,0 +1,645 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "fs/promises"; +import { basename, join } from "path"; +import { tmpdir } from "os"; +import { spawn } from "../helpers/node-runtime.ts"; + +const roots: string[] = []; +async function run(cwd: string, args: string[], env?: Record) { + const child = spawn(args, { + cwd, + env: env ? { ...process.env, ...env } : undefined, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} +async function arashi(cwd: string, args: string[], env?: Record) { + return run( + cwd, + [process.execPath, join(import.meta.dirname, "../../src/index.ts"), ...args], + env, + ); +} +async function repository() { + const root = await mkdtemp(join(tmpdir(), "arashi-standalone-")); + roots.push(root); + await run(root, ["git", "init"]); + await run(root, ["git", "config", "user.email", "test@example.com"]); + await run(root, ["git", "config", "user.name", "Test User"]); + await writeFile(join(root, "README.md"), "test\n"); + await run(root, ["git", "add", "."]); + await run(root, ["git", "commit", "-m", "initial"]); + return root; +} +afterEach(async () => + Promise.all(roots.splice(0).map((p) => rm(p, { recursive: true, force: true }))), +); + +describe("standalone lifecycle", () => { + test("blocks exact unignored create before branch or directory mutation", async () => { + const root = await repository(); + await (await import("fs/promises")).mkdir(join(root, ".worktrees")); + const canonicalRoot = await realpath(root); + const result = await arashi(root, ["create", "feat/blocked", "--dry-run", "--json"]); + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout).error).toMatchObject({ + code: "STANDALONE_DESTINATION_NOT_IGNORED", + details: { + effectiveIgnore: { ignored: false, source: null }, + mutation: { branch: false, config: false, ignore: false, worktree: false }, + repairCommands: expect.arrayContaining([ + "arashi init --zero-config", + expect.stringContaining("info/exclude"), + ]), + }, + }); + expect(JSON.parse(result.stdout).error.details.destination).toBe( + join(canonicalRoot, ".worktrees", "feat", "blocked"), + ); + expect((await run(root, ["git", "branch", "--list", "feat/blocked"])).stdout).toBe(""); + await expect(access(join(root, ".worktrees", "feat"))).rejects.toThrow(); + }); + + test("create JSON reports the effective ignore source", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + + const result = await arashi(root, ["create", "ignore-evidence", "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + effectiveIgnore: { + ignored: true, + pattern: ".worktrees/", + source: expect.stringContaining("info/exclude"), + }, + mode: "standalone", + }); + }); + + test("create reuses a remote-only branch", async () => { + const root = await repository(); + const remote = await mkdtemp(join(tmpdir(), "arashi-remote-")); + roots.push(remote); + await run(remote, ["git", "init", "--bare"]); + await run(root, ["git", "remote", "add", "origin", remote]); + await run(root, ["git", "branch", "remote-only"]); + await run(root, ["git", "push", "origin", "remote-only"]); + await run(root, ["git", "branch", "-D", "remote-only"]); + await arashi(root, ["init", "--zero-config"]); + + const result = await arashi(root, ["create", "remote-only", "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + branchSource: "origin/remote-only", + reusedRemoteBranch: true, + }); + expect((await run(root, ["git", "rev-parse", "remote-only"])).stdout.trim()).toBe( + (await run(root, ["git", "rev-parse", "origin/remote-only"])).stdout.trim(), + ); + }); + + test("post-create rollback removes only invocation-owned slash parents", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await mkdir(join(root, ".worktrees", "preserved")); + const home = await mkdtemp(join(tmpdir(), "arashi-home-")); + roots.push(home); + const hooks = join(home, ".arashi", "hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(hooks, "post-create.sh"), "#!/bin/sh\nexit 31\n"); + await chmod(join(hooks, "post-create.sh"), 0o755); + + const owned = await arashi(root, ["create", "owned/nested/failure", "--json"], { + HOME: home, + }); + const preserved = await arashi(root, ["create", "preserved/failure", "--json"], { + HOME: home, + }); + + expect(owned.exitCode).not.toBe(0); + expect(preserved.exitCode).not.toBe(0); + expect(JSON.parse(owned.stdout).error.code).toBe("STANDALONE_HOOK_FAILED"); + await expect(access(join(root, ".worktrees", "owned"))).rejects.toThrow(); + await expect(access(join(root, ".worktrees", "preserved"))).resolves.toBeUndefined(); + expect((await run(root, ["git", "branch", "--list", "owned/nested/failure"])).stdout).toBe(""); + }); + + test("bootstraps, creates slash path, lists/statuses from linked worktree, and removes", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + expect((await arashi(root, ["init", "--zero-config"])).exitCode).toBe(0); + const created = await arashi(root, ["create", "feat/example", "--json"]); + expect(created.exitCode).toBe(0); + expect(JSON.parse(created.stdout).data).toMatchObject({ + mode: "standalone", + worktreePath: join(canonicalRoot, ".worktrees", "feat", "example"), + }); + const linked = join(root, ".worktrees", "feat", "example"); + expect(JSON.parse((await arashi(linked, ["list", "--json"])).stdout).data).toMatchObject({ + mode: "standalone", + workspaceRoot: canonicalRoot, + }); + expect(JSON.parse((await arashi(linked, ["status", "--json"])).stdout).data.mode).toBe( + "standalone", + ); + const removed = await arashi(root, ["remove", "feat/example", "--json"]); + expect(removed.exitCode).toBe(0); + await expect(access(linked)).rejects.toThrow(); + await expect(access(join(root, ".arashi"))).rejects.toThrow(); + }); + + test.each([ + ["create", ["branch", "--only", "repo"]], + ["create", ["branch", "--group", "group"]], + ["create", ["branch", "--interactive"]], + ["switch", ["--all"]], + ])("rejects meaningless %s standalone selection", async (command, args) => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const result = await arashi(root, [command, ...args]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("standalone"); + }); + + test.each([ + ["prune", ["--dry-run", "--json"]], + ["doctor", ["--json"]], + ["handoff", ["--json"]], + ])("reports standalone metadata for %s", async (command, args) => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const result = await arashi(root, [command, ...args]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data.mode).toBe("standalone"); + }); + + test.each(["status", "prune", "handoff", "doctor"])( + "%s encloses invalid config in one command-specific JSON error", + async (command) => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, ".arashi")); + await writeFile(join(root, ".arashi", "config.json"), "{"); + + const result = await arashi(root, [command, "--json"]); + + expect(result.exitCode).not.toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope).toMatchObject({ command, ok: false }); + expect(envelope.error.message).toContain("parse"); + expect(envelope.error.details?.mode).not.toBe("standalone"); + }, + ); + + test.each(["status", "prune", "handoff", "doctor"])( + "%s reports configured metadata when valid config and .worktrees coexist", + async (command) => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, ".arashi")); + await writeFile( + join(root, ".arashi", "config.json"), + JSON.stringify({ + version: "1.0.0", + reposDir: "./configured-repos", + worktreesDir: "./configured-worktrees", + repos: {}, + }), + ); + + const result = await arashi(root, [ + command, + ...(command === "prune" ? ["--dry-run"] : []), + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + mode: "configured", + workspaceRoot: canonicalRoot, + worktreesBase: join(canonicalRoot, "configured-worktrees"), + }); + }, + ); + + test("handoff preserves standalone status, caller context, evidence, and summaries", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "handoff-context", "--json"]); + const linked = join(root, ".worktrees", "handoff-context"); + const canonicalLinked = await realpath(linked); + await writeFile(join(linked, "README.md"), "dirty handoff\n"); + + const result = await arashi(linked, [ + "handoff", + "--json", + "--link", + "issue-212", + "--validation", + "pnpm test — passed", + "--todo", + "finish docs", + "--risk", + "remote drift", + "--next-command", + "arashi status --verbose", + ]); + + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout).data; + expect(data).toMatchObject({ + callerWorktree: canonicalLinked, + context: { + links: ["issue-212"], + nextCommands: ["arashi status --verbose"], + risks: ["remote drift"], + todos: ["finish docs"], + validations: ["pnpm test — passed"], + }, + effectiveOptions: { format: "json" }, + mode: "standalone", + repositoryPath: canonicalRoot, + summary: { dirtyCount: 1, total: 2, touchedCount: 1 }, + workspace: { branch: "handoff-context", path: canonicalRoot }, + worktreesBase: join(canonicalRoot, ".worktrees"), + }); + expect(data.repositories).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + branch: expect.objectContaining({ localBranch: "handoff-context" }), + path: canonicalLinked, + state: "dirty", + }), + ]), + ); + + const markdown = await arashi(linked, [ + "handoff", + "--link", + "issue-212", + "--validation", + "pnpm test — passed", + "--todo", + "finish docs", + "--risk", + "remote drift", + "--next-command", + "arashi status --verbose", + ]); + expect(markdown.exitCode).toBe(0); + expect(markdown.stdout).toContain("Workspace mode: standalone"); + expect(markdown.stdout).toContain(`Caller worktree: ${canonicalLinked}`); + expect(markdown.stdout).toContain("handoff-context"); + expect(markdown.stdout).toContain("pnpm test — passed"); + expect(markdown.stdout).toContain("finish docs"); + expect(markdown.stdout).toContain("remote drift"); + expect(markdown.stdout).toContain("issue-212"); + }); + + test("status preserves remote/default relationships, caller worktree, and verbose details", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + const remote = await mkdtemp(join(tmpdir(), "arashi-status-remote-")); + roots.push(remote); + await run(remote, ["git", "init", "--bare"]); + await run(root, ["git", "remote", "add", "origin", remote]); + await run(root, ["git", "push", "-u", "origin", "main"]); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "status-context", "--json"]); + const linked = join(root, ".worktrees", "status-context"); + const canonicalLinked = await realpath(linked); + await run(linked, ["git", "push", "-u", "origin", "status-context"]); + await writeFile(join(linked, "README.md"), "status dirty\n"); + + const result = await arashi(linked, ["status", "--verbose", "--json"]); + + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout).data; + expect(data).toMatchObject({ + callerWorktree: canonicalLinked, + currentBranch: "status-context", + mode: "standalone", + repositoryPath: canonicalRoot, + summary: { dirtyCount: 1, total: 2 }, + workspaceRoot: canonicalRoot, + worktreesBase: join(canonicalRoot, ".worktrees"), + }); + expect(data.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + branch: expect.objectContaining({ + ahead: 0, + behind: 0, + localBranch: "status-context", + remoteBranch: "origin/status-context", + }), + defaultBranch: expect.objectContaining({ branch: "main", state: "available" }), + files: [expect.objectContaining({ path: "README.md" })], + fullStatus: expect.stringContaining("README.md"), + path: canonicalLinked, + }), + ]), + ); + }); + + test("list preserves simple composability, table, verbose, and max-depth modes", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "list-modes", "--json"]); + + const simple = await arashi(root, ["list", "--max-depth", "0"]); + const table = await arashi(root, ["list", "--table"]); + const verbose = await arashi(root, ["list", "--verbose", "--max-depth", "0"]); + + expect(simple.exitCode).toBe(0); + expect(simple.stdout.trim().split("\n")).toHaveLength(2); + expect( + simple.stdout + .trim() + .split("\n") + .every((line) => line.startsWith("/")), + ).toBe(true); + expect(table.stdout).toContain("BRANCH"); + expect(table.stdout).toContain("WORKTREE"); + expect(table.stdout).toContain("list-modes"); + expect(verbose.stdout).toContain("Workspace mode: standalone"); + expect(verbose.stdout).toContain("HEAD:"); + expect(verbose.stdout).toContain("Branch:"); + }); + + test("prune reports structured stale entries, reasons, totals, and results", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await arashi(root, ["init", "--zero-config"]); + await run(root, [ + "git", + "worktree", + "add", + "-b", + "stale-prune", + join(root, ".worktrees", "stale-prune"), + ]); + await rm(join(root, ".worktrees", "stale-prune"), { recursive: true, force: true }); + + const preview = await arashi(root, ["prune", "--dry-run", "--json"]); + + expect(preview.exitCode).toBe(0); + expect(JSON.parse(preview.stdout).data).toMatchObject({ + dryRun: true, + mode: "standalone", + overallStatus: "success", + repositories: [ + expect.objectContaining({ + path: canonicalRoot, + prunable: [ + expect.objectContaining({ + branch: "stale-prune", + path: expect.stringContaining("stale-prune"), + pruneReason: expect.any(String), + }), + ], + status: "skipped", + }), + ], + totalPrunable: 1, + totalPruned: 0, + totalRepositories: 1, + }); + + const applied = await arashi(root, ["prune", "--json"]); + expect(applied.exitCode).toBe(0); + expect(JSON.parse(applied.stdout).data).toMatchObject({ + repositories: [expect.objectContaining({ prunedCount: 1, status: "pruned" })], + totalPruned: 1, + }); + }); + + test("doctor reports full standalone repository/worktree health without synthetic repos", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await writeFile(join(root, "README.md"), "doctor dirty\n"); + await run(root, [ + "git", + "worktree", + "add", + "-b", + "doctor-stale", + join(root, ".worktrees", "doctor-stale"), + ]); + await rm(join(root, ".worktrees", "doctor-stale"), { recursive: true, force: true }); + + const result = await arashi(root, ["doctor", "--json"]); + + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout).data; + expect(data).toMatchObject({ + checkedCategories: expect.arrayContaining(["workspace", "repository", "worktree"]), + mode: "standalone", + }); + expect(data.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "REPOSITORY_DIRTY" }), + expect.objectContaining({ + code: "WORKTREE_STALE_METADATA", + details: expect.objectContaining({ path: expect.stringContaining("doctor-stale") }), + }), + ]), + ); + expect(JSON.stringify(data)).not.toContain("managed-ignore:./repos"); + expect(JSON.stringify(data)).not.toContain("MANAGED_IGNORE_MISSING"); + }); + + test("move JSON and human results identify standalone mode and roots", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "move-mode", "--json"]); + await writeFile(join(root, "README.md"), "move mode\n"); + const mainBranch = (await run(root, ["git", "branch", "--show-current"])).stdout.trim(); + + const json = await arashi(root, ["move", "--from", mainBranch, "--to", "move-mode", "--json"]); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout).data).toMatchObject({ + mode: "standalone", + repositoryPath: canonicalRoot, + workspaceRoot: canonicalRoot, + worktreesBase: join(canonicalRoot, ".worktrees"), + }); + + await run(join(root, ".worktrees", "move-mode"), ["git", "restore", "README.md"]); + await writeFile(join(root, "README.md"), "move mode again\n"); + const human = await arashi(root, ["move", "--from", mainBranch, "--to", "move-mode"]); + expect(human.stdout).toContain("Workspace mode: standalone"); + expect(human.stdout).toContain(canonicalRoot); + }); + + test("switch human success identifies standalone mode and exact target", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "switch-mode", "--json"]); + const directive = join(root, "switch.directive"); + + const result = await arashi(root, ["switch", "switch-mode", "--cd"], { + ARASHI_DIRECTIVE_FILE: directive, + ARASHI_SHELL: "zsh", + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Workspace mode: standalone"); + expect(result.stdout).toContain(await realpath(root)); + expect(await readFile(directive, "utf8")).toContain( + await realpath(join(root, ".worktrees", "switch-mode")), + ); + }); + + test.each([ + ["add", ["https://example.com/repository.git", "--json"]], + ["clone", ["--all", "--json"]], + ["exec", ["--json", "--", "true"]], + ["pull", ["--json"]], + ["push", ["--dry-run", "--json"]], + ["setup", ["--json"]], + ["sync", ["--json"]], + ])("rejects configured-only %s before repository mutation", async (command, args) => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + + const result = await arashi(root, [command, ...args]); + + expect(result.exitCode).not.toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.error).toMatchObject({ code: "CONFIGURED_WORKSPACE_REQUIRED" }); + expect(envelope.error.message).toContain("arashi init"); + await expect(access(join(root, ".arashi"))).rejects.toThrow(); + }); + + test("moves changes between standalone worktrees with explicit references", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "feature", "--json"]); + await writeFile(join(root, "README.md"), "changed\n"); + const mainBranch = (await run(root, ["git", "branch", "--show-current"])).stdout.trim(); + + const result = await arashi(root, ["move", "--from", mainBranch, "--to", "feature", "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ movedCount: 1 }); + expect( + await ( + await import("fs/promises") + ).readFile(join(root, ".worktrees", "feature", "README.md"), "utf8"), + ).toBe("changed\n"); + await expect(access(join(root, ".arashi"))).rejects.toThrow(); + }); + + test("uses a standalone linked worktree as the implicit move source", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "source", "--json"]); + const linked = join(root, ".worktrees", "source"); + await writeFile(join(linked, "README.md"), "from linked\n"); + const mainBranch = (await run(root, ["git", "branch", "--show-current"])).stdout.trim(); + + const result = await arashi(linked, ["move", "--to", mainBranch, "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ movedCount: 1 }); + expect(await readFile(join(root, "README.md"), "utf8")).toBe("from linked\n"); + }); + + test("runs targeted then shared global create hooks and ignores local hooks", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const home = await mkdtemp(join(tmpdir(), "arashi-home-")); + roots.push(home); + const hooks = join(home, ".arashi", "hooks"); + const record = join(home, "hooks.log"); + await mkdir(join(hooks, basename(root)), { recursive: true }); + await mkdir(join(root, ".arashi", "hooks"), { recursive: true }); + const scripts = [ + [join(hooks, basename(root), "pre-create.sh"), "targeted-pre"], + [join(hooks, "pre-create.sh"), "shared-pre"], + [join(hooks, basename(root), "post-create.sh"), "targeted-post"], + [join(hooks, "post-create.sh"), "shared-post"], + [join(root, ".arashi", "hooks", "pre-create.sh"), "local-should-not-run"], + ]; + for (const [path, label] of scripts) { + await writeFile(path, `#!/bin/sh\nprintf '%s\\n' '${label}' >> '${record}'\n`); + await chmod(path, 0o755); + } + + const result = await arashi(root, ["create", "hooked"], { HOME: home }); + + expect(result.exitCode).toBe(0); + expect(await readFile(record, "utf8")).toBe( + "targeted-pre\nshared-pre\ntargeted-post\nshared-post\n", + ); + }); + + test("a failing global pre-remove hook gates standalone removal", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "protected", "--json", "--no-hooks"]); + const home = await mkdtemp(join(tmpdir(), "arashi-home-")); + roots.push(home); + const hookDirectory = join(home, ".arashi", "hooks", basename(root)); + const hook = join(hookDirectory, "pre-remove.sh"); + await mkdir(hookDirectory, { recursive: true }); + await writeFile(hook, "#!/bin/sh\nexit 42\n"); + await chmod(hook, 0o755); + + const result = await arashi(root, ["remove", "protected"], { HOME: home }); + + expect(result.exitCode).not.toBe(0); + await expect(access(join(root, ".worktrees", "protected"))).resolves.toBeUndefined(); + expect((await run(root, ["git", "branch", "--list", "protected"])).stdout).toContain( + "protected", + ); + }); + + test("standalone remove finalizes after operation failure and aggregates hook failure", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "partial-remove", "--json", "--no-hooks"]); + const linked = join(root, ".worktrees", "partial-remove"); + await writeFile(join(linked, "untracked.txt"), "dirty\n"); + const home = await mkdtemp(join(tmpdir(), "arashi-home-")); + roots.push(home); + const hookDirectory = join(home, ".arashi", "hooks", basename(root)); + const record = join(home, "post-remove.log"); + await mkdir(hookDirectory, { recursive: true }); + await writeFile( + join(hookDirectory, "post-remove.sh"), + `#!/bin/sh\nprintf '%s\\n' "$ARASHI_WORKSPACE_MODE:$ARASHI_REPO_NAME:$ARASHI_BRANCH_NAME" > '${record}'\nexit 23\n`, + ); + await chmod(join(hookDirectory, "post-remove.sh"), 0o755); + + const result = await arashi(root, ["remove", "partial-remove", "--json"], { HOME: home }); + + expect(result.exitCode).not.toBe(0); + expect(await readFile(record, "utf8")).toContain("standalone"); + expect(JSON.parse(result.stdout).error).toMatchObject({ + code: "STANDALONE_REMOVE_PARTIAL_FAILURE", + details: { + finalState: { branchExists: true, worktreeExists: true }, + hookFailures: expect.arrayContaining([ + expect.objectContaining({ hookName: "post-remove" }), + ]), + operationFailures: expect.arrayContaining([ + expect.objectContaining({ operation: "remove-worktree" }), + ]), + }, + }); + await expect(access(linked)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/integration/workspace-context.test.ts b/tests/integration/workspace-context.test.ts new file mode 100644 index 0000000..90321fe --- /dev/null +++ b/tests/integration/workspace-context.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { access, chmod, mkdir, mkdtemp, realpath, rm, writeFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { spawn } from "../helpers/node-runtime.ts"; +import { ConfigParseError, resolveWorkspaceContext } from "../../src/lib/workspace-context.ts"; +import { + ConfigError, + ConfigValidationError, + UnsupportedConfigVersionError, +} from "../../src/lib/config.ts"; + +const roots: string[] = []; + +async function run(cwd: string, args: string[]): Promise { + const process = spawn(args, { cwd, stderr: "pipe", stdout: "pipe" }); + const [stdout, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + process.exited, + ]); + expect(exitCode).toBe(0); + return stdout.trim(); +} + +async function repository(): Promise { + const root = await mkdtemp(join(tmpdir(), "arashi-context-")); + roots.push(root); + await run(root, ["git", "init"]); + await run(root, ["git", "config", "user.email", "test@example.com"]); + await run(root, ["git", "config", "user.name", "Test User"]); + await writeFile(join(root, "README.md"), "test\n"); + await run(root, ["git", "add", "README.md"]); + await run(root, ["git", "commit", "-m", "initial"]); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("resolveWorkspaceContext", () => { + test("resolves a standalone main repository and nested invocation without persistence", async () => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, "src", "nested"), { recursive: true }); + const canonicalRoot = await realpath(root); + + const context = await resolveWorkspaceContext(join(root, "src", "nested")); + + expect(context).toMatchObject({ + config: { repos: {}, reposDir: "./repos", version: "1.0.0", worktreesDir: ".worktrees" }, + mainRoot: canonicalRoot, + mode: "standalone", + repository: { name: root.split("/").at(-1), path: canonicalRoot }, + workspaceRoot: canonicalRoot, + }); + await expect(access(join(root, ".arashi", "config.json"))).rejects.toThrow(); + }); + + test("resolves the main root when invoked from an external linked worktree", async () => { + const root = await repository(); + const linked = `${root}-linked`; + roots.push(linked); + await mkdir(join(root, ".worktrees")); + await run(root, ["git", "worktree", "add", "-b", "linked", linked]); + + const context = await resolveWorkspaceContext(linked); + + expect(context.mode).toBe("standalone"); + if (context.mode === "standalone") expect(context.mainRoot).toBe(await realpath(root)); + }); + + test("configured discovery wins and malformed configuration is not hidden", async () => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, ".arashi")); + await writeFile(join(root, ".arashi", "config.json"), "{"); + + await expect(resolveWorkspaceContext(root)).rejects.toBeInstanceOf(ConfigParseError); + }); + + test.each([ + [ + "schema-invalid", + JSON.stringify({ version: "1.0.0", reposDir: 42, repos: {} }), + ConfigValidationError, + ], + [ + "unsupported", + JSON.stringify({ version: "2.0.0", reposDir: "./repos", repos: {} }), + UnsupportedConfigVersionError, + ], + ])("preserves %s config failures beside the convention", async (_name, contents, ErrorType) => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, ".arashi")); + await writeFile(join(root, ".arashi", "config.json"), contents); + + await expect(resolveWorkspaceContext(root)).rejects.toBeInstanceOf(ErrorType); + }); + + test("preserves unreadable config failures beside the convention", async () => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await mkdir(join(root, ".arashi")); + const config = join(root, ".arashi", "config.json"); + await writeFile(config, JSON.stringify({ version: "1.0.0", reposDir: "./repos", repos: {} })); + await chmod(config, 0o000); + try { + await expect(resolveWorkspaceContext(root)).rejects.toBeInstanceOf(ConfigError); + } finally { + await chmod(config, 0o600); + } + }); + + test("does not hide invalid configuration at a linked-worktree invocation root", async () => { + const root = await repository(); + const linked = `${root}-linked-config`; + roots.push(linked); + await mkdir(join(root, ".worktrees")); + await run(root, ["git", "worktree", "add", "-b", "linked-config", linked]); + await mkdir(join(linked, ".arashi")); + await writeFile(join(linked, ".arashi", "config.json"), "{"); + + await expect(resolveWorkspaceContext(linked)).rejects.toBeInstanceOf(ConfigParseError); + }); + + test("resolves an enclosing configured workspace before a nested standalone convention", async () => { + const parent = await repository(); + await mkdir(join(parent, ".arashi")); + await writeFile( + join(parent, ".arashi", "config.json"), + JSON.stringify({ version: "1.0.0", reposDir: "./repos", repos: {} }), + ); + const child = join(parent, "nested", "child"); + await mkdir(child, { recursive: true }); + await run(child, ["git", "init"]); + await mkdir(join(child, ".worktrees")); + + await expect(resolveWorkspaceContext(child)).resolves.toMatchObject({ + mode: "configured", + workspaceRoot: parent, + }); + }); + + test("resolves an external linked worktree of a managed child to its configured parent", async () => { + const parent = await repository(); + const child = join(parent, "repos", "child"); + await mkdir(child, { recursive: true }); + await run(child, ["git", "init"]); + await run(child, ["git", "config", "user.email", "test@example.com"]); + await run(child, ["git", "config", "user.name", "Test User"]); + await writeFile(join(child, "README.md"), "child\n"); + await run(child, ["git", "add", "."]); + await run(child, ["git", "commit", "-m", "initial"]); + await mkdir(join(child, ".worktrees")); + await mkdir(join(parent, ".arashi")); + await writeFile( + join(parent, ".arashi", "config.json"), + JSON.stringify({ + version: "1.0.0", + reposDir: "./repos", + repos: { child: { path: "./repos/child" } }, + }), + ); + const linked = `${parent}-external-child`; + roots.push(linked); + await run(child, ["git", "worktree", "add", "-b", "external", linked]); + + await expect(resolveWorkspaceContext(linked)).resolves.toMatchObject({ + mode: "configured", + workspaceRoot: await realpath(parent), + }); + }); + + test("returns unavailable without the convention and outside Git", async () => { + const root = await repository(); + const plain = await mkdtemp(join(tmpdir(), "arashi-context-plain-")); + roots.push(plain); + + await expect(resolveWorkspaceContext(root)).resolves.toMatchObject({ mode: "unavailable" }); + await expect(resolveWorkspaceContext(plain)).resolves.toMatchObject({ mode: "unavailable" }); + }); + + test("resolves a repository created with a separate Git directory", async () => { + const parent = await mkdtemp(join(tmpdir(), "arashi-context-separate-")); + roots.push(parent); + const root = join(parent, "worktree"); + const gitDirectory = join(parent, "metadata.git"); + await mkdir(root); + await run(root, ["git", "init", `--separate-git-dir=${gitDirectory}`]); + await mkdir(join(root, ".worktrees")); + + await expect(resolveWorkspaceContext(root)).resolves.toMatchObject({ + mainRoot: await realpath(root), + mode: "standalone", + }); + }); +}); diff --git a/tests/unit/cli-contract.test.ts b/tests/unit/cli-contract.test.ts index 6ed17f9..d86736d 100644 --- a/tests/unit/cli-contract.test.ts +++ b/tests/unit/cli-contract.test.ts @@ -68,6 +68,38 @@ describe("CLI command contract", () => { ]); }); + test("classifies standalone support and configured-only boundaries", () => { + expect(commandSemantics.create.standalone).toEqual({ support: "full" }); + expect(commandSemantics.move.standalone).toEqual({ support: "full" }); + expect(commandSemantics.init.standalone.support).toBe("conditional"); + for (const command of ["add", "clone", "exec", "pull", "push", "setup", "sync"] as const) { + expect(commandSemantics[command].standalone.support).toBe("configured-only"); + expect(commandSemantics[command].standalone).toHaveProperty("reason"); + } + }); + + test("publishes enforceable init zero-config option and output policy", () => { + const contract = generateCommandContract( + buildProgram({ includeHelpBanner: false }), + commandSemantics, + ); + const init = contract.commands.find((command) => command.path === "init"); + + expect(init?.semantics.zeroConfig).toEqual({ + compatibleOptions: ["--dry-run", "--json", "--verbose"], + dryRun: { finalState: "unchanged", supported: true }, + incompatibleOptions: [ + "--force", + "--ignore-scope", + "--no-discover", + "--repos-dir", + "--worktrees-dir", + ], + json: { singleEnvelope: true, supported: true, suppressesHumanStdout: true }, + option: "--zero-config", + }); + }); + test("serializes deterministically with structural metadata", () => { const program = buildProgram({ includeHelpBanner: false }); const first = serializeCommandContract(generateCommandContract(program, commandSemantics)); From b3beb631e29a5c000c702cda355837812f87810a Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 19:20:22 -0700 Subject: [PATCH 2/9] fix(doctor): preserve structured config findings --- src/commands/doctor.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e126151..7bb2585 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -97,11 +97,32 @@ export const executeDoctor = async (options: DoctorOptions = {}): Promise Date: Tue, 14 Jul 2026 19:27:24 -0700 Subject: [PATCH 3/9] fix(doctor): retain config error context --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7bb2585..a1fa0c2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -117,7 +117,7 @@ export const executeDoctor = async (options: DoctorOptions = {}): Promise Date: Tue, 14 Jul 2026 19:43:57 -0700 Subject: [PATCH 4/9] fix(cli): preserve standalone command contracts --- src/commands/doctor.ts | 26 +++++++-- src/commands/list.ts | 7 ++- .../integration/standalone-lifecycle.test.ts | 55 ++++++++++++++++++- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a1fa0c2..46810ce 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -175,12 +175,30 @@ export const executeDoctor = async (options: DoctorOptions = {}): Promise ZERO; + if (options.json) { + if (hasBlockingFindings) { + writeJsonEnvelope( + createJsonErrorEnvelope("doctor", { + code: "DOCTOR_BLOCKING_FINDINGS", + details: data, + message: `${data.summary.error} blocking doctor finding(s) detected`, + }), + ); + } else { + writeJsonEnvelope(createJsonSuccessEnvelope("doctor", data)); + } + } else { console.log( - `Arashi workspace doctor\nWorkspace mode: standalone\nWorkspace: ${context.mainRoot}\n${findings.length ? findings.map((finding) => finding.message).join("\n") : "No workspace health findings were detected."}`, + `Workspace mode: standalone\n${formatDoctorHumanOutput({ + checkedCategories: [...data.checkedCategories], + findings, + summary: data.summary, + workspaceRoot: context.mainRoot, + })}`, ); - return ZERO; + } + return hasBlockingFindings ? ERROR_EXIT_CODE : ZERO; } const result = await runDoctor(); const hasBlockingFindings = result.summary.error > ZERO; diff --git a/src/commands/list.ts b/src/commands/list.ts index 41b8176..e6bc5f3 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -72,10 +72,15 @@ Examples: $ arashi list | fzf # Interactive selection with fzf `, ) - .action(async (options: CliOptions) => { + .action(async (options: CliOptions, command: Command) => { try { const context = await resolveWorkspaceContext(); if (context.mode === "standalone") { + if (command.getOptionValueSource("maxDepth") === "cli") { + throw new ListCommandError( + "--max-depth is not supported in standalone mode because standalone discovery never traverses sub-repositories.", + ); + } const worktrees = await standaloneWorktrees(context); if (options.json) { writeJsonEnvelope( diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index a790bf0..78669f2 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -352,14 +352,15 @@ describe("standalone lifecycle", () => { ); }); - test("list preserves simple composability, table, verbose, and max-depth modes", async () => { + test("list preserves simple composability, table, and verbose modes while rejecting standalone depth discovery", async () => { const root = await repository(); await arashi(root, ["init", "--zero-config"]); await arashi(root, ["create", "list-modes", "--json"]); - const simple = await arashi(root, ["list", "--max-depth", "0"]); + const simple = await arashi(root, ["list"]); const table = await arashi(root, ["list", "--table"]); - const verbose = await arashi(root, ["list", "--verbose", "--max-depth", "0"]); + const verbose = await arashi(root, ["list", "--verbose"]); + const depth = await arashi(root, ["list", "--max-depth", "0", "--json"]); expect(simple.exitCode).toBe(0); expect(simple.stdout.trim().split("\n")).toHaveLength(2); @@ -375,6 +376,12 @@ describe("standalone lifecycle", () => { expect(verbose.stdout).toContain("Workspace mode: standalone"); expect(verbose.stdout).toContain("HEAD:"); expect(verbose.stdout).toContain("Branch:"); + expect(depth.exitCode).not.toBe(0); + expect(JSON.parse(depth.stdout)).toMatchObject({ + command: "list", + error: { message: expect.stringContaining("--max-depth") }, + ok: false, + }); }); test("prune reports structured stale entries, reasons, totals, and results", async () => { @@ -459,6 +466,48 @@ describe("standalone lifecycle", () => { expect(JSON.stringify(data)).not.toContain("MANAGED_IGNORE_MISSING"); }); + test("doctor preserves blocking exit, JSON, and human finding contracts in standalone mode", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const realGit = (await run(root, ["which", "git"])).stdout.trim(); + const bin = join(root, "test-bin"); + const wrapper = join(bin, "git"); + await mkdir(bin); + await writeFile( + wrapper, + `#!/bin/sh\nif [ "$1" = status ]; then echo 'injected status failure' >&2; exit 42; fi\nexec '${realGit}' "$@"\n`, + ); + await chmod(wrapper, 0o755); + const env = { PATH: `${bin}:${process.env.PATH ?? ""}` }; + + const json = await arashi(root, ["doctor", "--json"], env); + expect(json.exitCode).not.toBe(0); + expect(JSON.parse(json.stdout)).toMatchObject({ + command: "doctor", + error: { + code: "DOCTOR_BLOCKING_FINDINGS", + details: { + findings: [ + expect.objectContaining({ + code: "REPOSITORY_STATUS_FAILED", + severity: "error", + suggestedCommands: [expect.stringContaining("git -C")], + }), + ], + mode: "standalone", + }, + }, + ok: false, + }); + + const human = await arashi(root, ["doctor"], env); + expect(human.exitCode).not.toBe(0); + expect(human.stdout).toContain("Workspace mode: standalone"); + expect(human.stdout).toContain("REPOSITORY_STATUS_FAILED"); + expect(human.stdout).toContain("repository:"); + expect(human.stdout).toContain("Suggested commands:"); + }); + test("move JSON and human results identify standalone mode and roots", async () => { const root = await repository(); const canonicalRoot = await realpath(root); From 9b30e54ea741bb365c970ac780c5373dab43336c Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 21:25:51 -0700 Subject: [PATCH 5/9] fix(cli): complete standalone lifecycle parity --- src/commands/create.ts | 70 ++++- src/commands/init.ts | 10 +- src/commands/list.ts | 17 +- src/commands/remove.ts | 295 +++++++++++++----- src/core/list.ts | 7 +- src/lib/workspace-context.ts | 23 ++ tests/integration/init.zero-config.test.ts | 30 ++ tests/integration/json-cli-output.test.ts | 24 ++ .../integration/standalone-lifecycle.test.ts | 179 ++++++++++- 9 files changed, 557 insertions(+), 98 deletions(-) diff --git a/src/commands/create.ts b/src/commands/create.ts index f731264..8701470 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -51,7 +51,7 @@ import { type ManagedIgnoreReconciliation, } from "../lib/managed-ignore.ts"; import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; -import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { resolveWorkspaceContext, workspaceJsonMetadata } from "../lib/workspace-context.ts"; import { createStandaloneWorktree, StandaloneDestinationNotIgnoredError, @@ -178,6 +178,7 @@ interface CreateSummaryJsonOptions { moveSummary?: MoveSummary | null; managedIgnore?: ManagedIgnoreReconciliation; summary: OperationSummary; + workspaceMetadata?: Record; } const createSummaryJsonData = ({ @@ -186,7 +187,9 @@ const createSummaryJsonData = ({ managedIgnore, moveSummary, summary, + workspaceMetadata, }: CreateSummaryJsonOptions) => ({ + ...workspaceMetadata, branchName, dirtyWorkspaceGuidance, dryRun: summary.isDryRun === true, @@ -587,6 +590,46 @@ const applyPostCreateDefaults = async ({ ); }; +const applyStandaloneCreateOverrides = async (options: { + branchName: string; + commandOptions: CreateCommandOptions; + context: Extract>, { mode: "standalone" }>; + deps: CreateCommandDependencies; + worktreePath: string; +}): Promise => { + if (options.commandOptions.dryRun) { + return; + } + const defaults = resolveCreateDefaults(options.commandOptions, options.context.config); + if (!defaults.shouldSwitch) { + return; + } + + info(`Default switch target: ${options.worktreePath}`); + if (!defaults.shouldLaunch) { + info("Launch skipped (resolved defaults disabled launch for this invocation)."); + return; + } + + const launchCandidate = options.deps.launchSwitchTarget ?? launchSwitchTarget; + const launchResult = await launchCandidate( + { + branchName: options.branchName, + repoName: options.context.repository.name, + worktreePath: options.worktreePath, + }, + { sesh: defaults.launchMode === SESH_LAUNCH_MODE }, + { + env: options.deps.env ?? process.env, + platform: options.deps.platform ?? process.platform, + runProcess: options.deps.runProcess, + }, + ); + success( + `Opened ${launchResult.mode} context for ${options.context.repository.name} at ${options.worktreePath}`, + ); +}; + export function createCommand(): Command { const editorHostOption = new Option( "--editor-host ", @@ -724,8 +767,13 @@ export async function executeCreate( skipHooks: options.noHooks === true || options.hooks === false, }, ); - if (options.json) writeJsonEnvelope(createJsonSuccessEnvelope("create", standaloneResult)); - else { + const standaloneData = { + ...workspaceJsonMetadata(workspaceContext), + ...standaloneResult, + }; + if (options.json) { + writeJsonEnvelope(createJsonSuccessEnvelope("create", standaloneData)); + } else { info("Workspace mode: standalone"); success( options.dryRun @@ -733,6 +781,13 @@ export async function executeCreate( : `Created worktree at ${standaloneResult.worktreePath}`, ); } + await applyStandaloneCreateOverrides({ + branchName, + commandOptions: options, + context: workspaceContext, + deps, + worktreePath: standaloneResult.worktreePath, + }); return ZERO; } @@ -991,6 +1046,15 @@ export async function executeCreate( managedIgnore, moveSummary, summary, + workspaceMetadata: { + mode: "configured", + repositoriesBase: resolve(context.workspaceRoot, arashiConfig.reposDir), + workspaceRoot: context.workspaceRoot, + worktreesBase: resolve( + context.workspaceRoot, + arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, + ), + }, }), ), ); diff --git a/src/commands/init.ts b/src/commands/init.ts index b838b43..ac252e6 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1306,7 +1306,15 @@ Existing effective tracked, local, or global rules are honored. Arashi never mod writeJsonEnvelope( createJsonErrorEnvelope("init", { code: "ZERO_CONFIG_INCOMPATIBLE_OPTIONS", - details: { conflicts, mode: "standalone" }, + details: { + attempted: { localExclude: false, worktreesDirectory: false }, + conflicts, + finalState: { + localExcludeChanged: false, + worktreesDirectoryChanged: false, + }, + mode: "standalone", + }, message: conflictError.message, }), ); diff --git a/src/commands/list.ts b/src/commands/list.ts index e6bc5f3..abd3bf6 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -20,7 +20,7 @@ import { } from "../lib/json-output.ts"; import { listCommand } from "../core/list.ts"; import { error as logError } from "../lib/logger.ts"; -import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { resolveWorkspaceContext, workspaceJsonMetadata } from "../lib/workspace-context.ts"; import { standaloneWorktrees } from "../lib/standalone.ts"; type ListCommandOptions = Parameters[0]; @@ -85,13 +85,13 @@ Examples: if (options.json) { writeJsonEnvelope( createJsonSuccessEnvelope("list", { - mode: "standalone", + ...workspaceJsonMetadata(context), repositoryPath: context.mainRoot, - workspaceRoot: context.mainRoot, worktrees, }), ); } else { + console.error("Workspace mode: standalone"); if (options.table) { console.log("BRANCH\tHEAD\tWORKTREE"); for (const worktree of worktrees) @@ -111,7 +111,10 @@ Examples: } process.exit(0); } - await executeList(options); + await executeList( + options, + context.mode === "configured" ? workspaceJsonMetadata(context) : undefined, + ); } catch (error) { if (options.json) { writeJsonEnvelope(createJsonErrorEnvelope("list", mapListErrorToJsonError(error))); @@ -181,9 +184,13 @@ function mapListErrorToJsonError(error: unknown): ReturnType { +async function executeList( + options: CliOptions, + jsonMetadata?: Record, +): Promise { const listOptions: ListCommandOptions = { json: options.json || false, + jsonMetadata, maxDepth: options.maxDepth ?? 3, table: options.table || false, verbose: options.verbose || false, diff --git a/src/commands/remove.ts b/src/commands/remove.ts index b7a8c61..c9467c5 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -46,7 +46,7 @@ import { writeJsonEnvelope, } from "../lib/json-output.ts"; import { findWorkspaceRoot, loadConfig } from "../lib/config.ts"; -import { resolveWorkspaceContext } from "../lib/workspace-context.ts"; +import { resolveWorkspaceContext, workspaceJsonMetadata } from "../lib/workspace-context.ts"; import { runStandaloneGlobalHooks, standaloneWorktrees } from "../lib/standalone.ts"; import { exec as standaloneGitExec, getDefaultBranch } from "../lib/git.ts"; import { info, error as logError, spinner, warn } from "../lib/logger.ts"; @@ -159,6 +159,15 @@ const formatDirtyDetailsText = (worktree: WorktreeEntry): string => { return ` (${parts.join(", ")})`; }; +const removalJsonData = ( + summary: Parameters[0], + extras: NonNullable[1]>, + metadata?: Record, +): Record => ({ + ...JSON.parse(formatRemovalSummaryJson(summary, extras)), + ...metadata, +}); + interface SelectionExpansionOptions { entries: WorktreeEntry[]; grouping: WorktreeGrouping; @@ -222,100 +231,217 @@ export async function executeRemove( RemoveCommandErrorCode.BRANCH_NOT_FOUND, ); } - if (!options.dryRun) { - await runStandaloneGlobalHooks( - workspaceContext, - "pre-remove", - target.branch ?? branchArg, - target.path, - false, - options.json === true, - ); + const repositoryName = workspaceContext.repository.name; + const targetEntry: WorktreeEntry = { + branch: target.branch ?? "", + childrenPaths: [], + isMain: false, + parentPath: null, + path: target.path, + repository: repositoryName, + status: "present", + }; + if (options.checkDirty !== false) { + await resolveWorktreeStatuses([targetEntry], true); + } + + const branchPresence: Record = target.branch + ? { [target.branch]: [repositoryName] } + : {}; + const prompt = promptHandlers || { confirm: promptConfirm, multiSelect: promptMultiSelect }; + const allowNonInteractive = Boolean(promptHandlers); + if (!options.force && !options.dryRun && !(options.keepBranches && options.keepWorktrees)) { + ensureInteractive(allowNonInteractive); + const confirmation = await promptConfirmation({ + branchPresence, + checkDirty: options.checkDirty !== false, + confirm: prompt.confirm, + worktrees: [targetEntry], + }); + if (confirmation === "cancelled") { + info("Operation cancelled"); + return ZERO; + } + if (confirmation === "declined") { + info("Operation cancelled by user"); + return ZERO; + } + } + + const totalWorktrees = options.keepWorktrees ? ZERO : ONE; + const totalBranches = options.keepBranches || !target.branch ? ZERO : ONE; + const summary = createRemovalSummary(totalWorktrees, totalBranches); + const metadata = { + ...workspaceJsonMetadata(workspaceContext), + repositoryPath: workspaceContext.mainRoot, + }; + const hookTargets = [{ name: repositoryName, path: workspaceContext.mainRoot }]; + + if (options.dryRun) { + summary.dryRun = true; + summary.effectiveOptions = { + checkDirty: options.checkDirty !== false, + force: options.force === true, + keepBranches: options.keepBranches === true, + keepWorktrees: options.keepWorktrees === true, + }; + summary.dirtyWorktrees = targetEntry.isDirty ? [targetEntry] : []; + if (!options.keepWorktrees) { + summary.operations.push({ + branchName: targetEntry.branch, + repository: repositoryName, + status: "pending", + type: "worktree_remove", + worktreePath: targetEntry.path, + }); + } + if (!options.keepBranches && target.branch) { + summary.operations.push({ + branchName: target.branch, + repository: repositoryName, + status: "pending", + type: "branch_delete", + }); + } + summary.hookPreviews = await previewRemoveLifecycleHooks({ + globalOnly: true, + targetRepositories: hookTargets, + workspaceRoot: workspaceContext.mainRoot, + }); + summary.duration = Date.now() - startTime; + const data = removalJsonData(summary, {}, metadata); + if (options.json) { + writeJsonEnvelope(createJsonSuccessEnvelope("remove", data)); + } else { + info("Workspace mode: standalone"); + console.log(formatRemovalSummaryHuman(summary, {})); + } + return ZERO; } + + if (options.keepBranches && options.keepWorktrees) { + summary.duration = Date.now() - startTime; + const data = removalJsonData(summary, {}, metadata); + if (options.json) { + writeJsonEnvelope(createJsonSuccessEnvelope("remove", data)); + } else { + warn("Both --keep-worktrees and --keep-branches specified"); + info("No operations will be performed. At least one removal type must be enabled."); + } + return ZERO; + } + + await runStandaloneGlobalHooks( + workspaceContext, + "pre-remove", + target.branch ?? branchArg, + target.path, + false, + options.json === true, + ); const operationFailures: { message: string; operation: string }[] = []; const hookFailures: { hookName: string; message: string }[] = []; - if (!options.dryRun && !options.keepWorktrees) { + if (options.keepWorktrees) { try { - await standaloneGitExec( - [ - "worktree", - "remove", - ...(options.force || options.checkDirty === false ? ["--force"] : []), - target.path, - ], - workspaceContext.mainRoot, - ); + await detachWorktree(target.path); } catch (error) { operationFailures.push({ message: error instanceof Error ? error.message : String(error), - operation: "remove-worktree", + operation: "detach-worktree", }); } - } - if (!options.dryRun && !options.keepBranches && target.branch) { + } else { + const operation: RemovalOperation = { + branchName: targetEntry.branch, + repository: repositoryName, + status: "pending", + type: "worktree_remove", + worktreePath: target.path, + }; try { - await standaloneGitExec(["branch", "-D", target.branch], workspaceContext.mainRoot); + await removeWorktree( + targetEntry, + workspaceContext.mainRoot, + options.force === true || options.checkDirty === false || targetEntry.isDirty === true, + ); + operation.status = "success"; + summary.successfulWorktrees = ONE; } catch (error) { - operationFailures.push({ - message: error instanceof Error ? error.message : String(error), - operation: "delete-branch", - }); + operation.status = "failed"; + operation.error = formatWorktreeRemovalError(error); + operationFailures.push({ message: operation.error, operation: "remove-worktree" }); } + summary.operations.push(operation); } - if (!options.dryRun) { + if (!options.keepBranches && target.branch) { + const operation: RemovalOperation = { + branchName: target.branch, + repository: repositoryName, + status: "pending", + type: "branch_delete", + }; try { - await runStandaloneGlobalHooks( - workspaceContext, - "post-remove", - target.branch ?? branchArg, - target.path, - false, - options.json === true, - ); + await standaloneGitExec(["branch", "-D", target.branch], workspaceContext.mainRoot); + operation.status = "success"; + summary.successfulBranches = ONE; } catch (error) { - hookFailures.push({ - hookName: "post-remove", - message: error instanceof Error ? error.message : String(error), - }); + operation.status = "failed"; + operation.error = formatBranchDeletionError(error); + operationFailures.push({ message: operation.error, operation: "delete-branch" }); } - if (operationFailures.length > ZERO || hookFailures.length > ZERO) { - let finalBranchExists = false; - if (target.branch) { - try { - await standaloneGitExec( - ["show-ref", "--verify", `refs/heads/${target.branch}`], - workspaceContext.mainRoot, - ); - finalBranchExists = true; - } catch { - finalBranchExists = false; - } + summary.operations.push(operation); + } + try { + await runStandaloneGlobalHooks( + workspaceContext, + "post-remove", + target.branch ?? branchArg, + target.path, + false, + options.json === true, + ); + } catch (error) { + hookFailures.push({ + hookName: "post-remove", + message: error instanceof Error ? error.message : String(error), + }); + } + if (operationFailures.length > ZERO || hookFailures.length > ZERO) { + let finalBranchExists = false; + if (target.branch) { + try { + await standaloneGitExec( + ["show-ref", "--verify", `refs/heads/${target.branch}`], + workspaceContext.mainRoot, + ); + finalBranchExists = true; + } catch { + finalBranchExists = false; } - throw new StandaloneRemovePartialFailure({ - finalState: { - branchExists: finalBranchExists, - worktreeExists: existsSync(target.path), - }, - hookFailures, - operationFailures, - }); } + throw new StandaloneRemovePartialFailure({ + finalState: { + branchExists: finalBranchExists, + worktreeExists: existsSync(target.path), + }, + hookFailures, + operationFailures, + }); + } + summary.duration = Date.now() - startTime; + const data = removalJsonData(summary, {}, metadata); + if (options.json) { + writeJsonEnvelope(createJsonSuccessEnvelope("remove", data)); + } else { + info("Workspace mode: standalone"); + console.log(formatRemovalSummaryHuman(summary, {})); } - const data = { - branch: target.branch, - dryRun: options.dryRun === true, - mode: "standalone", - repositoryPath: workspaceContext.mainRoot, - worktreePath: target.path, - }; - if (options.json) writeJsonEnvelope(createJsonSuccessEnvelope("remove", data)); - else - console.log( - `${options.dryRun ? "Would remove" : "Removed"} standalone worktree ${target.path}`, - ); return ZERO; } + const configuredMetadata = + workspaceContext.mode === "configured" ? workspaceJsonMetadata(workspaceContext) : undefined; + if (options.json && !branchArg) { writeJsonEnvelope(unsupportedJsonModeError("remove", "interactive-selection")); return ONE; @@ -333,7 +459,7 @@ export async function executeRemove( }; if (options.json) { writeJsonEnvelope( - createJsonSuccessEnvelope("remove", JSON.parse(formatRemovalSummaryJson(summary, {}))), + createJsonSuccessEnvelope("remove", removalJsonData(summary, {}, configuredMetadata)), ); } else if (options.dryRun) { console.log(formatRemovalSummaryHuman(summary, {})); @@ -344,8 +470,14 @@ export async function executeRemove( return ZERO; } - const workspaceRoot = await getWorkspaceRoot(); - const config = await loadWorkspaceConfig(workspaceRoot); + const workspaceRoot = + workspaceContext.mode === "configured" + ? workspaceContext.workspaceRoot + : await getWorkspaceRoot(); + const config = + workspaceContext.mode === "configured" + ? workspaceContext.config + : await loadWorkspaceConfig(workspaceRoot); const reposDirName = basename(config.reposDir); const childRepoNames = new Set(Object.keys(config.repos)); const repositories = buildRepositoryTargets(workspaceRoot, config.repos); @@ -628,7 +760,7 @@ export async function executeRemove( writeJsonEnvelope( createJsonSuccessEnvelope( "remove", - JSON.parse(formatRemovalSummaryJson(summary, { missingBranches, skippedMain })), + removalJsonData(summary, { missingBranches, skippedMain }, configuredMetadata), ), ); } else { @@ -654,7 +786,7 @@ export async function executeRemove( writeJsonEnvelope( createJsonSuccessEnvelope( "remove", - JSON.parse(formatRemovalSummaryJson(summary, { missingBranches, skippedMain })), + removalJsonData(summary, { missingBranches, skippedMain }, configuredMetadata), ), ); } else { @@ -770,7 +902,7 @@ export async function executeRemove( writeJsonEnvelope( createJsonSuccessEnvelope( "remove", - JSON.parse(formatRemovalSummaryJson(summary, { missingBranches, skippedMain })), + removalJsonData(summary, { missingBranches, skippedMain }, configuredMetadata), ), ); } else { @@ -1138,6 +1270,7 @@ const formatBranchDeletionError = (error: unknown): string => { }; const previewRemoveLifecycleHooks = async (options: { + globalOnly?: boolean; workspaceRoot: string; targetRepositories: HookTargetRepository[]; }): Promise => { @@ -1148,7 +1281,9 @@ const previewRemoveLifecycleHooks = async (options: { targetRepositories: options.targetRepositories, workspaceRoot: options.workspaceRoot, }); - for (const resolvedHook of resolvedHooks) { + for (const resolvedHook of resolvedHooks.filter( + (candidate) => !options.globalOnly || candidate.scope.startsWith("global-"), + )) { const validation = await validateHook(resolvedHook.scriptPath); previews.push({ error: validation.error, diff --git a/src/core/list.ts b/src/core/list.ts index 8dd7531..833b766 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -40,6 +40,7 @@ interface ListCommandOptions { json?: boolean; table?: boolean; maxDepth?: number; + jsonMetadata?: Record; } interface ListCommandOutput { @@ -262,6 +263,7 @@ export const findParentRepo = async (currentPath: string): Promise => { const opts: ListCommandOptions = { json: options?.json || false, + jsonMetadata: options?.jsonMetadata, maxDepth: options?.maxDepth || DEFAULT_MAX_DEPTH, table: options?.table || false, verbose: options?.verbose || false, @@ -310,7 +312,10 @@ export const listCommand = async (options?: ListCommandOptions): Promise = let output_str = formatAsSimpleList(output); if (opts.json) { output_str = stringifyJsonEnvelope( - createJsonSuccessEnvelope("list", { worktrees: output.worktrees }), + createJsonSuccessEnvelope("list", { + ...opts.jsonMetadata, + worktrees: output.worktrees, + }), ); } else if (opts.table || opts.verbose) { // Table format when explicitly requested or in verbose mode diff --git a/src/lib/workspace-context.ts b/src/lib/workspace-context.ts index 458625a..64c248e 100644 --- a/src/lib/workspace-context.ts +++ b/src/lib/workspace-context.ts @@ -12,6 +12,7 @@ import { import { exec } from "./git.ts"; import { createJsonErrorEnvelope, writeJsonEnvelope } from "./json-output.ts"; import { error as logError } from "./logger.ts"; +import { DEFAULT_WORKTREES_DIR } from "./worktree-location.ts"; export { ConfigParseError } from "./config.ts"; @@ -43,6 +44,28 @@ export type WorkspaceContext = | StandaloneWorkspaceContext | UnavailableWorkspaceContext; +export interface WorkspaceJsonMetadata extends Record { + mode: "configured" | "standalone"; + repositoriesBase: string; + workspaceRoot: string; + worktreesBase: string; +} + +export const workspaceJsonMetadata = ( + context: ConfiguredWorkspaceContext | StandaloneWorkspaceContext, +): WorkspaceJsonMetadata => ({ + mode: context.mode, + repositoriesBase: + context.mode === "standalone" + ? context.mainRoot + : resolve(context.workspaceRoot, context.config.reposDir), + workspaceRoot: context.workspaceRoot, + worktreesBase: + context.mode === "standalone" + ? resolve(context.mainRoot, ".worktrees") + : resolve(context.workspaceRoot, context.config.worktreesDir ?? DEFAULT_WORKTREES_DIR), +}); + const standaloneConfig = (): Config => ({ repos: {}, reposDir: "./repos", diff --git a/tests/integration/init.zero-config.test.ts b/tests/integration/init.zero-config.test.ts index 6bb4fad..b2e4678 100644 --- a/tests/integration/init.zero-config.test.ts +++ b/tests/integration/init.zero-config.test.ts @@ -268,6 +268,36 @@ describe("init --zero-config", () => { await expect(access(join(root, ".worktrees"))).rejects.toThrow(); }); + test.each([ + "--repos-dir=x", + "--worktrees-dir=x", + "--ignore-scope=none", + "--force", + "--no-discover", + ])( + "reports unchanged attempted and final state for incompatible JSON option %s", + async (option) => { + const root = await repository(); + const result = await arashi(root, ["init", "--zero-config", option, "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + command: "init", + error: { + code: "ZERO_CONFIG_INCOMPATIBLE_OPTIONS", + details: { + attempted: { localExclude: false, worktreesDirectory: false }, + conflicts: [expect.stringMatching(/^--/)], + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + mode: "standalone", + }, + }, + ok: false, + }); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }, + ); + test("rejects existing configured state before mutation", async () => { const root = await repository(); await mkdir(join(root, ".arashi")); diff --git a/tests/integration/json-cli-output.test.ts b/tests/integration/json-cli-output.test.ts index 2aed8ac..0a5757c 100644 --- a/tests/integration/json-cli-output.test.ts +++ b/tests/integration/json-cli-output.test.ts @@ -283,8 +283,12 @@ describe("CLI JSON output contract", () => { branchName, failureCount: 0, managedIgnore: { changed: true, scope: "local" }, + mode: "configured", + repositoriesBase: join(await realpath(workspaceRoot), "repos"), successCount: 3, totalRepositories: 3, + workspaceRoot: await realpath(workspaceRoot), + worktreesBase: join(await realpath(workspaceRoot), ".arashi", "worktrees"), }); const createdRepositories = jsonArray(createData.repositories); expect(createdRepositories.map((repo) => repo.repositoryName).toSorted()).toEqual( @@ -315,6 +319,8 @@ describe("CLI JSON output contract", () => { }); const removeData = jsonData(removeParsed); expect(removeData).toMatchObject({ + mode: "configured", + repositoriesBase: join(await realpath(workspaceRoot), "repos"), success: true, summary: { successfulBranches: 3, @@ -322,6 +328,8 @@ describe("CLI JSON output contract", () => { totalBranches: 3, totalWorktrees: 3, }, + workspaceRoot: await realpath(workspaceRoot), + worktreesBase: join(await realpath(workspaceRoot), ".arashi", "worktrees"), }); for (const repo of createdRepositories) { expect(await runtime.file(repo.worktreePath as string).exists()).toBe(false); @@ -329,6 +337,22 @@ describe("CLI JSON output contract", () => { expect(removeResult.stdout).not.toContain("Successfully removed"); }); + test("configured list JSON includes additive workspace path metadata", async () => { + const workspaceRoot = await createCommonWorkspace(); + const canonicalRoot = await realpath(workspaceRoot); + + const result = await runArashi(workspaceRoot, ["list", "--json"]); + + expect(result.exitCode).toBe(0); + expect(jsonData(parseSingleJsonDocument(result.stdout))).toMatchObject({ + mode: "configured", + repositoriesBase: join(canonicalRoot, "repos"), + workspaceRoot: canonicalRoot, + worktreesBase: join(canonicalRoot, ".arashi", "worktrees"), + worktrees: expect.any(Array), + }); + }); + test("prune --json reports and removes stale worktree metadata", async () => { const workspaceRoot = await createCommonWorkspace(); const stalePath = join(workspaceRoot, "../stale-worktree"); diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index 78669f2..3dc2dbd 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -3,6 +3,8 @@ import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from import { basename, join } from "path"; import { tmpdir } from "os"; import { spawn } from "../helpers/node-runtime.ts"; +import { executeCreate } from "../../src/commands/create.ts"; +import { executeRemove } from "../../src/commands/remove.ts"; const roots: string[] = []; async function run(cwd: string, args: string[], env?: Record) { @@ -150,12 +152,170 @@ describe("standalone lifecycle", () => { expect(JSON.parse((await arashi(linked, ["status", "--json"])).stdout).data.mode).toBe( "standalone", ); - const removed = await arashi(root, ["remove", "feat/example", "--json"]); + const removed = await arashi(root, ["remove", "feat/example", "--force", "--json"]); expect(removed.exitCode).toBe(0); await expect(access(linked)).rejects.toThrow(); await expect(access(join(root, ".arashi"))).rejects.toThrow(); }); + test("standalone remove preserves confirmation and effective keep semantics", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + for (const branch of ["declined", "keep-directory", "keep-branch"]) { + expect((await arashi(root, ["create", branch, "--json"])).exitCode).toBe(0); + } + + const originalCwd = process.cwd(); + process.chdir(root); + try { + const prompts: string[] = []; + expect( + await executeRemove( + "declined", + {}, + { + confirm: async (message) => { + prompts.push(message); + return { status: "ok", value: false }; + }, + multiSelect: async () => ({ status: "ok", value: [] }), + }, + ), + ).toBe(0); + expect(prompts).toEqual([expect.stringContaining("Remove 1 worktree and delete 1 branch")]); + await expect(access(join(root, ".worktrees", "declined"))).resolves.toBeUndefined(); + expect((await run(root, ["git", "branch", "--list", "declined"])).stdout).toContain( + "declined", + ); + + expect(await executeRemove("keep-directory", { force: true, keepWorktrees: true })).toBe(0); + await expect(access(join(root, ".worktrees", "keep-directory"))).resolves.toBeUndefined(); + const keptDirectoryListing = (await run(root, ["git", "worktree", "list", "--porcelain"])) + .stdout; + expect(keptDirectoryListing).toContain(".worktrees/keep-directory"); + expect(keptDirectoryListing).toContain("detached"); + expect((await run(root, ["git", "branch", "--list", "keep-directory"])).stdout).toBe(""); + + expect(await executeRemove("keep-branch", { force: true, keepBranches: true })).toBe(0); + await expect(access(join(root, ".worktrees", "keep-branch"))).rejects.toThrow(); + expect((await run(root, ["git", "branch", "--list", "keep-branch"])).stdout.trim()).toBe( + "keep-branch", + ); + } finally { + process.chdir(originalCwd); + } + }); + + test("standalone remove dry-run reports a complete non-mutating plan", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "remove-preview", "--json"]); + const linked = join(root, ".worktrees", "remove-preview"); + await writeFile(join(linked, "dirty.txt"), "dirty\n"); + const home = await mkdtemp(join(tmpdir(), "arashi-remove-preview-home-")); + roots.push(home); + const hooks = join(home, ".arashi", "hooks"); + await mkdir(hooks, { recursive: true }); + const marker = join(home, "hook-ran"); + for (const hookName of ["pre-remove", "post-remove"]) { + const hook = join(hooks, `${hookName}.sh`); + await writeFile(hook, `#!/bin/sh\ntouch "${marker}"\n`); + await chmod(hook, 0o755); + } + + const result = await arashi(root, ["remove", "remove-preview", "--dry-run", "--json"], { + HOME: home, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + blockers: [ + expect.objectContaining({ + branchName: "remove-preview", + path: expect.stringContaining("remove-preview"), + type: "dirty_worktree", + }), + ], + dryRun: true, + effectiveOptions: { + checkDirty: true, + force: false, + keepBranches: false, + keepWorktrees: false, + }, + hooks: expect.arrayContaining([ + expect.objectContaining({ hookName: "pre-remove", scope: "global-shared" }), + expect.objectContaining({ hookName: "post-remove", scope: "global-shared" }), + ]), + mode: "standalone", + operations: expect.arrayContaining([ + expect.objectContaining({ + type: "worktree_remove", + worktreePath: join(canonicalRoot, ".worktrees", "remove-preview"), + }), + expect.objectContaining({ branchName: "remove-preview", type: "branch_delete" }), + ]), + repositoriesBase: canonicalRoot, + repositoryPath: canonicalRoot, + summary: { totalBranches: 1, totalWorktrees: 1 }, + workspaceRoot: canonicalRoot, + worktreesBase: join(canonicalRoot, ".worktrees"), + }); + await expect(access(marker)).rejects.toThrow(); + await expect(access(linked)).resolves.toBeUndefined(); + expect((await run(root, ["git", "branch", "--list", "remove-preview"])).stdout).toContain( + "remove-preview", + ); + }); + + test("standalone create applies explicit launch, sesh, switch, and opt-out overrides", async () => { + const root = await repository(); + const canonicalRoot = await realpath(root); + await arashi(root, ["init", "--zero-config"]); + const originalCwd = process.cwd(); + process.chdir(root); + const launches: { branchName: string; sesh?: boolean }[] = []; + const output: string[] = []; + const originalLog = console.log; + console.log = (...values: unknown[]) => output.push(values.map(String).join(" ")); + const dependencies = { + launchSwitchTarget: async ( + candidate: { branchName: string }, + options: { sesh?: boolean }, + ) => { + launches.push({ branchName: candidate.branchName, sesh: options.sesh }); + return { + command: ["test-launch"], + mode: options.sesh ? ("sesh" as const) : ("fallback" as const), + }; + }, + }; + try { + expect(await executeCreate("explicit-launch", { launch: true }, dependencies)).toBe(0); + expect(await executeCreate("explicit-sesh", { sesh: true }, dependencies)).toBe(0); + expect(await executeCreate("explicit-switch", { switch: true }, dependencies)).toBe(0); + expect( + await executeCreate("explicit-opt-out", { launch: false, switch: false }, dependencies), + ).toBe(0); + } finally { + console.log = originalLog; + process.chdir(originalCwd); + } + + expect(launches).toEqual([ + { branchName: "explicit-launch", sesh: false }, + { branchName: "explicit-sesh", sesh: true }, + ]); + expect(output.join("\n")).toContain( + `Default switch target: ${join(canonicalRoot, ".worktrees", "explicit-switch")}`, + ); + expect(output.join("\n")).toContain( + "Launch skipped (resolved defaults disabled launch for this invocation).", + ); + await expect(access(join(root, ".arashi"))).rejects.toThrow(); + }); + test.each([ ["create", ["branch", "--only", "repo"]], ["create", ["branch", "--group", "group"]], @@ -363,6 +523,7 @@ describe("standalone lifecycle", () => { const depth = await arashi(root, ["list", "--max-depth", "0", "--json"]); expect(simple.exitCode).toBe(0); + expect(simple.stderr).toContain("Workspace mode: standalone"); expect(simple.stdout.trim().split("\n")).toHaveLength(2); expect( simple.stdout @@ -373,6 +534,8 @@ describe("standalone lifecycle", () => { expect(table.stdout).toContain("BRANCH"); expect(table.stdout).toContain("WORKTREE"); expect(table.stdout).toContain("list-modes"); + expect(table.stderr).toContain("Workspace mode: standalone"); + expect(table.stdout).not.toContain("Workspace mode: standalone"); expect(verbose.stdout).toContain("Workspace mode: standalone"); expect(verbose.stdout).toContain("HEAD:"); expect(verbose.stdout).toContain("Branch:"); @@ -647,7 +810,7 @@ describe("standalone lifecycle", () => { await writeFile(hook, "#!/bin/sh\nexit 42\n"); await chmod(hook, 0o755); - const result = await arashi(root, ["remove", "protected"], { HOME: home }); + const result = await arashi(root, ["remove", "protected", "--force"], { HOME: home }); expect(result.exitCode).not.toBe(0); await expect(access(join(root, ".worktrees", "protected"))).resolves.toBeUndefined(); @@ -673,22 +836,22 @@ describe("standalone lifecycle", () => { ); await chmod(join(hookDirectory, "post-remove.sh"), 0o755); - const result = await arashi(root, ["remove", "partial-remove", "--json"], { HOME: home }); + const result = await arashi(root, ["remove", "partial-remove", "--force", "--json"], { + HOME: home, + }); expect(result.exitCode).not.toBe(0); expect(await readFile(record, "utf8")).toContain("standalone"); expect(JSON.parse(result.stdout).error).toMatchObject({ code: "STANDALONE_REMOVE_PARTIAL_FAILURE", details: { - finalState: { branchExists: true, worktreeExists: true }, + finalState: { branchExists: false, worktreeExists: false }, hookFailures: expect.arrayContaining([ expect.objectContaining({ hookName: "post-remove" }), ]), - operationFailures: expect.arrayContaining([ - expect.objectContaining({ operation: "remove-worktree" }), - ]), + operationFailures: [], }, }); - await expect(access(linked)).resolves.toBeUndefined(); + await expect(access(linked)).rejects.toThrow(); }); }); From 8e002d28d37b56ddc5f0a4b8af77d5a565fbfa91 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 21:52:30 -0700 Subject: [PATCH 6/9] fix(cli): honor negated ignore evidence --- src/lib/git-ignore.ts | 21 +++++++++++++++++++ src/lib/standalone.ts | 12 +++++------ src/lib/zero-config-bootstrap.ts | 8 +++++-- tests/integration/init.zero-config.test.ts | 20 ++++++++++++++++++ .../integration/standalone-lifecycle.test.ts | 19 +++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/lib/git-ignore.ts diff --git a/src/lib/git-ignore.ts b/src/lib/git-ignore.ts new file mode 100644 index 0000000..0009a5c --- /dev/null +++ b/src/lib/git-ignore.ts @@ -0,0 +1,21 @@ +export interface GitIgnoreVerboseEvidence { + ignored: boolean; + line?: number; + metadata: string; + pattern: string | null; + source: string | null; +} + +/** Parse one `git check-ignore --verbose` result, including negated matches. */ +export function parseGitIgnoreVerbose(output: string): GitIgnoreVerboseEvidence { + const metadata = output.trim().split("\t", 1)[0] ?? ""; + const match = metadata.match(/^(.*):(\d+):(.*)$/); + const pattern = match?.[3] ?? null; + return { + ignored: metadata.length > 0 && pattern?.startsWith("!") !== true, + ...(match ? { line: Number(match[2]) } : {}), + metadata, + pattern, + source: match?.[1] ?? (metadata || null), + }; +} diff --git a/src/lib/standalone.ts b/src/lib/standalone.ts index 73f5df5..796a223 100644 --- a/src/lib/standalone.ts +++ b/src/lib/standalone.ts @@ -1,6 +1,7 @@ import { access, rmdir } from "fs/promises"; import { dirname, join, relative, resolve } from "path"; import { exec } from "./git.ts"; +import { parseGitIgnoreVerbose } from "./git-ignore.ts"; import { executeHook, resolveScopedLifecycleHooks } from "./hooks.ts"; import type { StandaloneWorkspaceContext } from "./workspace-context.ts"; @@ -115,13 +116,12 @@ export async function inspectStandaloneIgnore( ["check-ignore", "--no-index", "--verbose", destination], context.mainRoot, ); - const metadata = result.stdout.trim().split("\t", 1)[0] ?? ""; - const match = metadata.match(/^(.*):(\d+):(.*)$/); + const evidence = parseGitIgnoreVerbose(result.stdout); return { - ignored: true, - ...(match - ? { line: Number(match[2]), pattern: match[3], source: match[1] } - : { pattern: null, source: metadata || null }), + ignored: evidence.ignored, + ...(evidence.line === undefined ? {} : { line: evidence.line }), + pattern: evidence.pattern, + source: evidence.source, }; } catch { return { ignored: false, pattern: null, source: null }; diff --git a/src/lib/zero-config-bootstrap.ts b/src/lib/zero-config-bootstrap.ts index 328f229..2f95952 100644 --- a/src/lib/zero-config-bootstrap.ts +++ b/src/lib/zero-config-bootstrap.ts @@ -2,6 +2,7 @@ import { access, lstat, mkdir, readFile, rmdir, unlink, writeFile } from "fs/pro import { dirname, isAbsolute, join, resolve } from "path"; import { configExists } from "./config.ts"; import { exec } from "./git.ts"; +import { parseGitIgnoreVerbose } from "./git-ignore.ts"; import { resolveGitMainWorktree } from "./workspace-context.ts"; const RULE = ".worktrees/"; @@ -82,8 +83,11 @@ async function effectiveIgnore( ): Promise<{ ignored: boolean; source?: string }> { try { const result = await exec(["check-ignore", "--no-index", "-v", path], root); - const line = result.stdout.trim(); - return { ignored: line.length > 0, ...(line ? { source: line.split("\t", 1)[0] } : {}) }; + const evidence = parseGitIgnoreVerbose(result.stdout); + return { + ignored: evidence.ignored, + ...(evidence.metadata ? { source: evidence.metadata } : {}), + }; } catch { return { ignored: false }; } diff --git a/tests/integration/init.zero-config.test.ts b/tests/integration/init.zero-config.test.ts index b2e4678..a8a05bc 100644 --- a/tests/integration/init.zero-config.test.ts +++ b/tests/integration/init.zero-config.test.ts @@ -228,6 +228,26 @@ describe("init --zero-config", () => { expect(await readFile(exclude)).toEqual(before); }); + test("treats a tracked negation as exposed and rolls back an ineffective local rule", async () => { + const root = await repository(); + const exclude = join(root, ".git", "info", "exclude"); + const before = await readFile(exclude); + await writeFile(join(root, ".gitignore"), "!/.worktrees/\n!/.worktrees/**\n"); + + const result = await arashi(root, ["init", "--zero-config", "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout).error).toMatchObject({ + code: "ZERO_CONFIG_BOOTSTRAP_FAILED", + details: { + finalState: { localExcludeChanged: false, worktreesDirectoryChanged: false }, + restored: { localExclude: true, worktreesDirectory: true }, + }, + }); + expect(await readFile(exclude)).toEqual(before); + await expect(access(join(root, ".worktrees"))).rejects.toThrow(); + }); + test("preserves CRLF local exclude formatting", async () => { const root = await repository(); const exclude = join(root, ".git", "info", "exclude"); diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index 3dc2dbd..78bc74a 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -68,6 +68,25 @@ describe("standalone lifecycle", () => { await expect(access(join(root, ".worktrees", "feat"))).rejects.toThrow(); }); + test("blocks create when verbose Git ignore evidence is an effective negation", async () => { + const root = await repository(); + await mkdir(join(root, ".worktrees")); + await writeFile(join(root, ".gitignore"), "!/.worktrees/\n!/.worktrees/**\n"); + + const result = await arashi(root, ["create", "feat/exposed", "--dry-run", "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout).error).toMatchObject({ + code: "STANDALONE_DESTINATION_NOT_IGNORED", + details: { + effectiveIgnore: { ignored: false }, + mutation: { branch: false, config: false, ignore: false, worktree: false }, + }, + }); + expect((await run(root, ["git", "branch", "--list", "feat/exposed"])).stdout).toBe(""); + await expect(access(join(root, ".worktrees", "feat", "exposed"))).rejects.toThrow(); + }); + test("create JSON reports the effective ignore source", async () => { const root = await repository(); await arashi(root, ["init", "--zero-config"]); From 6d062782d5c1bd46791503c0e06695b4d4ac59d0 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 21:59:17 -0700 Subject: [PATCH 7/9] fix(remove): complete standalone finalization plans --- src/commands/remove.ts | 12 ++++- src/core/remove.ts | 12 +++++ src/lib/standalone.ts | 53 +++++++++++-------- src/types/remove.ts | 2 +- .../integration/standalone-lifecycle.test.ts | 28 +++++++++- 5 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/commands/remove.ts b/src/commands/remove.ts index c9467c5..07c938b 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -294,6 +294,14 @@ export async function executeRemove( type: "worktree_remove", worktreePath: targetEntry.path, }); + } else if (!options.keepBranches && target.branch) { + summary.operations.push({ + branchName: target.branch, + repository: repositoryName, + status: "pending", + type: "worktree_detach", + worktreePath: targetEntry.path, + }); } if (!options.keepBranches && target.branch) { summary.operations.push({ @@ -392,14 +400,16 @@ export async function executeRemove( summary.operations.push(operation); } try { - await runStandaloneGlobalHooks( + const postHookFailures = await runStandaloneGlobalHooks( workspaceContext, "post-remove", target.branch ?? branchArg, target.path, false, options.json === true, + true, ); + hookFailures.push(...postHookFailures); } catch (error) { hookFailures.push({ hookName: "post-remove", diff --git a/src/core/remove.ts b/src/core/remove.ts index 70ec5cf..a1c6c9b 100644 --- a/src/core/remove.ts +++ b/src/core/remove.ts @@ -397,6 +397,18 @@ export const formatRemovalSummaryHuman = ( } } + if ( + summary.operations.some((op) => op.type === "worktree_detach" && op.status === worktreeStatus) + ) { + lines.push(""); + lines.push(summary.dryRun ? "Planned worktree detaches:" : "Detached worktrees:"); + for (const op of summary.operations) { + if (op.type === "worktree_detach" && op.status === worktreeStatus) { + lines.push(` • ${op.repository}: ${op.worktreePath}`); + } + } + } + if (summary.operations.some((op) => op.type === "branch_delete" && op.status === branchStatus)) { lines.push(""); lines.push(summary.dryRun ? "Planned branch deletions:" : "Deleted branches:"); diff --git a/src/lib/standalone.ts b/src/lib/standalone.ts index 796a223..91c8b13 100644 --- a/src/lib/standalone.ts +++ b/src/lib/standalone.ts @@ -52,35 +52,46 @@ export async function runStandaloneGlobalHooks( worktreePath: string, skipHooks: boolean, quiet = false, -): Promise { - if (skipHooks) return; + continueOnFailure = false, +): Promise<{ hookName: string; message: string }[]> { + if (skipHooks) return []; + const failures: { hookName: string; message: string }[] = []; const hooks = await resolveScopedLifecycleHooks({ hookName, targetRepositories: [context.repository], workspaceRoot: context.mainRoot, }); for (const hook of hooks.filter((candidate) => candidate.scope.startsWith("global-"))) { - const result = await executeHook({ - context: { - hookName, - hookScope: hook.scope, - operationData: { - BRANCH_NAME: branch, - REPO_NAME: context.repository.name, - WORKSPACE_MODE: "standalone", - WORKTREE_PATH: worktreePath, + try { + const result = await executeHook({ + context: { + hookName, + hookScope: hook.scope, + operationData: { + BRANCH_NAME: branch, + REPO_NAME: context.repository.name, + WORKSPACE_MODE: "standalone", + WORKTREE_PATH: worktreePath, + }, + repoPath: context.mainRoot, + sourceScriptPath: hook.sourceScriptPath, + targetRepoName: context.repository.name, + targetRepoPath: context.mainRoot, }, - repoPath: context.mainRoot, - sourceScriptPath: hook.sourceScriptPath, - targetRepoName: context.repository.name, - targetRepoPath: context.mainRoot, - }, - hookName, - quiet, - scriptPath: hook.scriptPath, - }); - if (!result.success) throw new StandaloneHookError(hookName, hook.scriptPath); + hookName, + quiet, + scriptPath: hook.scriptPath, + }); + if (!result.success) throw new StandaloneHookError(hookName, hook.scriptPath); + } catch (error) { + if (!continueOnFailure) throw error; + failures.push({ + hookName, + message: error instanceof Error ? error.message : String(error), + }); + } } + return failures; } export async function standaloneWorktrees(context: StandaloneWorkspaceContext) { diff --git a/src/types/remove.ts b/src/types/remove.ts index 955053c..6bdf587 100644 --- a/src/types/remove.ts +++ b/src/types/remove.ts @@ -57,7 +57,7 @@ export interface WorktreeGrouping { export interface RemovalOperation { /** Operation type */ - type: "worktree_remove" | "branch_delete"; + type: "worktree_remove" | "worktree_detach" | "branch_delete"; /** Repository name */ repository: string; /** Target branch name */ diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index 78bc74a..f5e516f 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -281,6 +281,24 @@ describe("standalone lifecycle", () => { workspaceRoot: canonicalRoot, worktreesBase: join(canonicalRoot, ".worktrees"), }); + const keepWorktree = await arashi( + root, + ["remove", "remove-preview", "--dry-run", "--keep-worktrees", "--json"], + { HOME: home }, + ); + expect(keepWorktree.exitCode).toBe(0); + expect(JSON.parse(keepWorktree.stdout).data).toMatchObject({ + effectiveOptions: { keepBranches: false, keepWorktrees: true }, + operations: expect.arrayContaining([ + expect.objectContaining({ + branchName: "remove-preview", + type: "worktree_detach", + worktreePath: join(canonicalRoot, ".worktrees", "remove-preview"), + }), + expect.objectContaining({ branchName: "remove-preview", type: "branch_delete" }), + ]), + summary: { totalBranches: 1, totalWorktrees: 0 }, + }); await expect(access(marker)).rejects.toThrow(); await expect(access(linked)).resolves.toBeUndefined(); expect((await run(root, ["git", "branch", "--list", "remove-preview"])).stdout).toContain( @@ -851,16 +869,21 @@ describe("standalone lifecycle", () => { await mkdir(hookDirectory, { recursive: true }); await writeFile( join(hookDirectory, "post-remove.sh"), - `#!/bin/sh\nprintf '%s\\n' "$ARASHI_WORKSPACE_MODE:$ARASHI_REPO_NAME:$ARASHI_BRANCH_NAME" > '${record}'\nexit 23\n`, + `#!/bin/sh\nprintf '%s\\n' 'targeted' >> '${record}'\nexit 23\n`, ); await chmod(join(hookDirectory, "post-remove.sh"), 0o755); + await writeFile( + join(home, ".arashi", "hooks", "post-remove.sh"), + `#!/bin/sh\nprintf '%s\\n' 'shared' >> '${record}'\nexit 24\n`, + ); + await chmod(join(home, ".arashi", "hooks", "post-remove.sh"), 0o755); const result = await arashi(root, ["remove", "partial-remove", "--force", "--json"], { HOME: home, }); expect(result.exitCode).not.toBe(0); - expect(await readFile(record, "utf8")).toContain("standalone"); + expect(await readFile(record, "utf8")).toBe("targeted\nshared\n"); expect(JSON.parse(result.stdout).error).toMatchObject({ code: "STANDALONE_REMOVE_PARTIAL_FAILURE", details: { @@ -871,6 +894,7 @@ describe("standalone lifecycle", () => { operationFailures: [], }, }); + expect(JSON.parse(result.stdout).error.details.hookFailures).toHaveLength(2); await expect(access(linked)).rejects.toThrow(); }); }); From 2c52871d7e366debe7e07aee32ce2cecddd74d2c Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 22:09:15 -0700 Subject: [PATCH 8/9] test(cli): pin standalone default branch --- tests/integration/standalone-lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index f5e516f..d7743f9 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -31,7 +31,7 @@ async function arashi(cwd: string, args: string[], env?: Record) async function repository() { const root = await mkdtemp(join(tmpdir(), "arashi-standalone-")); roots.push(root); - await run(root, ["git", "init"]); + await run(root, ["git", "init", "-b", "main"]); await run(root, ["git", "config", "user.email", "test@example.com"]); await run(root, ["git", "config", "user.name", "Test User"]); await writeFile(join(root, "README.md"), "test\n"); From b210b82d20b4b82f78c6a9c1181410e6524f0153 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 22:18:16 -0700 Subject: [PATCH 9/9] test(cli): make workspace fixtures cross-platform --- tests/integration/workspace-context.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/integration/workspace-context.test.ts b/tests/integration/workspace-context.test.ts index 90321fe..eee3d13 100644 --- a/tests/integration/workspace-context.test.ts +++ b/tests/integration/workspace-context.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "vitest"; -import { access, chmod, mkdir, mkdtemp, realpath, rm, writeFile } from "fs/promises"; -import { join } from "path"; +import { access, mkdir, mkdtemp, realpath, rm, writeFile } from "fs/promises"; +import { basename, join } from "path"; import { tmpdir } from "os"; import { spawn } from "../helpers/node-runtime.ts"; import { ConfigParseError, resolveWorkspaceContext } from "../../src/lib/workspace-context.ts"; @@ -51,7 +51,7 @@ describe("resolveWorkspaceContext", () => { config: { repos: {}, reposDir: "./repos", version: "1.0.0", worktreesDir: ".worktrees" }, mainRoot: canonicalRoot, mode: "standalone", - repository: { name: root.split("/").at(-1), path: canonicalRoot }, + repository: { name: basename(canonicalRoot), path: canonicalRoot }, workspaceRoot: canonicalRoot, }); await expect(access(join(root, ".arashi", "config.json"))).rejects.toThrow(); @@ -104,13 +104,9 @@ describe("resolveWorkspaceContext", () => { await mkdir(join(root, ".worktrees")); await mkdir(join(root, ".arashi")); const config = join(root, ".arashi", "config.json"); - await writeFile(config, JSON.stringify({ version: "1.0.0", reposDir: "./repos", repos: {} })); - await chmod(config, 0o000); - try { - await expect(resolveWorkspaceContext(root)).rejects.toBeInstanceOf(ConfigError); - } finally { - await chmod(config, 0o600); - } + await mkdir(config); + + await expect(resolveWorkspaceContext(root)).rejects.toBeInstanceOf(ConfigError); }); test("does not hide invalid configuration at a linked-worktree invocation root", async () => {