diff --git a/README.md b/README.md index f32fc29..477000b 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,8 @@ Arashi currently provides these commands: ## Quick Example ```bash -arashi init +arashi init # repository-local ignore rules (default) +arashi init --ignore-scope tracked # opt in to a shared .gitignore block arashi add git@github.com:your-org/frontend.git arashi add git@github.com:your-org/backend.git arashi create feature-auth-refresh @@ -148,6 +149,25 @@ arashi switch --cd feature-auth-refresh # parent-shell cd when shell integra arashi switch --no-default-launch # bypass configured launch mode defaults once ``` +### Managed Git ignore rules + +Configured workspaces keep `reposDir` and `worktreesDir` effectively ignored. Arashi asks Git +first, so an existing tracked `.gitignore`, repository-local `.git/info/exclude`, or configured +global excludes rule is honored without duplication. Missing safe repository-relative rules use +the common repository's local exclude file by default, including when a command runs in a linked +worktree. + +Use `arashi init --ignore-scope tracked` when the team wants Arashi-owned rules committed in the +workspace `.gitignore`. Use `arashi init --ignore-scope none` for a fully manual workflow; Arashi +will warn about unignored managed paths but will not edit ignore files. Running +`arashi init --ignore-scope local` resets that clone-local preference without recreating an +existing workspace. The explicit `tracked` or `none` preference is stored only in local Git config +as `arashi.ignoreScope`; Arashi never creates or modifies global Git ignore configuration. + +`init`, `pull`, `clone`, `add`, and `create` reconcile the same owned rules before materializing +configured repositories or worktrees. `doctor` reports missing, unsafe, invalid, or stale managed +ignore state without changing it. + ## Workflow Guides Use the docs site workflow guides when you want setup guidance by outcome instead of by individual command. diff --git a/contracts/cli-commands.json b/contracts/cli-commands.json index 80336c0..98f8f44 100644 --- a/contracts/cli-commands.json +++ b/contracts/cli-commands.json @@ -432,6 +432,13 @@ "optional": false, "variadic": false }, + { + "flags": "--ignore-scope ", + "description": "Managed Git ignore scope: local (default), tracked, or none", + "required": true, + "optional": false, + "variadic": false + }, { "flags": "--json", "description": "Output result as JSON", diff --git a/docs/commands/init.md b/docs/commands/init.md new file mode 100644 index 0000000..100352b --- /dev/null +++ b/docs/commands/init.md @@ -0,0 +1,23 @@ +# `arashi init` + +Initialize a configured Arashi workspace, or reconcile the managed Git ignore preference of an +existing workspace. + +```bash +arashi init +arashi init --ignore-scope local +arashi init --ignore-scope tracked +arashi init --ignore-scope none +arashi init --dry-run --json +``` + +`local` is the default and writes missing safe `reposDir` and `worktreesDir` rules to the common +repository's `info/exclude`. `tracked` opts this clone into an Arashi-owned block in the workspace +`.gitignore`. `none` persists a non-mutating preference and reports paths that remain unignored. +Selecting `local` removes the clone-local override. + +Before writing, Arashi uses Git to detect effective tracked, repository-local, and configured +global rules. Existing effective rules are preserved without duplication. Arashi never changes a +global excludes file or global Git configuration, and it skips repository root, absolute, and +parent-traversal paths. `--dry-run` previews the structured plan without changing ignore files or +local preference state. diff --git a/docs/configuration.md b/docs/configuration.md index b4e1016..7ec2b73 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,20 @@ Arashi stores workspace settings in `.arashi/config.json`. +The personal managed-ignore preference is deliberately not stored in that shared file. Safe +configured `reposDir` and `worktreesDir` paths default to repository-local Git excludes. Select a +different clone-local policy with: + +```bash +arashi init --ignore-scope local # default; remove any stored override +arashi init --ignore-scope tracked # maintain an owned block in .gitignore +arashi init --ignore-scope none # report only; manage ignore rules manually +``` + +Git's effective tracked, repository-local, or existing global rule always takes precedence, and +Arashi does not write global Git configuration. Lifecycle commands (`pull`, `clone`, `add`, and +`create`) reuse the stored preference and reconcile before materializing configured paths. + To enable JSON validation and editor autocomplete, include a `$schema` property: ```json diff --git a/src/commands/add.ts b/src/commands/add.ts index ed4e9a9..e32435f 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -18,11 +18,22 @@ import { Command } from "commander"; import { executeClone } from "./clone.ts"; import { confirm as promptConfirm } from "../lib/prompts.ts"; import { rm } from "node:fs/promises"; +import { + reconcileManagedIgnore, + restoreManagedIgnore, + type ManagedIgnoreReconciliation, +} from "../lib/managed-ignore.ts"; +import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; +import { + createJsonErrorEnvelope, + createJsonSuccessEnvelope, + unknownErrorToJsonError, + writeJsonEnvelope, +} from "../lib/json-output.ts"; type RepoConfig = Awaited>["repos"][string]; const ZERO = 0; -const JSON_INDENT = 2; const ERROR_EXIT_CODE = 1; const CANCELLED_EXIT_CODE = 2; @@ -116,6 +127,9 @@ export interface AddCommandOptions { json?: boolean; } +export const shouldTreatFailedCloneAsMaterialized = (destinationExists: boolean): boolean => + destinationExists; + /** * Result of add operation */ @@ -132,6 +146,8 @@ export interface AddCommandResult { setupScriptCreated: boolean; /** Original Git URL that was cloned */ gitUrl: string; + /** Managed ignore reconciliation retained for the materialized repository. */ + managedIgnore: ManagedIgnoreReconciliation; } /** @@ -381,6 +397,9 @@ const executeAdd = async ( workspaceRoot: string, ): Promise => { const operations: RollbackOperation[] = []; + let existingFailedCloneDestinationSurvives = false; + let managedIgnore: ManagedIgnoreReconciliation | undefined = undefined; + const startSpinner = (text: string) => (options.json ? undefined : spinner(text).start()); try { // Step 1: Validate workspace is initialized @@ -394,9 +413,9 @@ const executeAdd = async ( } // Step 2: Parse and validate Git URL - const s1 = spinner("Validating Git URL...").start(); + const s1 = startSpinner("Validating Git URL..."); const urlInfo = parseGitUrl(gitUrl); - s1.succeed("Git URL validated"); + s1?.succeed("Git URL validated"); // Step 3: Determine repository name const repositoryName = options.name || urlInfo.derivedName; @@ -419,14 +438,34 @@ const executeAdd = async ( const reposDir = join(workspaceRoot, config.reposDir); const clonePath = join(reposDir, repositoryName); + managedIgnore = await reconcileManagedIgnore({ + reposDir: config.reposDir, + workspaceRoot, + worktreesDir: config.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json) { + for (const warning of managedIgnore.warnings) { + info(`Warning: ${warning}`); + } + } + // Step 6: Clone repository - const s2 = spinner(`Cloning repository from ${gitUrl}...`).start(); + const s2 = startSpinner(`Cloning repository from ${gitUrl}...`); + const clonePathExistedBefore = await runtime.file(clonePath).exists(); try { await clone(gitUrl, clonePath); operations.push({ path: clonePath, reversible: true, type: "clone" }); - s2.succeed("Repository cloned"); + s2?.succeed("Repository cloned"); } catch (error) { - s2.fail("Clone failed"); + const clonePathExistsAfterFailure = await runtime.file(clonePath).exists(); + if (clonePathExistedBefore) { + existingFailedCloneDestinationSurvives = shouldTreatFailedCloneAsMaterialized( + clonePathExistsAfterFailure, + ); + } else if (clonePathExistsAfterFailure) { + operations.push({ path: clonePath, reversible: true, type: "clone" }); + } + s2?.fail("Clone failed"); throw new AddCommandError( `Git clone operation failed: ${(error as Error).message}`, AddCommandErrorCode.CLONE_FAILED, @@ -435,24 +474,24 @@ const executeAdd = async ( } // Step 7: Detect default branch - const s3 = spinner("Detecting default branch...").start(); + const s3 = startSpinner("Detecting default branch..."); const defaultBranch = await detectDefaultBranchOrThrow(clonePath, gitUrl).catch((error) => { - s3.fail("Branch detection failed"); + s3?.fail("Branch detection failed"); throw error; }); - s3.succeed(`Detected default branch: ${defaultBranch}`); + s3?.succeed(`Detected default branch: ${defaultBranch}`); // Step 8: Detect setup script - const s4 = spinner("Checking for setup script...").start(); + const s4 = startSpinner("Checking for setup script..."); const setupScript = await detectSetupScript(clonePath); if (setupScript) { - s4.succeed(`Found setup script: ${basename(setupScript)}`); + s4?.succeed(`Found setup script: ${basename(setupScript)}`); } else { - s4.info("No setup script found"); + s4?.info("No setup script found"); } // Step 9: Update configuration - const s5 = spinner("Updating configuration...").start(); + const s5 = startSpinner("Updating configuration..."); try { const repoConfig: RepoConfig = { gitUrl: urlInfo.url, @@ -461,9 +500,9 @@ const executeAdd = async ( config.repos[repositoryName] = repoConfig; await saveConfig(workspaceRoot, config); - s5.succeed("Configuration updated"); + s5?.succeed("Configuration updated"); } catch (error) { - s5.fail("Configuration update failed"); + s5?.fail("Configuration update failed"); throw new AddCommandError( `Failed to update configuration file: ${(error as Error).message}`, AddCommandErrorCode.CONFIG_UPDATE_FAILED, @@ -476,11 +515,14 @@ const executeAdd = async ( clonePath, defaultBranch, gitUrl, + managedIgnore, repositoryName, setupScript, setupScriptCreated: false, }; } catch (error) { + let managedIgnoreRestoreError: string | undefined = undefined; + let materializedStateSurvives = existingFailedCloneDestinationSurvives; // Rollback operations in reverse order const rollbackOperations = [...operations]; rollbackOperations.reverse(); @@ -491,11 +533,33 @@ const executeAdd = async ( await rm(operation.path, { force: true, recursive: true }); } } catch (cleanupError) { - info(`Warning: Failed to clean up ${operation.path}: ${(cleanupError as Error).message}`); - info(`Please manually remove: rm -rf ${operation.path}`); + materializedStateSurvives = true; + if (!options.json) { + info(`Warning: Failed to clean up ${operation.path}: ${(cleanupError as Error).message}`); + info(`Please manually remove: rm -rf ${operation.path}`); + } + } + if (operation.type === "clone" && (await runtime.file(operation.path).exists())) { + materializedStateSurvives = true; } } + if (managedIgnore?.changed && !materializedStateSurvives) { + try { + await restoreManagedIgnore(managedIgnore); + } catch (restoreError) { + managedIgnoreRestoreError = (restoreError as Error).message; + } + } + if (error instanceof AddCommandError && managedIgnore) { + throw new AddCommandError(error.message, error.code, { + ...error.context, + managedIgnore, + materializedStateSurvives, + ...(managedIgnoreRestoreError ? { managedIgnoreRestoreError } : {}), + }); + } + // Re-throw original error throw error; } @@ -570,24 +634,20 @@ export function createCommand(): Command { const result = await executeAdd(gitUrl, options, workspaceRoot); if (options.json) { - console.log( - JSON.stringify( - { - repository: { - defaultBranch: result.defaultBranch, - gitUrl: result.gitUrl, - name: result.repositoryName, - path: result.clonePath.replace(workspaceRoot, "."), - setupScript: result.setupScript - ? result.setupScript.replace(workspaceRoot, ".") - : null, - setupScriptCreated: result.setupScriptCreated, - }, - success: true, + writeJsonEnvelope( + createJsonSuccessEnvelope("add", { + managedIgnore: result.managedIgnore, + repository: { + defaultBranch: result.defaultBranch, + gitUrl: result.gitUrl, + name: result.repositoryName, + path: result.clonePath.replace(workspaceRoot, "."), + setupScript: result.setupScript + ? result.setupScript.replace(workspaceRoot, ".") + : null, + setupScriptCreated: result.setupScriptCreated, }, - null, - 2, - ), + }), ); } else { displaySuccess(result, workspaceRoot); @@ -597,19 +657,12 @@ export function createCommand(): Command { } catch (error) { if (error instanceof AddCommandError) { if (options.json) { - console.log( - JSON.stringify( - { - error: { - code: error.code, - details: error.context, - message: error.message, - }, - success: false, - }, - null, - JSON_INDENT, - ), + writeJsonEnvelope( + createJsonErrorEnvelope("add", { + code: error.code, + details: error.context, + message: error.message, + }), ); } else { displayError(error); @@ -617,21 +670,10 @@ export function createCommand(): Command { } process.exit(CANCELLED_EXIT_CODE); } else { - logError(`\nUnexpected error: ${(error as Error).message}`); if (options.json) { - console.log( - JSON.stringify( - { - error: { - code: "UNKNOWN_ERROR", - message: (error as Error).message, - }, - success: false, - }, - null, - JSON_INDENT, - ), - ); + writeJsonEnvelope(createJsonErrorEnvelope("add", unknownErrorToJsonError(error))); + } else { + logError(`\nUnexpected error: ${(error as Error).message}`); } process.exit(ERROR_EXIT_CODE); } diff --git a/src/commands/clone.ts b/src/commands/clone.ts index fe9155b..44ea132 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -29,6 +29,12 @@ import { import { Command } from "commander"; import { removeDir } from "../lib/filesystem.ts"; import { stat } from "fs/promises"; +import { + reconcileManagedIgnore, + restoreManagedIgnore, + type ManagedIgnoreReconciliation, +} from "../lib/managed-ignore.ts"; +import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; interface Choice { value: T; @@ -59,6 +65,7 @@ export interface CloneExecutionResult { cloned: string[]; failed: { name: string; reason: string }[]; skipped: string[]; + managedIgnore?: ManagedIgnoreReconciliation; } interface CloneCommandDependencies { @@ -308,10 +315,22 @@ export async function executeClone( const cloned: string[] = []; const failed: { name: string; reason: string }[] = []; + let residualMaterializedState = false; const skipped = missingWithUrls .map((repository) => repository.name) .filter((name) => !selectedRepositories.some((repository) => repository.name === name)); + const managedIgnore = await reconcileManagedIgnore({ + reposDir: config.reposDir, + workspaceRoot, + worktreesDir: config.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json) { + for (const warning of managedIgnore.warnings) { + warn(warning); + } + } + for (const repository of selectedRepositories) { const rawGitUrl = repository.config.gitUrl; if (rawGitUrl) { @@ -341,7 +360,18 @@ export async function executeClone( cloneSpinner?.succeed(`Cloned ${repository.name}`); cloned.push(repository.name); } catch (error) { - const reason = error instanceof Error ? error.message : String(error); + let reason = error instanceof Error ? error.message : String(error); + if (await exists(repository.path)) { + try { + await deleteDirectory(repository.path); + } catch (cleanupError) { + residualMaterializedState = true; + reason += `; cleanup failed: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }`; + } + } + residualMaterializedState = residualMaterializedState || (await exists(repository.path)); cloneSpinner?.fail(`Failed to clone ${repository.name}`); failed.push({ name: repository.name, @@ -356,8 +386,19 @@ export async function executeClone( } } - if (configUpdated) { - await writeConfig(workspaceRoot, config); + try { + if (configUpdated) { + await writeConfig(workspaceRoot, config); + } + } catch (error) { + if (cloned.length === ZERO && !residualMaterializedState && managedIgnore.changed) { + await restoreManagedIgnore(managedIgnore); + } + throw error; + } + + if (cloned.length === ZERO && !residualMaterializedState && managedIgnore.changed) { + await restoreManagedIgnore(managedIgnore); } if (failed.length > 0) { @@ -379,6 +420,7 @@ export async function executeClone( return { cloned, failed, + managedIgnore, skipped, status, }; diff --git a/src/commands/create.ts b/src/commands/create.ts index 4a2bc25..c3bafcd 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -15,7 +15,10 @@ import { applyRepositoryFilter, createCoordinatedWorktrees, } from "../core/worktree.ts"; -import { basename, resolve } from "path"; +import { basename, dirname, join, resolve } from "path"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { buildDirtyGuidance, buildMovePlan, @@ -42,6 +45,12 @@ import { } from "../lib/repo-filter.ts"; import { launchSwitchTarget } from "../lib/switch-launcher.ts"; import { resolveDefaultWithPrecedence } from "../lib/default-resolution.ts"; +import { + reconcileManagedIgnore, + restoreManagedIgnore, + type ManagedIgnoreReconciliation, +} from "../lib/managed-ignore.ts"; +import { DEFAULT_WORKTREES_DIR } from "../lib/worktree-location.ts"; type LoadedConfig = Awaited>; type Config = LoadedConfig["config"]; @@ -55,6 +64,8 @@ type LaunchMode = "auto" | "sesh"; type LaunchSwitchResult = Awaited>; type OperationSummary = Awaited>; type RepositoryResult = OperationSummary["repositoryResults"][number]; + +const temporaryManagedIgnoreWorktrees = new Map(); type SwitchProcessRunner = NonNullable< NonNullable[2]>["runProcess"] >; @@ -154,12 +165,14 @@ interface CreateSummaryJsonOptions { branchName: string; dirtyWorkspaceGuidance?: ReturnType; moveSummary?: MoveSummary | null; + managedIgnore?: ManagedIgnoreReconciliation; summary: OperationSummary; } const createSummaryJsonData = ({ branchName, dirtyWorkspaceGuidance, + managedIgnore, moveSummary, summary, }: CreateSummaryJsonOptions) => ({ @@ -169,6 +182,7 @@ const createSummaryJsonData = ({ errorSummary: summary.errorSummary, failureCount: summary.failureCount, hookOutcomes: summary.hookOutcomes, + managedIgnore, moveSummary, nextSteps: summary.nextSteps, repositories: summary.repositoryResults.map((result) => ({ @@ -252,12 +266,16 @@ export interface ResolvedCreateDefaults { export interface CreateCommandDependencies { resolveCreateInvocationContext?: (invocationPath?: string) => Promise; + resolveManagedIgnoreWorkspaceRoot?: (context: CreateInvocationContext) => Promise; loadConfigWithFallback?: typeof loadConfigWithFallback; discoverRepositories?: typeof discoverRepositories; isGitRepository?: (path: string) => Promise; resolveCurrentBranch?: (path: string) => Promise; applyRepositoryFilter?: typeof applyRepositoryFilter; createCoordinatedWorktrees?: typeof createCoordinatedWorktrees; + reconcileManagedIgnore?: typeof reconcileManagedIgnore; + restoreManagedIgnore?: typeof restoreManagedIgnore; + pathExists?: (path: string) => boolean; launchSwitchTarget?: ( candidate: SwitchCandidate, options: { sesh?: boolean }, @@ -311,6 +329,67 @@ export async function resolveCreateInvocationContext( }; } +export async function resolveManagedIgnoreWorkspaceRoot( + context: CreateInvocationContext, +): Promise { + if (context.repositoryType !== "bare") { + return context.workspaceRoot; + } + + const result = await exec(["worktree", "list", "--porcelain"], context.executionPath); + const worktreePaths = result.stdout + .split(/\r?\n/) + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length).trim()); + for (const path of worktreePaths) { + try { + const repositoryType = await exec(["rev-parse", "--is-bare-repository"], path); + if (repositoryType.stdout.trim() === "false") { + return path; + } + } catch { + // Ignore stale worktree-list entries and continue looking for a usable work tree. + } + } + const temporaryParent = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-worktree-")); + const temporaryWorktree = join(temporaryParent, "worktree"); + try { + const branchRefs = await exec( + ["for-each-ref", "--format=%(refname)", "--sort=-committerdate", "refs/heads"], + context.executionPath, + ); + const sourceRef = branchRefs.stdout.split(/\r?\n/).find((line) => line.trim().length > 0); + if (!sourceRef) { + throw new CreateSetupError( + "Managed ignore reconciliation requires at least one committed branch in the bare repository.", + ); + } + await exec( + ["worktree", "add", "--detach", temporaryWorktree, sourceRef.trim()], + context.executionPath, + ); + } catch (error) { + await rm(temporaryParent, { force: true, recursive: true }); + throw error; + } + temporaryManagedIgnoreWorktrees.set(temporaryWorktree, context.executionPath); + return temporaryWorktree; +} + +const releaseManagedIgnoreWorkspaceRoot = async (workspaceRoot: string): Promise => { + const bareRepository = temporaryManagedIgnoreWorktrees.get(workspaceRoot); + if (!bareRepository) { + return false; + } + temporaryManagedIgnoreWorktrees.delete(workspaceRoot); + try { + await exec(["worktree", "remove", "--force", workspaceRoot], bareRepository); + } finally { + await rm(dirname(workspaceRoot), { force: true, recursive: true }); + } + return true; +}; + const isGitRepository = async (path: string): Promise => { try { await exec(["rev-parse", "--git-dir"], path); @@ -620,6 +699,8 @@ export async function executeCreate( const resolveInvocationContext = deps.resolveCreateInvocationContext ?? resolveCreateInvocationContext; + const resolveIgnoreWorkspaceRoot = + deps.resolveManagedIgnoreWorkspaceRoot ?? resolveManagedIgnoreWorkspaceRoot; const loadWorkspaceConfig = deps.loadConfigWithFallback ?? loadConfigWithFallback; const discoverWorkspaceRepositories = deps.discoverRepositories ?? discoverRepositories; const detectGitRepository = deps.isGitRepository ?? isGitRepository; @@ -631,6 +712,9 @@ export async function executeCreate( }); const filterRepositories = deps.applyRepositoryFilter ?? applyRepositoryFilter; const runCreate = deps.createCoordinatedWorktrees ?? createCoordinatedWorktrees; + const reconcileIgnore = deps.reconcileManagedIgnore ?? reconcileManagedIgnore; + const restoreIgnore = deps.restoreManagedIgnore ?? restoreManagedIgnore; + const pathExists = deps.pathExists ?? existsSync; // 1. Resolve invocation context and load configuration const context = await resolveInvocationContext(); @@ -807,8 +891,45 @@ export async function executeCreate( workspaceRoot: context.workspaceRoot, }; + const managedIgnoreWorkspaceRoot = await resolveIgnoreWorkspaceRoot(context); + const temporaryIgnoreWorkspace = temporaryManagedIgnoreWorktrees.has(managedIgnoreWorkspaceRoot); + let managedIgnore: ManagedIgnoreReconciliation; + try { + managedIgnore = await reconcileIgnore({ + dryRun: options.dryRun, + reposDir: arashiConfig.reposDir, + workspaceRoot: managedIgnoreWorkspaceRoot, + worktreesDir: arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if ( + temporaryIgnoreWorkspace && + managedIgnore.targetType === "tracked" && + managedIgnore.attempted + ) { + if (managedIgnore.changed) { + await restoreIgnore(managedIgnore); + } + throw new CreateSetupError( + "Tracked managed-ignore changes from a bare repository require an existing linked worktree. Run arashi init from a checked-out worktree first.", + ); + } + } finally { + await releaseManagedIgnoreWorkspaceRoot(managedIgnoreWorkspaceRoot); + } + if (!options.json) { + for (const warning of managedIgnore.warnings) { + warn(warning); + } + } + // 6. Execute coordinated worktree creation const summary = await runCreate(branchName, selectedRepos, worktreeOptions); + const residualWorktrees = summary.repositoryResults.some( + (result) => Boolean(result.worktreePath) && pathExists(result.worktreePath as string), + ); + if (summary.rolledBack && !residualWorktrees && managedIgnore.changed) { + await restoreIgnore(managedIgnore); + } const dirtyGuidanceContext = options.dryRun ? null : await resolvePostCreateDirtyGuidance(context, arashiConfig, branchName); @@ -825,7 +946,13 @@ export async function executeCreate( writeJsonEnvelope( createJsonSuccessEnvelope( "create", - createSummaryJsonData({ branchName, dirtyWorkspaceGuidance, moveSummary, summary }), + createSummaryJsonData({ + branchName, + dirtyWorkspaceGuidance, + managedIgnore, + moveSummary, + summary, + }), ), ); if (summary.rolledBack || summary.failureCount > ZERO) { diff --git a/src/commands/init.ts b/src/commands/init.ts index 3fc3283..e95e008 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -11,11 +11,11 @@ import { DEFAULT_CONFIG_SCHEMA_URL, configExists, getConfigPath, + loadConfig, saveConfig, } from "../lib/config.ts"; import { DEFAULT_WORKTREES_DIR, - DEFAULT_WORKTREES_GITIGNORE_ENTRY, WorktreeLocationValidationError, normalizeWorktreesDir, } from "../lib/worktree-location.ts"; @@ -34,7 +34,6 @@ import { createJsonErrorEnvelope, createJsonSuccessEnvelope, unknownErrorToJsonError, - unsupportedJsonModeError, writeJsonEnvelope, } from "../lib/json-output.ts"; import { info, error as logError, success, warn } from "../lib/logger.ts"; @@ -42,6 +41,13 @@ import { isAbsolute, join, relative, resolve } from "path"; import { Command } from "commander"; import { discoverRepositories } from "../core/repository.ts"; import { exec as gitExec } from "../lib/git.ts"; +import { + classifyManagedPaths, + reconcileManagedIgnore, + restoreManagedIgnore, + type ManagedIgnoreReconciliation, + type ManagedIgnoreScope, +} from "../lib/managed-ignore.ts"; type Config = Parameters[1]; type RepoConfig = Config["repos"][string]; @@ -54,6 +60,73 @@ const JSON_INDENT = 2; const PATH_MAX_LENGTH = 4096; const EXISTING_CONFIG_WARNING = "\n⚠ Warning: Existing configuration will be backed up"; +const previewBootstrapManagedIgnore = ( + workspaceRoot: string, + reposDir: string, + worktreesDir: string, + requestedScope?: string, +): ManagedIgnoreReconciliation => { + const validScopes = new Set(["local", "tracked", "none"]); + const scope = (requestedScope ?? "local") as ManagedIgnoreScope; + if (!validScopes.has(scope)) { + throw new Error("Invalid ignore scope. Expected one of: local, tracked, none."); + } + const classifications = classifyManagedPaths([reposDir, worktreesDir]); + const plannedRules = + scope === "none" + ? [] + : classifications.flatMap((path) => (path.safety === "safe" ? [path.rule] : [])); + const localExcludePath = join(workspaceRoot, ".git", "info", "exclude"); + const trackedIgnorePath = join(workspaceRoot, ".gitignore"); + const targetType = scope === "none" ? undefined : scope; + const targetPath = + targetType === "local" + ? localExcludePath + : targetType === "tracked" + ? trackedIgnorePath + : undefined; + const paths = classifications.map((path) => + path.safety === "safe" + ? { + input: path.input, + rule: path.rule, + safety: "safe" as const, + status: scope === "none" ? ("unignored" as const) : ("planned" as const), + } + : { + input: path.input, + safety: "unsafe" as const, + safetyReason: path.reason, + status: "unsafe" as const, + }, + ); + + return { + appliedRules: [], + attempted: plannedRules.length > 0 || requestedScope !== undefined, + changed: false, + fileChanges: { local: false, preference: false, tracked: false }, + localExcludePath, + paths, + plannedRules, + restored: false, + scope, + staleRules: [], + storedPreference: null, + targetPath, + targetType, + trackedIgnorePath, + warnings: + scope === "none" + ? paths.flatMap((path) => + path.rule + ? [`Managed path '${path.rule}' remains unignored because scope is none.`] + : [], + ) + : [], + }; +}; + // ============================================================================ // Data Types // ============================================================================ @@ -76,6 +149,12 @@ interface InitOptions { /** Verbose output - show detailed information during initialization */ verbose?: boolean; + + /** Managed Git ignore destination */ + ignoreScope?: string; + + /** Suppress human output for JSON mode */ + quiet?: boolean; } interface InitResult { @@ -102,6 +181,18 @@ interface InitResult { /** Resolved workspace root used for initialization */ workspaceRoot?: string; + + /** Managed Git ignore inspection and final reconciliation state */ + managedIgnore?: ManagedIgnoreReconciliation; + + /** Structured managed-ignore failure details for JSON automation */ + managedIgnoreFailure?: ReturnType; + + /** Structured incomplete-rollback details for JSON automation */ + rollbackFailure?: ReturnType; + + /** Whether only an existing workspace preference was reconciled */ + preferenceOnly?: boolean; } interface InitDependencies { @@ -117,6 +208,9 @@ interface InitDependencies { /** Override confirmation prompt implementation for tests */ promptConfirm?: (message: string, defaultValue?: boolean) => Promise>; + /** Override managed-ignore restoration for rollback tests */ + restoreManagedIgnore?: typeof restoreManagedIgnore; + /** Override stdin tty detection for tests */ stdinIsTTY?: boolean; } @@ -129,7 +223,7 @@ interface HookTemplate { content: string; } -type OperationType = "CREATE_DIR" | "WRITE_FILE" | "MODIFY_FILE" | "BACKUP_FILE"; +type OperationType = "CREATE_DIR" | "WRITE_FILE" | "MODIFY_FILE" | "BACKUP_FILE" | "MANAGED_IGNORE"; interface Operation { /** Type of operation performed */ @@ -143,6 +237,9 @@ interface Operation { /** Rollback function */ rollback: () => Promise; + + /** Final-state guard for rollbacks that would remove required coverage */ + shouldRollback?: () => Promise; } interface InitResolution { @@ -174,6 +271,17 @@ const ExitCode = { const operations: Operation[] = []; +class InitRollbackError extends Error { + readonly code = "INIT_ROLLBACK_FAILED"; + readonly details: { failures: Array<{ message: string; path: string }> }; + + constructor(failures: Array<{ message: string; path: string }>) { + super(`Initialization rollback was incomplete for ${failures.length} operation(s).`); + this.name = "InitRollbackError"; + this.details = { failures }; + } +} + /** * Add an operation to the rollback stack */ @@ -184,22 +292,42 @@ const addOperation = (operation: Operation): void => { /** * Execute rollback of all tracked operations in LIFO order */ -const executeRollback = async (): Promise => { - info("\nRolling back changes..."); +const executeRollback = async (quiet = false): Promise => { + if (!quiet) { + info("\nRolling back changes..."); + } const reversedOps = [...operations]; reversedOps.reverse(); + const failures: Array<{ message: string; path: string }> = []; for (const op of reversedOps) { try { + if (op.shouldRollback && !(await op.shouldRollback())) { + if (!quiet) { + info(` • Retained: ${op.path} (managed state still exists)`); + } + continue; + } await op.rollback(); - info(` • Rolled back: ${op.path}`); + if (!quiet) { + info(` • Rolled back: ${op.path}`); + } } catch (error) { - warn(` • Failed to rollback: ${op.path} - ${(error as Error).message}`); + failures.push({ + message: error instanceof Error ? error.message : String(error), + path: op.path, + }); + if (!quiet) { + warn(` • Failed to rollback: ${op.path} - ${(error as Error).message}`); + } } } operations.length = ZERO; + if (failures.length > ZERO) { + throw new InitRollbackError(failures); + } }; // ============================================================================ @@ -321,9 +449,9 @@ const resolveInitRoot = async ( if (options.dryRun) { if (bootstrapTarget !== ".") { - logDryRun("CREATE_DIR", workspaceRoot); + logDryRun("CREATE_DIR", workspaceRoot, options); } - logDryRun("GIT_INIT", workspaceRoot); + logDryRun("GIT_INIT", workspaceRoot, options); } else { logVerbose(`Bootstrapping git repository at: ${workspaceRoot}`, options); @@ -377,7 +505,10 @@ const logVerbose = (message: string, options: InitOptions): void => { /** * Log dry-run action */ -const logDryRun = (action: string, details: string): void => { +const logDryRun = (action: string, details: string, options: InitOptions): void => { + if (options.quiet) { + return; + } console.log(`[DRY RUN] ${action}: ${details}`); }; @@ -588,113 +719,6 @@ const writeHookTemplates = async (hooksDir: string): Promise => { } }; -// ============================================================================ -// Gitignore Helper -// ============================================================================ - -/** - * Update .gitignore to exclude managed directories (idempotent) - */ -const normalizeGitignorePattern = (directoryPath: string): string => { - let pattern = directoryPath.replace(/^\.\//, ""); - if (!pattern.endsWith("/")) { - pattern += "/"; - } - return pattern; -}; - -const hasGitignorePattern = (content: string, pattern: string): boolean => { - const alternate = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; - return content - .split("\n") - .map((line) => line.trim()) - .some((line) => line === pattern || line === alternate); -}; - -const getManagedWorktreesGitignorePattern = (worktreesDir: string): string | undefined => { - const normalizedWorktreesDir = normalizeWorktreesDir(worktreesDir); - - if ( - normalizedWorktreesDir === "." || - normalizedWorktreesDir === ".." || - normalizedWorktreesDir.startsWith("../") - ) { - return undefined; - } - - if (normalizedWorktreesDir === DEFAULT_WORKTREES_DIR) { - return DEFAULT_WORKTREES_GITIGNORE_ENTRY; - } - - return normalizeGitignorePattern(normalizedWorktreesDir); -}; - -const getManagedGitignorePatterns = (reposDir: string, worktreesDir: string): string[] => { - const patterns = [normalizeGitignorePattern(reposDir)]; - const worktreesPattern = getManagedWorktreesGitignorePattern(worktreesDir); - if (worktreesPattern) { - patterns.push(worktreesPattern); - } - - return patterns; -}; - -const updateGitignore = async ( - cwd: string, - reposDir: string, - worktreesDir: string, -): Promise => { - const gitignorePath = join(cwd, ".gitignore"); - const patterns = getManagedGitignorePatterns(reposDir, worktreesDir); - - let content = ""; - let originalContent: string | undefined = undefined; - - if (await fileExists(gitignorePath)) { - originalContent = await readTextFile(gitignorePath); - content = originalContent; - } - - const missingPatterns = patterns.filter((pattern) => !hasGitignorePattern(content, pattern)); - if (missingPatterns.length === 0) { - return; - } - - if (content && !content.endsWith("\n")) { - content += "\n"; - } - - content += "\n# Arashi managed repositories\n"; - for (const pattern of missingPatterns) { - content += `${pattern}\n`; - } - - await writeTextFile(gitignorePath, content); - - if (originalContent === undefined) { - addOperation({ - path: gitignorePath, - rollback: async () => { - const file = runtime.file(gitignorePath); - if (await file.exists()) { - await runtime.write(gitignorePath, ""); - await removeDir(gitignorePath); - } - }, - type: "WRITE_FILE", - }); - } else { - addOperation({ - originalContent, - path: gitignorePath, - rollback: async () => { - await writeTextFile(gitignorePath, originalContent); - }, - type: "MODIFY_FILE", - }); - } -}; - // ============================================================================ // Main Command Logic // ============================================================================ @@ -708,11 +732,12 @@ export const executeInit = async ( ): Promise => { const startTime = Date.now(); const cwd = deps.cwd ?? process.cwd(); + const restoreIgnore = deps.restoreManagedIgnore ?? restoreManagedIgnore; operations.length = ZERO; // Dry-run header - if (options.dryRun) { + if (options.dryRun && !options.quiet) { console.log("=== DRY RUN MODE ==="); console.log("No changes will be made to the filesystem.\n"); } @@ -727,7 +752,35 @@ export const executeInit = async ( // 2. Check if already initialized (without --force) logVerbose("Checking for existing Arashi configuration...", options); - if (!options.force && (await configExists(workspaceRoot))) { + const hasExistingConfig = await configExists(workspaceRoot); + if ( + !options.force && + hasExistingConfig && + options.ignoreScope !== undefined && + options.reposDir === undefined && + options.worktreesDir === undefined + ) { + const existingConfig = await loadConfig(workspaceRoot); + const managedIgnore = await reconcileManagedIgnore({ + dryRun: options.dryRun, + reposDir: existingConfig.reposDir, + requestedScope: options.ignoreScope, + workspaceRoot, + worktreesDir: existingConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + return { + configPath: getConfigPath(workspaceRoot), + discoveredCount: Object.keys(existingConfig.repos).length, + exitCode: ExitCode.SUCCESS, + hooksPath: join(workspaceRoot, ".arashi", "hooks"), + managedIgnore, + preferenceOnly: true, + reposPath: resolve(workspaceRoot, existingConfig.reposDir), + success: true, + workspaceRoot, + }; + } + if (!options.force && hasExistingConfig) { const existingConfigPath = getConfigPath(workspaceRoot); return { error: `Arashi configuration already exists at: ${existingConfigPath}`, @@ -749,7 +802,7 @@ export const executeInit = async ( const backupPath = `${existingConfigPath}.backup-${timestamp}`; if (options.dryRun) { - logDryRun("BACKUP", `${existingConfigPath} → ${backupPath}`); + logDryRun("BACKUP", `${existingConfigPath} → ${backupPath}`, options); } else { warn(EXISTING_CONFIG_WARNING); info(`Backing up: ${existingConfigPath} → ${backupPath}\n`); @@ -769,7 +822,7 @@ export const executeInit = async ( if (!isValidPath(reposDir)) { if (initRoot.bootstrapped && !options.dryRun) { - await executeRollback(); + await executeRollback(options.quiet); } return { @@ -786,7 +839,7 @@ export const executeInit = async ( const rawWorktreesDir = options.worktreesDir; if (rawWorktreesDir !== undefined && !isValidPath(rawWorktreesDir)) { if (initRoot.bootstrapped && !options.dryRun) { - await executeRollback(); + await executeRollback(options.quiet); } return { @@ -803,7 +856,7 @@ export const executeInit = async ( } catch (error) { if (error instanceof WorktreeLocationValidationError) { if (initRoot.bootstrapped && !options.dryRun) { - await executeRollback(); + await executeRollback(options.quiet); } return { @@ -818,11 +871,44 @@ export const executeInit = async ( } logVerbose(`Resolved worktrees directory: ${resolve(workspaceRoot, worktreesDir)}`, options); + // Reconcile before any managed directories are materialized. + logVerbose("Reconciling managed Git ignore rules...", options); + const managedIgnore = + options.dryRun && initRoot.bootstrapped + ? previewBootstrapManagedIgnore(workspaceRoot, reposDir, worktreesDir, options.ignoreScope) + : await reconcileManagedIgnore({ + dryRun: options.dryRun, + reposDir, + requestedScope: options.ignoreScope, + workspaceRoot, + worktreesDir, + }); + if (options.dryRun && managedIgnore.targetPath) { + logDryRun( + "UPDATE_FILE", + `${managedIgnore.targetPath} (add: ${managedIgnore.plannedRules.join(", ")})`, + options, + ); + } + logVerbose("✓ Managed Git ignore rules reconciled", options); + if (!options.dryRun && managedIgnore.attempted) { + addOperation({ + path: managedIgnore.targetPath ?? "clone-local arashi.ignoreScope", + rollback: async () => { + await restoreIgnore(managedIgnore); + }, + shouldRollback: async () => + !(await fileExists(absoluteReposPath)) && + !(await fileExists(resolve(workspaceRoot, worktreesDir))), + type: "MANAGED_IGNORE", + }); + } + // 5. Create .arashi directory const arashiDir = join(workspaceRoot, ".arashi"); if (options.dryRun) { - logDryRun("CREATE_DIR", arashiDir); + logDryRun("CREATE_DIR", arashiDir, options); } else { logVerbose(`Creating .arashi directory: ${arashiDir}`, options); try { @@ -836,6 +922,7 @@ export const executeInit = async ( }); logVerbose("✓ .arashi directory created", options); } catch (error) { + await executeRollback(options.quiet); if (error instanceof PermissionError) { return { error: `Permission denied creating directory: ${arashiDir}`, @@ -852,7 +939,7 @@ export const executeInit = async ( const hooksDir = join(arashiDir, "hooks"); if (options.dryRun) { - logDryRun("CREATE_DIR", hooksDir); + logDryRun("CREATE_DIR", hooksDir, options); } else { logVerbose(`Creating hooks directory: ${hooksDir}`, options); try { @@ -866,7 +953,7 @@ export const executeInit = async ( }); logVerbose("✓ Hooks directory created", options); } catch (error) { - await executeRollback(); + await executeRollback(options.quiet); if (error instanceof PermissionError) { return { error: `Permission denied creating hooks directory: ${hooksDir}`, @@ -883,7 +970,7 @@ export const executeInit = async ( if (options.dryRun) { for (const template of HOOK_TEMPLATES) { const templatePath = join(hooksDir, template.filename); - logDryRun("WRITE_FILE", `${templatePath} (${template.content.length} bytes)`); + logDryRun("WRITE_FILE", `${templatePath} (${template.content.length} bytes)`, options); } } else { logVerbose(`Writing ${HOOK_TEMPLATES.length} hook templates...`, options); @@ -891,7 +978,7 @@ export const executeInit = async ( await writeHookTemplates(hooksDir); logVerbose("✓ Hook templates written", options); } catch (error) { - await executeRollback(); + await executeRollback(options.quiet); if (error instanceof PermissionError || error instanceof DiskFullError) { return { error: `Failed to write hook templates: ${(error as Error).message}`, @@ -907,7 +994,7 @@ export const executeInit = async ( // 8. Create repos directory if (options.dryRun) { - logDryRun("CREATE_DIR", absoluteReposPath); + logDryRun("CREATE_DIR", absoluteReposPath, options); } else { logVerbose(`Creating repos directory: ${absoluteReposPath}`, options); try { @@ -921,7 +1008,7 @@ export const executeInit = async ( }); logVerbose("✓ Repos directory created", options); } catch (error) { - await executeRollback(); + await executeRollback(options.quiet); if (error instanceof PermissionError) { return { error: `Permission denied creating repos directory: ${absoluteReposPath}`, @@ -948,7 +1035,7 @@ export const executeInit = async ( if (options.noDiscover) { logVerbose("Skipping repository discovery (--no-discover)", options); } else if (options.dryRun) { - logDryRun("DISCOVER", `Scan ${reposDir} for git repositories`); + logDryRun("DISCOVER", `Scan ${reposDir} for git repositories`, options); discoveredCount = 0; // Can't discover in dry-run mode } else { logVerbose(`Discovering repositories in: ${reposDir}`, options); @@ -958,7 +1045,7 @@ export const executeInit = async ( logVerbose(`✓ Found ${discoveredCount} repositories`, options); collectDiscoveredRepos(discoveredRepos, options, discoveryResult.repositories); } catch (error) { - await executeRollback(); + await executeRollback(options.quiet); return { error: `Repository discovery failed: ${(error as Error).message}`, exitCode: ExitCode.DISCOVERY_FAILED, @@ -979,9 +1066,11 @@ export const executeInit = async ( const configPath = getConfigPath(workspaceRoot); if (options.dryRun) { - logDryRun("WRITE_FILE", `${configPath}`); - console.log("\nConfiguration preview:"); - console.log(JSON.stringify(arashiConfig, null, JSON_INDENT)); + logDryRun("WRITE_FILE", `${configPath}`, options); + if (!options.quiet) { + console.log("\nConfiguration preview:"); + console.log(JSON.stringify(arashiConfig, null, JSON_INDENT)); + } } else { logVerbose("Writing configuration file...", options); try { @@ -1000,7 +1089,7 @@ export const executeInit = async ( }); logVerbose("✓ Configuration written", options); } catch (error) { - await executeRollback(); + await executeRollback(options.quiet); if (error instanceof PermissionError || error instanceof DiskFullError) { return { error: `Failed to write configuration: ${(error as Error).message}`, @@ -1014,35 +1103,11 @@ export const executeInit = async ( } } - // 11. Update .gitignore - const gitignorePath = join(workspaceRoot, ".gitignore"); - const managedPatterns = getManagedGitignorePatterns(reposDir, worktreesDir); - if (options.dryRun) { - logDryRun("UPDATE_FILE", `${gitignorePath} (add: ${managedPatterns.join(", ")})`); - } else { - logVerbose("Updating .gitignore...", options); - try { - await updateGitignore(workspaceRoot, reposDir, worktreesDir); - logVerbose("✓ .gitignore updated", options); - } catch (error) { - await executeRollback(); - if (error instanceof PermissionError) { - return { - error: `Failed to update .gitignore: ${(error as Error).message}`, - exitCode: ExitCode.PERMISSION_DENIED, - success: false, - workspaceRoot, - }; - } - throw error; - } - } - - // 12. Success! + // 11. Success! const duration = ((Date.now() - startTime) / 1000).toFixed(1); logVerbose(`Initialization completed in ${duration}s`, options); - if (options.dryRun) { + if (options.dryRun && !options.quiet) { console.log("\n=== DRY RUN COMPLETE ==="); console.log("No changes were made. Run without --dry-run to apply."); } @@ -1054,6 +1119,7 @@ export const executeInit = async ( discoveredCount, exitCode: ExitCode.SUCCESS, hooksPath: hooksDir, + managedIgnore, reposPath: absoluteReposPath, success: true, workspaceRoot, @@ -1061,12 +1127,19 @@ export const executeInit = async ( } catch (error) { // Unexpected error - rollback and exit if (!options.dryRun) { - await executeRollback(); + await executeRollback(options.quiet); } + const structuredError = unknownErrorToJsonError(error); return { error: `Unexpected error: ${(error as Error).message}`, exitCode: ExitCode.UNKNOWN, + ...(structuredError.code === "MANAGED_IGNORE_RECONCILIATION_FAILED" + ? { managedIgnoreFailure: structuredError } + : {}), + ...(structuredError.code === "INIT_ROLLBACK_FAILED" + ? { rollbackFailure: structuredError } + : {}), success: false, workspaceRoot: cwd, }; @@ -1077,7 +1150,9 @@ export const executeInit = async ( * Display success message with details */ const displaySuccess = (result: InitResult, options: InitOptions): void => { - success("Initialized Arashi workspace"); + success( + result.preferenceOnly ? "Updated managed ignore preference" : "Initialized Arashi workspace", + ); console.log("\nCreated:"); console.log(` • Configuration: ${result.configPath}`); @@ -1096,12 +1171,22 @@ const displaySuccess = (result: InitResult, options: InitOptions): void => { console.log(`\nDiscovered ${result.discoveredCount} repositories`); } - const reposDir = options.reposDir || "./repos"; - const worktreesDir = options.worktreesDir || DEFAULT_WORKTREES_DIR; - const managedPatterns = getManagedGitignorePatterns(reposDir, worktreesDir); - console.log(`\nUpdated .gitignore to exclude: ${managedPatterns[0]}`); - for (const pattern of managedPatterns.slice(1)) { - console.log(` • ${pattern}`); + if (result.managedIgnore) { + console.log(`\nManaged ignore scope: ${result.managedIgnore.scope}`); + for (const path of result.managedIgnore.paths) { + if (path.status === "unsafe") { + console.log(` • Skipped unsafe path: ${path.input} (${path.safetyReason})`); + } else if (path.source) { + console.log( + ` • ${path.rule}: already ignored by ${path.source.type} (${path.source.path})`, + ); + } else { + console.log(` • ${path.rule}: ${path.status}`); + } + } + for (const warning of result.managedIgnore.warnings) { + warn(warning); + } } console.log("\nNext steps:"); @@ -1175,33 +1260,39 @@ const displayError = (result: InitResult): void => { export function createCommand(): Command { return new Command("init") .description("Initialize Arashi workspace in the current repository or bootstrap a new one") - .option("--repos-dir ", "Custom location for managed repositories", "./repos") - .option( - "--worktrees-dir ", - "Custom base location for managed worktrees", - DEFAULT_WORKTREES_DIR, - ) + .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("--force", "Overwrite existing configuration if present") .option("--no-discover", "Skip automatic repository discovery") .option("--dry-run", "Show what would be done without making changes") .option("--verbose", "Show detailed information during initialization") .option("--json", "Output result as JSON") + .addHelpText( + "after", + ` +Examples: + $ arashi init # Local info/exclude rules (default) + $ arashi init --ignore-scope tracked # Team-owned .gitignore block + $ arashi init --ignore-scope none # Manual ignore management + $ arashi init --ignore-scope local # Reset an existing clone to the default + +Existing effective tracked, local, or global rules are honored. Arashi never modifies global Git configuration. + `, + ) .action(async (options: InitOptions & { discover?: boolean; json?: boolean }) => { // Commander converts --no-discover to discover: false const normalizedOptions: InitOptions = { dryRun: options.dryRun, force: options.force, + ignoreScope: options.ignoreScope, noDiscover: options.discover === false, // --no-discover sets discover: false + quiet: options.json, reposDir: options.reposDir, verbose: options.json ? false : options.verbose, worktreesDir: options.worktreesDir, }; - if (options.json && options.dryRun) { - writeJsonEnvelope(unsupportedJsonModeError("init", "dry-run-preview")); - process.exit(ExitCode.UNKNOWN); - } - const result = await executeInit(normalizedOptions).catch((error): never => { if (options.json) { writeJsonEnvelope(createJsonErrorEnvelope("init", unknownErrorToJsonError(error))); @@ -1220,11 +1311,15 @@ export function createCommand(): Command { } else { if (options.json) { writeJsonEnvelope( - createJsonErrorEnvelope("init", { - code: `INIT_${result.exitCode}`, - details: { exitCode: result.exitCode, workspaceRoot: result.workspaceRoot }, - message: result.error || "Unknown error", - }), + createJsonErrorEnvelope( + "init", + result.managedIgnoreFailure ?? + result.rollbackFailure ?? { + code: `INIT_${result.exitCode}`, + details: { exitCode: result.exitCode, workspaceRoot: result.workspaceRoot }, + message: result.error || "Unknown error", + }, + ), ); } else { displayError(result); diff --git a/src/commands/pull.ts b/src/commands/pull.ts index adaf363..41fe73e 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -24,6 +24,9 @@ import { checkRemoteChanges } from "../lib/git-remote.ts"; import { EmptyRepositoryFiltersError, filterRepositories } from "../lib/repo-filter.ts"; import { info } from "../lib/logger.ts"; 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"; const ZERO = 0; const ONE = 1; @@ -44,29 +47,11 @@ export interface PullCommandOptions { verbose?: boolean; } -const executePull = async (options: PullCommandOptions): Promise => { - let workspaceRoot = ""; - try { - workspaceRoot = await findWorkspaceRoot(); - } catch { - throw new CliUsageError( - 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', - ); - } - - const repositoriesResult = await loadWorkspaceRepositories(workspaceRoot).catch( - (error): never => { - throw new CliUsageError( - `Failed to load workspace configuration: ${error instanceof Error ? error.message : String(error)}`, - ); - }, - ); - - const filterResult = filterRepositories( - repositoriesResult.repositories, - options.only, - options.group, - ); +const selectRepositories = ( + repositories: Awaited>["repositories"], + options: PullCommandOptions, +) => { + const filterResult = filterRepositories(repositories, options.only, options.group); if (filterResult.emptyFilters.length > ZERO) { throw new EmptyRepositoryFiltersError(filterResult.emptyFilters); } @@ -83,18 +68,61 @@ const executePull = async (options: PullCommandOptions): Promise => if (filterResult.emptyIntersection) { throw new CliUsageError("No repositories matched the combined --only/--group filters"); } + return filterResult.selected; +}; - const repositories = filterResult.selected; +const excludeWorkspaceRoot = ( + repositories: Awaited>["repositories"], + workspaceRoot: string, +) => repositories.filter((repository) => repository.path !== workspaceRoot); + +const executePull = async (options: PullCommandOptions): Promise => { + let workspaceRoot = ""; + try { + workspaceRoot = await findWorkspaceRoot(); + } catch { + throw new CliUsageError( + 'Not in an arashi workspace. Run "arashi init" to initialize a workspace', + ); + } + + let repositoriesResult = await loadWorkspaceRepositories(workspaceRoot).catch((error): never => { + throw new CliUsageError( + `Failed to load workspace configuration: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + + let repositories = selectRepositories(repositoriesResult.repositories, options); + const selectedParent = repositories.find((repository) => repository.path === workspaceRoot); + if (selectedParent) { + repositories = [ + selectedParent, + ...repositories.filter((repository) => repository !== selectedParent), + ]; + } + let managedIgnore; + if (!selectedParent) { + managedIgnore = await reconcileManagedIgnore({ + reposDir: repositoriesResult.config.reposDir, + workspaceRoot, + worktreesDir: repositoriesResult.config.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json) { + for (const warning of managedIgnore.warnings) { + info(`Warning: ${warning}`); + } + } + } if (repositories.length === ZERO) { if (!options.json) { info("No repositories selected for pull"); } - return { overallStatus: "success", results: [] }; + return { managedIgnore, overallStatus: "success", results: [] }; } const results: PullResult[] = []; - const total = repositories.length; - const timeoutMs = repositoriesResult.config.hooks?.timeout; + let total = repositories.length; + let timeoutMs = repositoriesResult.config.hooks?.timeout; for (let index = ZERO; index < repositories.length; index += ONE) { const repo = repositories[index]; @@ -103,6 +131,19 @@ const executePull = async (options: PullCommandOptions): Promise => } const start = Date.now(); + if (repo.path !== workspaceRoot && !(await fileExists(repo.path))) { + const result: PullResult = { + elapsedSeconds: (Date.now() - start) / MILLISECONDS_PER_SECOND, + errorMessage: `Repository is not materialized; run \`arashi clone\` to create ${repo.name}.`, + repositoryId: repo.name, + status: "skipped", + }; + results.push(result); + if (!options.json) { + info(formatResultLine(result)); + } + continue; + } const remoteStatus = await checkRemoteChanges(repo.name, repo.path); if (remoteStatus.error) { const elapsedSeconds = (Date.now() - start) / MILLISECONDS_PER_SECOND; @@ -152,9 +193,53 @@ const executePull = async (options: PullCommandOptions): Promise => info(formatResultLine(lastResult)); } } + + const parentResult = results.at(-ONE); + if (repo.path === workspaceRoot && parentResult?.repositoryId === repo.name) { + if (parentResult.status === "updated") { + try { + repositoriesResult = await loadWorkspaceRepositories(workspaceRoot); + const postPullSelection = excludeWorkspaceRoot( + selectRepositories(repositoriesResult.repositories, options), + workspaceRoot, + ); + repositories.splice(index + ONE, repositories.length, ...postPullSelection); + total = repositories.length; + timeoutMs = repositoriesResult.config.hooks?.timeout; + } catch (error) { + results.push({ + elapsedSeconds: ZERO, + errorMessage: `Failed to reload pulled workspace configuration: ${error instanceof Error ? error.message : String(error)}`, + repositoryId: "workspace-config", + status: "failed", + }); + break; + } + } + try { + managedIgnore = await reconcileManagedIgnore({ + reposDir: repositoriesResult.config.reposDir, + workspaceRoot, + worktreesDir: repositoriesResult.config.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json) { + for (const warning of managedIgnore.warnings) { + info(`Warning: ${warning}`); + } + } + } catch (error) { + results.push({ + elapsedSeconds: ZERO, + errorMessage: `Managed ignore reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, + repositoryId: "managed-ignore", + status: "failed", + }); + break; + } + } } - const summary = buildSummary(results); + const summary = { ...buildSummary(results), managedIgnore }; if (!options.json) { console.log(formatSummary(summary)); } diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 722b088..b3fe880 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -9,6 +9,8 @@ import { checkAllRepos, isMissingRepositoryStatus } from "../commands/status.ts" import { findWorkspaceRoot, getConfigPath, loadConfig } from "./config.ts"; import { discoverPrunableWorktrees } from "../core/remove.ts"; import { readdir } from "fs/promises"; +import { inspectManagedIgnore, type ManagedIgnoreInspection } from "./managed-ignore.ts"; +import { DEFAULT_WORKTREES_DIR } from "./worktree-location.ts"; const ZERO = 0; @@ -62,6 +64,86 @@ const ALL_CHECKED_CATEGORIES: DoctorCategory[] = [ const createFinding = (finding: DoctorFinding): DoctorFinding => finding; +export const managedIgnoreToDoctorFindings = ( + inspection: ManagedIgnoreInspection, +): DoctorFinding[] => { + const findings: DoctorFinding[] = []; + for (const path of inspection.paths) { + if (path.status === "unignored") { + findings.push( + createFinding({ + category: "configuration", + code: "MANAGED_IGNORE_MISSING", + details: { path: path.input, rule: path.rule, scope: inspection.scope }, + message: `Managed path '${path.rule}' is not effectively ignored (scope: ${inspection.scope}).`, + scope: `managed-ignore:${path.rule}`, + severity: "warning", + suggestedCommands: ["arashi init --ignore-scope local"], + }), + ); + } else if (path.status === "unsafe") { + findings.push( + createFinding({ + category: "configuration", + code: "MANAGED_IGNORE_UNSAFE_PATH", + details: { path: path.input, safetyReason: path.safetyReason }, + message: `Configured managed path '${path.input}' is unsafe to ignore automatically (${path.safetyReason}).`, + scope: `managed-ignore:${path.input}`, + severity: "warning", + suggestedCommands: ["arashi init --help", "edit .arashi/config.json"], + }), + ); + } + } + for (const stale of inspection.staleRules) { + findings.push( + createFinding({ + category: "configuration", + code: "MANAGED_IGNORE_STALE_RULE", + details: { ...stale }, + message: `Arashi-owned ignore rule '${stale.rule}' is stale in ${stale.path}.`, + scope: `managed-ignore:${stale.target}`, + severity: "warning", + suggestedCommands: ["arashi init --ignore-scope local"], + }), + ); + } + return findings; +}; + +const collectManagedIgnoreFindings = async ( + workspaceRoot: string, + config: Config, +): Promise => { + try { + const inspection = await inspectManagedIgnore({ + reposDir: config.reposDir, + workspaceRoot, + worktreesDir: config.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + return managedIgnoreToDoctorFindings(inspection); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Invalid clone-local arashi.ignoreScope")) { + return [ + createFinding({ + category: "configuration", + code: "MANAGED_IGNORE_SCOPE_INVALID", + details: { error: message }, + message, + scope: "managed-ignore:preference", + severity: "warning", + suggestedCommands: [ + "git config --local --unset arashi.ignoreScope", + "arashi init --ignore-scope local", + ], + }), + ]; + } + throw error; + } +}; + export const summarizeDoctorFindings = (findings: DoctorFinding[]): DoctorSummary => ({ error: findings.filter((finding) => finding.severity === "error").length, info: findings.filter((finding) => finding.severity === "info").length, @@ -505,6 +587,7 @@ export const runDoctor = async (): Promise => { } const phaseResults = await Promise.allSettled([ + collectManagedIgnoreFindings(workspaceRoot, config), collectRepositoryFindings(workspaceRoot, config), collectWorktreeFindings(workspaceRoot, config), collectHookFindings(workspaceRoot, config), diff --git a/src/lib/json-output.ts b/src/lib/json-output.ts index 62bcbcb..caa6465 100644 --- a/src/lib/json-output.ts +++ b/src/lib/json-output.ts @@ -77,6 +77,21 @@ export const unknownErrorToJsonError = ( }; } + if ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + "details" in error && + typeof error.details === "object" && + error.details !== null + ) { + return { + code: error.code, + details: error.details as Record, + message: error.message, + }; + } + return { code, message: error instanceof Error ? error.message : String(error), diff --git a/src/lib/managed-ignore.ts b/src/lib/managed-ignore.ts new file mode 100644 index 0000000..d994fe3 --- /dev/null +++ b/src/lib/managed-ignore.ts @@ -0,0 +1,605 @@ +import { dirname, isAbsolute, posix, resolve, win32 } from "path"; +import { lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { ArashiError } from "./errors.ts"; +import { exec as gitExec } from "./git.ts"; +import { runtime } from "./runtime.ts"; +import { normalizeSpawnEnvironment } from "./shell-directives.ts"; + +export interface SafeManagedPath { + input: string; + rule: string; + safety: "safe"; +} + +export type UnsafeManagedPathReason = + | "absolute" + | "control-character" + | "parent-traversal" + | "repository-root"; + +export interface UnsafeManagedPath { + input: string; + reason: UnsafeManagedPathReason; + safety: "unsafe"; +} + +export type ManagedPathClassification = SafeManagedPath | UnsafeManagedPath; + +export type ManagedIgnoreScope = "local" | "tracked" | "none"; + +export interface ManagedIgnoreSource { + path: string; + pattern: string; + type: "tracked" | "local" | "global"; +} + +export interface ManagedIgnorePathResult { + input: string; + rule?: string; + safety: "safe" | "unsafe"; + safetyReason?: UnsafeManagedPathReason; + source?: ManagedIgnoreSource; + status: "already-ignored" | "applied" | "planned" | "unignored" | "unsafe"; +} + +export interface ManagedIgnoreInspection { + localExcludePath: string; + paths: ManagedIgnorePathResult[]; + scope: ManagedIgnoreScope; + staleRules: ManagedIgnoreStaleRule[]; + storedPreference: ManagedIgnoreScope | null; + trackedIgnorePath: string; +} + +export interface ManagedIgnoreStaleRule { + path: string; + rule: string; + target: "local" | "tracked"; +} + +export interface InspectManagedIgnoreOptions { + dryRun?: boolean; + reposDir: string; + requestedScope?: string; + workspaceRoot: string; + worktreesDir: string; +} + +export interface ManagedIgnoreReconciliation extends ManagedIgnoreInspection { + appliedRules: string[]; + attempted: boolean; + changed: boolean; + fileChanges: { local: boolean; preference: boolean; tracked: boolean }; + plannedRules: string[]; + restored: boolean; + staleRules: ManagedIgnoreStaleRule[]; + targetPath?: string; + targetType?: "local" | "tracked"; + warnings: string[]; +} + +export class ManagedIgnoreError extends Error { + readonly code = "MANAGED_IGNORE_RECONCILIATION_FAILED"; + readonly details: { + attempted: boolean; + changed: boolean; + phase: "apply" | "inspection"; + restored: boolean; + restorationError?: string; + targetPath?: string; + }; + + constructor(message: string, details: ManagedIgnoreError["details"], options?: ErrorOptions) { + super(message, options); + this.name = "ManagedIgnoreError"; + this.details = details; + } +} + +interface ManagedIgnoreSnapshot { + files: Array<{ content: string | null; path: string }>; + preference: string | null; + workspaceRoot: string; +} + +const reconciliationSnapshots = new WeakMap(); +const BLOCK_START = "# BEGIN Arashi managed ignore rules"; +const BLOCK_END = "# END Arashi managed ignore rules"; + +export const classifyManagedPaths = (paths: string[]): ManagedPathClassification[] => { + const seen = new Set(); + const candidates: ManagedPathClassification[] = []; + + for (const input of paths) { + const hasControlCharacter = [...input].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 31 || codePoint === 127); + }); + if (hasControlCharacter) { + candidates.push({ input, reason: "control-character", safety: "unsafe" }); + continue; + } + const slashPath = input.replaceAll("\\", "/"); + const normalized = posix.normalize(slashPath).replace(/^\.\//, "").replace(/\/$/, ""); + if (normalized === "" || normalized === ".") { + candidates.push({ input, reason: "repository-root", safety: "unsafe" }); + continue; + } + if (posix.isAbsolute(normalized) || win32.isAbsolute(input)) { + candidates.push({ input, reason: "absolute", safety: "unsafe" }); + continue; + } + if (normalized === ".." || normalized.startsWith("../")) { + candidates.push({ input, reason: "parent-traversal", safety: "unsafe" }); + continue; + } + + const escaped = normalized.replace(/([*?[\]])/g, "\\$1").replace(/^([#!])/, "\\$1"); + const rule = `/${escaped}/`; + if (seen.has(rule)) { + continue; + } + seen.add(rule); + candidates.push({ input, rule, safety: "safe" }); + } + + return candidates; +}; + +const resolveGitPath = (workspaceRoot: string, path: string): string => + isAbsolute(path) ? path : resolve(workspaceRoot, path); + +const inspectEffectiveSource = async ( + workspaceRoot: string, + rule: string, + localExcludePath: string, +): Promise => { + const args = ["check-ignore", "-z", "-v", "--no-index", "--stdin"]; + const process = runtime.spawn(["git", ...args], { + cwd: workspaceRoot, + env: normalizeSpawnEnvironment(globalThis.process.env), + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + }); + process.stdin?.end(`${rule}\0`); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + if (process.spawnError) { + throw new ArashiError(`Failed to spawn git command: ${process.spawnError.message}`, { + args, + cwd: workspaceRoot, + exitCode: -1, + stderr: process.spawnError.message, + stdout, + }); + } + if (exitCode === 1) { + return undefined; + } + if (exitCode !== 0) { + const errorMessage = stderr.trim() || stdout.trim() || "Git command failed with no output"; + throw new ArashiError(`Git command failed: ${errorMessage}`, { + args, + cwd: workspaceRoot, + exitCode, + stderr, + stdout, + }); + } + const [sourcePath = "", , pattern = ""] = stdout.split("\0"); + if (sourcePath.length === 0 || pattern.length === 0) { + throw new Error("Git returned malformed managed-ignore source data."); + } + const absoluteSource = resolveGitPath(workspaceRoot, sourcePath); + let type: ManagedIgnoreSource["type"] = "global"; + if (absoluteSource === localExcludePath) { + type = "local"; + } else if (sourcePath === ".gitignore" || sourcePath.endsWith("/.gitignore")) { + type = "tracked"; + } + return { path: sourcePath, pattern, type }; +}; + +export const inspectManagedIgnore = async ({ + reposDir, + requestedScope, + workspaceRoot, + worktreesDir, +}: InspectManagedIgnoreOptions): Promise => { + let storedValue: string | null = null; + try { + const result = await gitExec( + ["config", "--local", "--get", "arashi.ignoreScope"], + workspaceRoot, + ); + storedValue = result.stdout.trim() || null; + } catch (error) { + if (!(error instanceof ArashiError && error.context.exitCode === 1)) { + throw error; + } + } + const validScopes = new Set(["local", "tracked", "none"]); + if (storedValue !== null && !validScopes.has(storedValue as ManagedIgnoreScope)) { + throw new Error( + `Invalid clone-local arashi.ignoreScope value '${storedValue}'. Run \`git config --local --unset arashi.ignoreScope\` or \`arashi init --ignore-scope local\`.`, + ); + } + if (requestedScope !== undefined && !validScopes.has(requestedScope as ManagedIgnoreScope)) { + throw new Error("Invalid ignore scope. Expected one of: local, tracked, none."); + } + const storedPreference = storedValue as ManagedIgnoreScope | null; + const scope = (requestedScope as ManagedIgnoreScope | undefined) ?? storedPreference ?? "local"; + const localPathResult = await gitExec(["rev-parse", "--git-path", "info/exclude"], workspaceRoot); + const localExcludePath = resolveGitPath(workspaceRoot, localPathResult.stdout.trim()); + const trackedIgnorePath = resolve(workspaceRoot, ".gitignore"); + const classifications = classifyManagedPaths([reposDir, worktreesDir]); + const paths: ManagedIgnorePathResult[] = []; + + for (const classification of classifications) { + if (classification.safety === "unsafe") { + paths.push({ + input: classification.input, + safety: "unsafe", + safetyReason: classification.reason, + status: "unsafe", + }); + continue; + } + const effectivePath = `${posix + .normalize(classification.input.replaceAll("\\", "/")) + .replace(/^\.\//, "") + .replace(/\/$/, "")}/`; + const source = await inspectEffectiveSource(workspaceRoot, effectivePath, localExcludePath); + paths.push({ + input: classification.input, + rule: classification.rule, + safety: "safe", + source, + status: source ? "already-ignored" : "unignored", + }); + } + + const safeRules = new Set(paths.flatMap((path) => (path.rule ? [path.rule] : []))); + const staleRules: ManagedIgnoreStaleRule[] = []; + for (const [target, path] of [ + ["local", localExcludePath], + ["tracked", trackedIgnorePath], + ] as const) { + const rules = getOwnedRules(await readOptionalFile(path)); + staleRules.push( + ...rules.filter((rule) => !safeRules.has(rule)).map((rule) => ({ path, rule, target })), + ); + } + + return { + localExcludePath, + paths, + scope, + staleRules, + storedPreference, + trackedIgnorePath, + }; +}; + +const readOptionalFile = async (path: string): Promise => { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +}; + +const assertNotSymlink = async (path: string): Promise => { + try { + const stats = await lstat(path); + if (stats.isSymbolicLink()) { + throw new Error(`Refusing to modify symbolic-link ignore file: ${path}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +}; + +const getOwnedRules = (content: string | null): string[] => { + if (!content) { + return []; + } + const start = content.indexOf(BLOCK_START); + const end = content.indexOf(BLOCK_END, start + BLOCK_START.length); + if (start === -1 || end === -1) { + return []; + } + return content + .slice(start + BLOCK_START.length, end) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +}; + +const replaceOwnedBlock = (content: string | null, rules: string[]): string | null => { + if (content === null && rules.length === 0) { + return null; + } + const original = content ?? ""; + const start = original.indexOf(BLOCK_START); + const endMarkerIndex = original.indexOf(BLOCK_END, Math.max(0, start)); + const block = rules.length > 0 ? `${BLOCK_START}\n${rules.join("\n")}\n${BLOCK_END}` : ""; + let next = original; + if (start !== -1 && endMarkerIndex !== -1) { + const after = endMarkerIndex + BLOCK_END.length; + next = `${original.slice(0, start)}${block}${original.slice(after)}`; + } else if (block) { + let separator = "\n\n"; + if (original.length === 0) { + separator = ""; + } else if (original.endsWith("\n")) { + separator = "\n"; + } + next = `${original}${separator}${block}\n`; + } else { + next = original; + } + return next.replace(/^\n(?=# BEGIN)/, ""); +}; + +const updateStoredPreference = async ( + workspaceRoot: string, + scope: ManagedIgnoreScope, +): Promise => { + if (scope === "local") { + try { + await gitExec(["config", "--local", "--unset-all", "arashi.ignoreScope"], workspaceRoot); + } catch (error) { + if (!(error instanceof ArashiError && error.context.exitCode === 5)) { + throw error; + } + } + return; + } + await gitExec(["config", "--local", "arashi.ignoreScope", scope], workspaceRoot); +}; + +export const reconcileManagedIgnore = async ( + options: InspectManagedIgnoreOptions, +): Promise => { + let inspection: ManagedIgnoreInspection; + try { + inspection = await inspectManagedIgnore(options); + } catch (error) { + throw new ManagedIgnoreError( + error instanceof Error ? error.message : String(error), + { attempted: false, changed: false, phase: "inspection", restored: false }, + { cause: error }, + ); + } + const targetType = inspection.scope === "none" ? undefined : inspection.scope; + const targetPath = + targetType === "local" + ? inspection.localExcludePath + : targetType === "tracked" + ? inspection.trackedIgnorePath + : undefined; + const fileStates = { + local: { + content: await readOptionalFile(inspection.localExcludePath), + path: inspection.localExcludePath, + }, + tracked: { + content: await readOptionalFile(inspection.trackedIgnorePath), + path: inspection.trackedIgnorePath, + }, + }; + const ownedRules = { + local: getOwnedRules(fileStates.local.content), + tracked: getOwnedRules(fileStates.tracked.content), + }; + const safeRules = new Set( + inspection.paths.flatMap((path) => (path.safety === "safe" && path.rule ? [path.rule] : [])), + ); + const staleRules = inspection.staleRules; + const migrateOwnedRules = + options.requestedScope !== undefined || inspection.storedPreference !== null; + const otherType = + migrateOwnedRules && targetType === "local" + ? "tracked" + : migrateOwnedRules && targetType === "tracked" + ? "local" + : undefined; + const missingPaths = inspection.paths.filter((path) => { + if (path.safety !== "safe" || !path.rule) { + return false; + } + if (path.status === "unignored") { + return true; + } + return ( + otherType !== undefined && + path.source?.type === otherType && + ownedRules[otherType].includes(path.rule) + ); + }); + const plannedRules = targetType ? missingPaths.map((path) => path.rule as string) : []; + const warnings = + inspection.scope === "none" + ? [ + ...missingPaths.map( + (path) => `Managed path '${path.rule}' remains unignored because scope is none.`, + ), + ...staleRules.map( + (stale) => + `Stale Arashi-owned rule '${stale.rule}' remains unchanged because scope is none.`, + ), + ] + : []; + const nextContents = { + local: + targetType === undefined + ? fileStates.local.content + : targetType === "local" + ? replaceOwnedBlock( + fileStates.local.content, + [...ownedRules.local.filter((rule) => safeRules.has(rule)), ...plannedRules].filter( + (rule, index, rules) => rules.indexOf(rule) === index, + ), + ) + : migrateOwnedRules + ? replaceOwnedBlock(fileStates.local.content, []) + : fileStates.local.content, + tracked: + targetType === undefined + ? fileStates.tracked.content + : targetType === "tracked" + ? replaceOwnedBlock( + fileStates.tracked.content, + [...ownedRules.tracked.filter((rule) => safeRules.has(rule)), ...plannedRules].filter( + (rule, index, rules) => rules.indexOf(rule) === index, + ), + ) + : migrateOwnedRules + ? replaceOwnedBlock(fileStates.tracked.content, []) + : fileStates.tracked.content, + }; + const filePlans = (["local", "tracked"] as const).filter( + (type) => nextContents[type] !== fileStates[type].content, + ); + const preferenceWouldChange = + options.requestedScope !== undefined && + (inspection.scope === "local" + ? inspection.storedPreference !== null + : inspection.storedPreference !== inspection.scope); + const result: ManagedIgnoreReconciliation = { + ...inspection, + appliedRules: [], + attempted: filePlans.length > 0 || preferenceWouldChange, + changed: false, + fileChanges: { local: false, preference: false, tracked: false }, + paths: inspection.paths.map((path) => + plannedRules.includes(path.rule ?? "") + ? { ...path, status: options.dryRun ? "planned" : "applied" } + : path, + ), + plannedRules, + restored: false, + staleRules, + targetPath, + targetType, + warnings, + }; + + const snapshot: ManagedIgnoreSnapshot = { + files: [], + preference: inspection.storedPreference, + workspaceRoot: options.workspaceRoot, + }; + reconciliationSnapshots.set(result, snapshot); + if (options.dryRun) { + return result; + } + + let mutationStarted = false; + try { + if (preferenceWouldChange) { + mutationStarted = true; + await updateStoredPreference(options.workspaceRoot, inspection.scope); + result.fileChanges.preference = true; + } + for (const type of filePlans) { + const nextContent = nextContents[type]; + if (nextContent === null) { + continue; + } + mutationStarted = true; + await mkdir(dirname(fileStates[type].path), { recursive: true }); + await assertNotSymlink(fileStates[type].path); + snapshot.files.push({ + content: fileStates[type].content, + path: fileStates[type].path, + }); + await writeFile(fileStates[type].path, nextContent); + result.fileChanges[type] = true; + } + result.appliedRules = plannedRules; + } catch (error) { + result.changed = mutationStarted; + if (mutationStarted) { + try { + await restoreManagedIgnore(result); + } catch (restoreError) { + throw new ManagedIgnoreError( + `Managed ignore reconciliation failed and restoration also failed: ${error instanceof Error ? error.message : String(error)}`, + { + attempted: true, + changed: result.changed, + phase: "apply", + restorationError: + restoreError instanceof Error ? restoreError.message : String(restoreError), + restored: false, + ...(targetPath ? { targetPath } : {}), + }, + { + cause: new AggregateError( + [error, restoreError], + "Managed ignore apply and restore failed", + ), + }, + ); + } + } + throw new ManagedIgnoreError( + error instanceof Error ? error.message : String(error), + { + attempted: mutationStarted, + changed: result.changed, + phase: "apply", + restored: result.restored, + ...(targetPath ? { targetPath } : {}), + }, + { cause: error }, + ); + } + result.changed = filePlans.length > 0 || preferenceWouldChange; + return result; +}; + +export const restoreManagedIgnore = async ( + reconciliation: ManagedIgnoreReconciliation, +): Promise => { + const snapshot = reconciliationSnapshots.get(reconciliation); + if (!snapshot) { + throw new Error("Managed ignore reconciliation cannot be restored."); + } + for (const file of snapshot.files) { + if (file.content === null) { + try { + await unlink(file.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } else { + await assertNotSymlink(file.path); + await writeFile(file.path, file.content); + } + } + if (snapshot.preference === null) { + await updateStoredPreference(snapshot.workspaceRoot, "local"); + } else { + await gitExec( + ["config", "--local", "arashi.ignoreScope", snapshot.preference], + snapshot.workspaceRoot, + ); + } + reconciliation.changed = false; + reconciliation.restored = true; + reconciliation.fileChanges = { local: false, preference: false, tracked: false }; +}; diff --git a/src/lib/pull-types.ts b/src/lib/pull-types.ts index 88ee1b1..7ef2b33 100644 --- a/src/lib/pull-types.ts +++ b/src/lib/pull-types.ts @@ -2,6 +2,8 @@ * Types for pull command results and summaries. */ +import type { ManagedIgnoreReconciliation } from "./managed-ignore.ts"; + export type PullStatus = "updated" | "skipped" | "failed" | "manual-update"; export interface PullResult { @@ -17,4 +19,5 @@ export type PullOverallStatus = "success" | "partial-failure" | "failure"; export interface PullSummary { overallStatus: PullOverallStatus; results: PullResult[]; + managedIgnore?: ManagedIgnoreReconciliation; } diff --git a/tests/helpers/create-bare-create-workspace.ts b/tests/helpers/create-bare-create-workspace.ts index b9119a5..db44dc4 100644 --- a/tests/helpers/create-bare-create-workspace.ts +++ b/tests/helpers/create-bare-create-workspace.ts @@ -11,6 +11,7 @@ export interface BareCreateWorkspace { } export interface BareCreateWorkspaceOptions { + createLinkedWorktree?: boolean; includeConfig?: boolean; configReposDir?: string; configWorktreesDir?: string; @@ -19,6 +20,7 @@ export interface BareCreateWorkspaceOptions { export async function createBareCreateWorkspace( options: BareCreateWorkspaceOptions = {}, ): Promise { + const createLinkedWorktree = options.createLinkedWorktree ?? true; const includeConfig = options.includeConfig ?? true; const configReposDir = options.configReposDir ?? "./repos"; const configWorktreesDir = options.configWorktreesDir ?? ".arashi/worktrees"; @@ -63,7 +65,11 @@ export async function createBareCreateWorkspace( await execGit(["remote", "add", "origin", bareRepoPath], seedPath); await execGit(["push", "origin", "main"], seedPath); - await execGit(["worktree", "add", worktreePath, "main"], bareRepoPath); + if (createLinkedWorktree) { + await execGit(["worktree", "add", worktreePath, "main"], bareRepoPath); + } else { + await execGit(["symbolic-ref", "HEAD", "refs/heads/unborn"], bareRepoPath); + } return { bareRepoPath, diff --git a/tests/integration/add.test.ts b/tests/integration/add.test.ts index eb3d8c5..d3eea04 100644 --- a/tests/integration/add.test.ts +++ b/tests/integration/add.test.ts @@ -17,6 +17,7 @@ import { detectSetupScript, isValidGitUrl, parseGitUrl, + shouldTreatFailedCloneAsMaterialized, } from "../../src/commands/add.ts"; import { mkdir, rm } from "fs/promises"; import { AddCommandErrorCode } from "../../src/lib/errors.ts"; @@ -267,6 +268,11 @@ describe("Add Command - Edge Cases", () => { // Or CI/CD pipelines with test repositories. describe("Add Command - Validation Summary", () => { + test("treats surviving failed-clone destinations as materialized even when they pre-existed", () => { + expect(shouldTreatFailedCloneAsMaterialized(true)).toBe(true); + expect(shouldTreatFailedCloneAsMaterialized(false)).toBe(false); + }); + test("all URL validation functions work correctly", () => { expect(() => { const valid = isValidGitUrl("https://github.com/user/repo.git"); diff --git a/tests/integration/clone.test.ts b/tests/integration/clone.test.ts index 4554113..58aa960 100644 --- a/tests/integration/clone.test.ts +++ b/tests/integration/clone.test.ts @@ -1,15 +1,17 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { executeClone, resolveCoordinatedSourceWorkspaceRoot } from "../../src/commands/clone.ts"; -import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; import type { Config } from "../../src/lib/config.ts"; import { join } from "path"; import { tmpdir } from "os"; +import { spawn } from "../helpers/node-runtime.ts"; describe("clone command", () => { let workspaceRoot: string; beforeEach(async () => { workspaceRoot = await mkdtemp(join(tmpdir(), "arashi-clone-command-")); + await spawn(["git", "init"], { cwd: workspaceRoot }).exited; await mkdir(join(workspaceRoot, "repos"), { recursive: true }); }); @@ -124,6 +126,36 @@ describe("clone command", () => { expect(clonedDestinations).toHaveLength(2); }); + test("reconciles local ignore rules before cloning a configured repository", async () => { + const config: Config = { + repos: { + "repo-a": { + gitUrl: "https://github.com/team/repo-a.git", + path: "./repos/repo-a", + }, + }, + reposDir: "./repos", + version: "1.0.0", + }; + + const result = await executeClone( + { all: true }, + { + cloneRepository: async (_gitUrl, destinationPath) => { + const exclude = await readFile(join(workspaceRoot, ".git", "info", "exclude"), "utf8"); + expect(exclude).toContain("repos/"); + await mkdir(destinationPath, { recursive: true }); + return { exitCode: 0, stderr: "", stdout: "" }; + }, + loadConfig: async () => config, + saveConfig: async () => {}, + workspaceRoot, + }, + ); + + expect(result.managedIgnore).toMatchObject({ changed: true, scope: "local" }); + }); + test("completes missing repositories in coordinated worktrees using current branch", async () => { const coordinatedRoot = join(workspaceRoot, ".arashi", "worktrees", "meta-feat-demo"); await mkdir(join(workspaceRoot, "repos", "repo-a"), { recursive: true }); @@ -186,6 +218,44 @@ describe("clone command", () => { expect(resolveCoordinatedSourceWorkspaceRoot("/workspace/arashi-arashi")).toBeNull(); }); + test("retains managed ignore coverage when failed clone cleanup leaves a destination", async () => { + const destination = join(workspaceRoot, "repos", "repo-a"); + let materialized = false; + const config: Config = { + repos: { + "repo-a": { + gitUrl: "https://github.com/team/repo-a.git", + path: "./repos/repo-a", + }, + }, + reposDir: "./repos", + version: "1.0.0", + }; + + const result = await executeClone( + { all: true }, + { + cloneRepository: async () => { + materialized = true; + throw new Error("simulated partial clone"); + }, + loadConfig: async () => config, + pathExists: async (path) => path === destination && materialized, + removeDir: async () => { + throw new Error("simulated cleanup failure"); + }, + saveConfig: async () => {}, + workspaceRoot, + }, + ); + + expect(result.status).toBe("partial-failure"); + expect(result.managedIgnore).toMatchObject({ changed: true, restored: false }); + expect(await readFile(join(workspaceRoot, ".git", "info", "exclude"), "utf8")).toContain( + "/repos/", + ); + }); + test("continues cloning after partial failures", async () => { const config: Config = { repos: { diff --git a/tests/integration/create.bare-root-success.test.ts b/tests/integration/create.bare-root-success.test.ts index 33aa9a9..b83a281 100644 --- a/tests/integration/create.bare-root-success.test.ts +++ b/tests/integration/create.bare-root-success.test.ts @@ -2,6 +2,7 @@ import { runtime } from "../helpers/node-runtime.ts"; import { afterEach, describe, expect, test } from "vitest"; import { createBareCreateWorkspace } from "../helpers/create-bare-create-workspace.ts"; import { existsSync } from "fs"; +import { readFile } from "fs/promises"; import { join } from "path"; type BareCreateWorkspace = Awaited>; @@ -53,4 +54,29 @@ describe("create command from bare root", () => { const expectedWorktreePath = join(workspace.bareRepoPath, ".arashi", "worktrees", branch); expect(existsSync(expectedWorktreePath)).toBe(true); }); + + test("creates the first worktree when the bare repository has no linked worktrees", async () => { + workspace = await createBareCreateWorkspace({ createLinkedWorktree: false }); + const branch = "feature-first-bare-worktree"; + + const command = runtime.spawn( + [process.execPath, CLI_ENTRY, "create", branch, "--no-hooks", "--no-progress"], + { + cwd: workspace.bareRepoPath, + stderr: "pipe", + stdout: "pipe", + }, + ); + + const exitCode = await command.exited; + const stdout = await new Response(command.stdout).text(); + const stderr = await new Response(command.stderr).text(); + if (exitCode !== 0) { + throw new Error(`create failed (exit=${exitCode})\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + + expect(existsSync(join(workspace.bareRepoPath, ".arashi", "worktrees", branch))).toBe(true); + const localExclude = await readFile(join(workspace.bareRepoPath, "info", "exclude"), "utf8"); + expect(localExclude).toContain("/.arashi/worktrees/"); + }); }); diff --git a/tests/integration/create.defaults.test.ts b/tests/integration/create.defaults.test.ts index 66d7547..6bf182f 100644 --- a/tests/integration/create.defaults.test.ts +++ b/tests/integration/create.defaults.test.ts @@ -69,6 +69,21 @@ function baseDeps(overrides: Partial = {}): CreateCom }), isGitRepository: async () => true, loadConfigWithFallback: async () => createLoadedConfig(), + reconcileManagedIgnore: async () => ({ + appliedRules: [], + attempted: false, + changed: false, + fileChanges: { local: false, preference: false, tracked: false }, + localExcludePath: "/workspace/.git/info/exclude", + paths: [], + plannedRules: [], + restored: false, + scope: "local", + staleRules: [], + storedPreference: null, + trackedIgnorePath: "/workspace/.gitignore", + warnings: [], + }), resolveCreateInvocationContext: async () => ({ executionPath: workspaceRoot, invocationPath: workspaceRoot, @@ -229,4 +244,88 @@ describe("create defaults integration", () => { expect(launchCalls).toEqual([{ sesh: false }]); }); + + test("retains managed ignore state when rollback leaves a residual worktree", async () => { + let restoreCalls = 0; + const managedIgnore = { + appliedRules: ["repos/"], + attempted: true, + changed: true, + fileChanges: { local: true, preference: false, tracked: false }, + localExcludePath: "/workspace/.git/info/exclude", + paths: [], + plannedRules: ["repos/"], + restored: false, + scope: "local" as const, + staleRules: [], + storedPreference: null, + trackedIgnorePath: "/workspace/.gitignore", + warnings: [], + }; + const failedSummary = { + ...createSummary(), + rolledBack: true, + }; + + await expect( + executeCreate( + branchName, + {}, + baseDeps({ + createCoordinatedWorktrees: async () => failedSummary, + pathExists: () => true, + reconcileManagedIgnore: async () => managedIgnore, + restoreManagedIgnore: async () => { + restoreCalls += 1; + }, + }), + ), + ).rejects.toThrow('process.exit unexpectedly called with "1"'); + + expect(restoreCalls).toBe(0); + expect(managedIgnore.changed).toBe(true); + }); + + test("restores managed ignore state after a complete worktree rollback", async () => { + let restoreCalls = 0; + const managedIgnore = { + appliedRules: ["repos/"], + attempted: true, + changed: true, + fileChanges: { local: true, preference: false, tracked: false }, + localExcludePath: "/workspace/.git/info/exclude", + paths: [], + plannedRules: ["repos/"], + restored: false, + scope: "local" as const, + staleRules: [], + storedPreference: null, + trackedIgnorePath: "/workspace/.gitignore", + warnings: [], + }; + const failedSummary = { + ...createSummary(), + rolledBack: true, + }; + + await expect( + executeCreate( + branchName, + {}, + baseDeps({ + createCoordinatedWorktrees: async () => failedSummary, + pathExists: () => false, + reconcileManagedIgnore: async () => managedIgnore, + restoreManagedIgnore: async (result) => { + restoreCalls += 1; + result.changed = false; + result.restored = true; + }, + }), + ), + ).rejects.toThrow('process.exit unexpectedly called with "1"'); + + expect(restoreCalls).toBe(1); + expect(managedIgnore).toMatchObject({ changed: false, restored: true }); + }); }); diff --git a/tests/integration/doctor.test.ts b/tests/integration/doctor.test.ts index 35b1ecd..c0050b4 100644 --- a/tests/integration/doctor.test.ts +++ b/tests/integration/doctor.test.ts @@ -125,7 +125,7 @@ const createHealthyRemoteBackedWorkspace = async (): Promise => { await writeWorkspaceConfig(workspaceRoot, { "repo-a": { gitUrl: repoRemote, path: "./repos/repo-a" }, }); - await writeFile(join(workspaceRoot, ".gitignore"), "repos/\n"); + await writeFile(join(workspaceRoot, ".gitignore"), "repos/\n.arashi/worktrees/\n"); await runGit(workspaceRoot, ["add", ".arashi/config.json", ".gitignore"]); await runGit(workspaceRoot, ["commit", "-m", "Add Arashi config"]); await runGit(workspaceRoot, ["push", "origin", "HEAD:main"]); diff --git a/tests/integration/init.test.ts b/tests/integration/init.test.ts index 80b18b4..2b409bc 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -87,6 +87,19 @@ async function runInitCommand( return { exitCode, stderr, stdout }; } +async function runCommand( + cwd: string, + args: string[], +): Promise<{ exitCode: number; stderr: string; stdout: string }> { + const proc = spawn(args, { cwd, stderr: "pipe", stdout: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stderr, stdout }; +} + describe("init command - success cases", () => { let testDir: string; @@ -134,16 +147,40 @@ describe("init command - success cases", () => { // Verify repos directory created expect(await fileExists(join(testDir, "repos"))).toBe(true); - // Verify .gitignore updated + // Verify repository-local ignore rules are the default const gitignorePath = join(testDir, ".gitignore"); - expect(await fileExists(gitignorePath)).toBe(true); - const gitignoreContent = await readTextFile(gitignorePath); - expect(gitignoreContent).toContain("repos/"); - expect(gitignoreContent).toContain(".arashi/worktrees/"); + expect(await fileExists(gitignorePath)).toBe(false); + const localExcludeContent = await readTextFile(join(testDir, ".git", "info", "exclude")); + expect(localExcludeContent).toContain("repos/"); + expect(localExcludeContent).toContain(".arashi/worktrees/"); + }); + + test("JSON reports managed-ignore inspection failures with stable phase details", async () => { + await runCommand(testDir, ["git", "config", "--local", "core.excludesFile", testDir]); + + const result = await runInitCommand(testDir, ["--json", "--no-discover"]); + const envelope = JSON.parse(result.stdout) as { + error: { code: string; details: { attempted: boolean; phase: string; restored: boolean } }; + ok: boolean; + }; + + expect(result.exitCode).toBe(99); + expect(envelope).toMatchObject({ + error: { + code: "MANAGED_IGNORE_RECONCILIATION_FAILED", + details: { attempted: false, phase: "inspection", restored: false }, + }, + ok: false, + }); }); test("init with custom repos directory", async () => { - const result = await runInitCommand(testDir, ["--repos-dir", "./custom-repos"]); + const result = await runInitCommand(testDir, [ + "--repos-dir", + "./custom-repos", + "--ignore-scope", + "tracked", + ]); expect(result.exitCode).toBe(0); @@ -161,7 +198,12 @@ describe("init command - success cases", () => { }); test("init with custom managed subdirectory adds normalized worktrees ignore entry", async () => { - const result = await runInitCommand(testDir, ["--worktrees-dir", "./workspace-worktrees/"]); + const result = await runInitCommand(testDir, [ + "--worktrees-dir", + "./workspace-worktrees/", + "--ignore-scope", + "tracked", + ]); expect(result.exitCode).toBe(0); @@ -176,7 +218,12 @@ describe("init command - success cases", () => { }); test("init with parent worktrees directory does not auto-ignore unsafe path", async () => { - const result = await runInitCommand(testDir, ["--worktrees-dir", "../workspace-worktrees"]); + const result = await runInitCommand(testDir, [ + "--worktrees-dir", + "../workspace-worktrees", + "--ignore-scope", + "tracked", + ]); expect(result.exitCode).toBe(0); @@ -190,7 +237,12 @@ describe("init command - success cases", () => { }); test("init with dot worktrees directory does not auto-ignore workspace root", async () => { - const result = await runInitCommand(testDir, ["--worktrees-dir", "."]); + const result = await runInitCommand(testDir, [ + "--worktrees-dir", + ".", + "--ignore-scope", + "tracked", + ]); expect(result.exitCode).toBe(0); @@ -220,9 +272,29 @@ describe("init command - success cases", () => { expect(Object.keys(loadedConfig.repos)).toHaveLength(0); }); + test("none preference can be reset to local without reinitializing config", async () => { + const first = await runInitCommand(testDir, ["--ignore-scope", "none", "--no-discover"]); + expect(first.exitCode).toBe(0); + expect( + await runCommand(testDir, ["git", "config", "--local", "--get", "arashi.ignoreScope"]), + ).toMatchObject({ exitCode: 0, stdout: "none\n" }); + const originalConfig = await readTextFile(getConfigPath(testDir)); + + const reset = await runInitCommand(testDir, ["--ignore-scope", "local"]); + + expect(reset.exitCode).toBe(0); + expect(reset.stdout).toContain("Updated managed ignore preference"); + expect(await readTextFile(getConfigPath(testDir))).toBe(originalConfig); + expect( + (await runCommand(testDir, ["git", "config", "--local", "--get", "arashi.ignoreScope"])) + .exitCode, + ).toBe(1); + expect(await readTextFile(join(testDir, ".git", "info", "exclude"))).toContain("repos/"); + }); + test("init with --force overwrites existing configuration", async () => { // First initialization - await runInitCommand(testDir); + await runInitCommand(testDir, ["--ignore-scope", "tracked"]); // Modify config let loadedConfig = await loadConfig(testDir); @@ -249,7 +321,7 @@ describe("init command - success cases", () => { test(".gitignore update is idempotent", async () => { // First init - await runInitCommand(testDir); + await runInitCommand(testDir, ["--ignore-scope", "tracked"]); const gitignoreContent1 = await readTextFile(join(testDir, ".gitignore")); const reposLineCount1 = (gitignoreContent1.match(/repos\//g) || []).length; @@ -273,7 +345,12 @@ describe("init command - success cases", () => { }); test(".gitignore update is idempotent with configured worktrees directory", async () => { - await runInitCommand(testDir, ["--worktrees-dir", "workspace-worktrees"]); + await runInitCommand(testDir, [ + "--worktrees-dir", + "workspace-worktrees", + "--ignore-scope", + "tracked", + ]); const gitignoreContent1 = await readTextFile(join(testDir, ".gitignore")); const reposLineCount1 = (gitignoreContent1.match(/repos\//g) || []).length; @@ -282,7 +359,12 @@ describe("init command - success cases", () => { await rm(join(testDir, ".arashi"), { recursive: true }); - await runInitCommand(testDir, ["--worktrees-dir", "./workspace-worktrees/"]); + await runInitCommand(testDir, [ + "--worktrees-dir", + "./workspace-worktrees/", + "--ignore-scope", + "tracked", + ]); const gitignoreContent2 = await readTextFile(join(testDir, ".gitignore")); const reposLineCount2 = (gitignoreContent2.match(/repos\//g) || []).length; @@ -451,6 +533,33 @@ describe("init command - repository bootstrap", () => { expect(loadedConfig.reposDir).toBe("./repos"); }); + test("dry-run previews bootstrap ignore rules without requiring a Git repository", async () => { + testDir = await createTempDir(); + + const result = await executeInit( + { dryRun: true, noDiscover: true }, + { + cwd: testDir, + promptConfirm: async () => ({ status: "ok", value: true }), + promptInput: async () => ({ status: "ok", value: "." }), + stdinIsTTY: true, + }, + ); + + expect(result).toMatchObject({ + managedIgnore: { + changed: false, + paths: [{ status: "planned" }, { status: "planned" }], + scope: "local", + }, + success: true, + workspaceRoot: testDir, + }); + expect(await fileExists(join(testDir, ".git"))).toBe(false); + expect(await fileExists(join(testDir, ".arashi"))).toBe(false); + expect(await fileExists(join(testDir, "repos"))).toBe(false); + }); + test("rejects unsupported bootstrap targets", async () => { testDir = await createTempDir(); @@ -491,7 +600,7 @@ describe("init command - rollback behavior", () => { // Create a file where repos directory should be (will cause mkdir to fail) await writeFile(join(testDir, "repos"), "file content"); - const result = await runInitCommand(testDir); + const result = await runInitCommand(testDir, ["--ignore-scope", "tracked"]); // Should fail expect(result.exitCode).not.toBe(0); @@ -499,6 +608,29 @@ describe("init command - rollback behavior", () => { // Verify .arashi directory removed expect(await fileExists(join(testDir, ".arashi"))).toBe(false); + // Coverage is retained because the managed repos path still exists. + expect(await fileExists(join(testDir, ".gitignore"))).toBe(true); + expect(await readTextFile(join(testDir, ".gitignore"))).toContain("/repos/"); + }); + + test("retains managed ignore coverage when init rollback leaves a managed directory", async () => { + await writeFile(join(testDir, "repos"), "blocks directory creation"); + let restoreCalled = false; + + const result = await executeInit( + { noDiscover: true, quiet: true }, + { + cwd: testDir, + restoreManagedIgnore: async () => { + restoreCalled = true; + }, + }, + ); + + expect(result.success).toBe(false); + expect(restoreCalled).toBe(false); + const exclude = await readTextFile(join(testDir, ".git", "info", "exclude")); + expect(exclude).toContain("/repos/"); }); test("rolls back when config write fails due to permissions", async () => { @@ -593,7 +725,7 @@ describe("init command - edge cases", () => { await rm(gitignorePath); } - const result = await runInitCommand(testDir); + const result = await runInitCommand(testDir, ["--ignore-scope", "tracked"]); expect(result.exitCode).toBe(0); @@ -612,7 +744,7 @@ describe("init command - edge cases", () => { // Create .gitignore without trailing newline await writeFile(join(testDir, ".gitignore"), "node_modules/"); - const result = await runInitCommand(testDir); + const result = await runInitCommand(testDir, ["--ignore-scope", "tracked"]); expect(result.exitCode).toBe(0); @@ -717,8 +849,8 @@ describe("init command - output format", () => { // Check for discovery info expect(result.stdout).toMatch(/Discovered \d+ repositories/); - // Check for .gitignore info - expect(result.stdout).toContain("Updated .gitignore"); + // Check for managed ignore info + expect(result.stdout).toContain("Managed ignore scope: local"); // Check for next steps expect(result.stdout).toContain("Next steps:"); @@ -813,7 +945,7 @@ describe("init command - dry-run mode", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("[DRY RUN] UPDATE_FILE:"); - expect(result.stdout).toContain("add: repos/"); + expect(result.stdout).toContain("add: /repos/"); expect(result.stdout).not.toContain("../workspace-worktrees/"); expect(result.stdout).not.toContain(".arashi/worktrees/"); }); @@ -876,13 +1008,13 @@ describe("init command - dry-run mode", () => { expect(result.stdout).toContain("setup.sh.example"); }); - test("--dry-run shows .gitignore update", async () => { + test("--dry-run shows managed ignore update", async () => { const result = await runInitCommand(testDir, ["--dry-run"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("[DRY RUN]"); expect(result.stdout).toContain("[DRY RUN] UPDATE_FILE:"); - expect(result.stdout).toContain(".gitignore"); + expect(result.stdout).toContain("info/exclude"); }); }); @@ -942,12 +1074,12 @@ describe("init command - verbose mode", () => { expect(result.stdout).toContain("✓ Found 1 repositories"); }); - test("--verbose shows .gitignore update details", async () => { + test("--verbose shows managed ignore update details", async () => { const result = await runInitCommand(testDir, ["--verbose"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("[VERBOSE]"); - expect(result.stdout).toContain("Updating .gitignore"); + expect(result.stdout).toContain("Reconciling managed Git ignore rules"); }); test("--verbose works with --force and shows backup details", async () => { diff --git a/tests/integration/json-cli-output.test.ts b/tests/integration/json-cli-output.test.ts index e607615..2aed8ac 100644 --- a/tests/integration/json-cli-output.test.ts +++ b/tests/integration/json-cli-output.test.ts @@ -250,6 +250,7 @@ describe("CLI JSON output contract", () => { warnings: [], }); const data = jsonData(parsed); + expect(data.managedIgnore).toMatchObject({ scope: "local" }); expect(data.overallStatus).toBe("success"); expect(jsonArray(data.results)).toEqual([ expect.objectContaining({ repositoryId: "repo-a", status: "skipped" }), @@ -281,6 +282,7 @@ describe("CLI JSON output contract", () => { expect(createData).toMatchObject({ branchName, failureCount: 0, + managedIgnore: { changed: true, scope: "local" }, successCount: 3, totalRepositories: 3, }); @@ -460,7 +462,7 @@ describe("CLI JSON output contract", () => { code: "NOT_IN_REPOSITORY", command: "create", }, - { args: ["init", "--dry-run", "--json"], code: "JSON_UNSUPPORTED_FOR_MODE", command: "init" }, + { args: ["init", "--dry-run", "--json"], code: "INIT_1", command: "init" }, { args: ["prune", "--json"], code: "NOT_IN_WORKSPACE", command: "prune" }, { args: ["pull", "--json"], code: "UNKNOWN_ERROR", command: "pull" }, { args: ["setup", "--json"], code: "UNKNOWN_ERROR", command: "setup" }, @@ -517,6 +519,28 @@ describe("CLI JSON output contract", () => { expect(result.stdout).not.toContain("[VERBOSE]"); }); + test("add --json reports managed ignore reconciliation in one envelope", async () => { + const baseDir = await makeTempDir(); + const remote = await createBareRemote(baseDir, "add-json-remote"); + await seedRemote(remote, baseDir, "add-json-seed"); + const workspaceRoot = await makeTempDir(); + await initializeGitRepository(workspaceRoot); + await mkdir(join(workspaceRoot, ".arashi"), { recursive: true }); + await mkdir(join(workspaceRoot, "repos"), { recursive: true }); + await writeFile( + join(workspaceRoot, ".arashi", "config.json"), + JSON.stringify({ repos: {}, reposDir: "./repos", version: "1.0.0" }), + ); + + const result = await runArashi(workspaceRoot, ["add", remote, "--json", "--force"]); + + expect(result.exitCode, JSON.stringify(result)).toBe(0); + const parsed = parseSingleJsonDocument(result.stdout); + expect(parsed).toMatchObject({ command: "add", ok: true, schemaVersion: 1 }); + expect(jsonData(parsed).managedIgnore).toMatchObject({ changed: true, scope: "local" }); + expect(result.stderr).toBe(""); + }); + test("remove --json rejects interactive selection mode with one envelope", async () => { const workspaceRoot = await createCommonWorkspace(); diff --git a/tests/integration/pull.test.ts b/tests/integration/pull.test.ts index 2207966..a0fc6f2 100644 --- a/tests/integration/pull.test.ts +++ b/tests/integration/pull.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; @@ -196,6 +196,62 @@ describe("pull command", () => { SLOW_PULL_TEST_TIMEOUT, ); + test( + "pulls the selected parent before tracked managed-ignore reconciliation", + async () => { + const { workspaceRoot, mainRemote } = await createWorkspaceWithRepo(testDir); + await runGit(workspaceRoot, ["config", "--local", "arashi.ignoreScope", "tracked"]); + await createRemoteCommit(mainRemote, testDir, "main-ignore-update", ".gitignore"); + + const result = await runPullCommand(workspaceRoot, ["--only", "workspace"]); + const trackedIgnore = await readFile(join(workspaceRoot, ".gitignore"), "utf8"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("updated"); + expect(trackedIgnore).toContain("update"); + expect(trackedIgnore).toContain("/repos/"); + }, + SLOW_PULL_TEST_TIMEOUT, + ); + + test( + "reloads parent config and reconciles changed managed paths before child processing", + async () => { + const { workspaceRoot, mainRemote } = await createWorkspaceWithRepo(testDir); + await runGit(workspaceRoot, ["add", ".arashi/config.json"]); + await runGit(workspaceRoot, ["commit", "-m", "Track workspace config"]); + await runGit(workspaceRoot, ["push", "origin", "HEAD:main"]); + + const updater = join(testDir, "main-config-update"); + await runGit(testDir, ["clone", mainRemote, updater]); + await runGit(updater, ["checkout", "-B", "main", "origin/main"]); + await runGit(updater, ["config", "user.email", "test@example.com"]); + await runGit(updater, ["config", "user.name", "Test User"]); + const config = JSON.parse( + await readFile(join(updater, ".arashi", "config.json"), "utf8"), + ) as Record; + config.reposDir = "./managed-repos"; + config.worktreesDir = "./managed-worktrees"; + (config.repos as Record)["new-child"] = { + gitUrl: "https://example.invalid/new-child.git", + path: "./managed-repos/new-child", + }; + await writeFile(join(updater, ".arashi", "config.json"), JSON.stringify(config, null, 2)); + await runGit(updater, ["add", ".arashi/config.json"]); + await runGit(updater, ["commit", "-m", "Change managed paths"]); + await runGit(updater, ["push", "origin", "HEAD:main"]); + + const result = await runPullCommand(workspaceRoot); + const exclude = await readFile(join(workspaceRoot, ".git", "info", "exclude"), "utf8"); + + expect(result.exitCode).toBe(0); + expect(exclude).toContain("managed-repos/"); + expect(exclude).toContain("managed-worktrees/"); + expect(result.stdout).toContain("arashi clone"); + }, + SLOW_PULL_TEST_TIMEOUT, + ); + test( "respects --group repository filtering", async () => { diff --git a/tests/unit/doctor-managed-ignore.test.ts b/tests/unit/doctor-managed-ignore.test.ts new file mode 100644 index 0000000..1b20642 --- /dev/null +++ b/tests/unit/doctor-managed-ignore.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { managedIgnoreToDoctorFindings } from "../../src/lib/doctor.ts"; + +describe("doctor managed ignore findings", () => { + test("reports missing, unsafe, and stale state with stable codes", () => { + const findings = managedIgnoreToDoctorFindings({ + localExcludePath: "/workspace/.git/info/exclude", + paths: [ + { input: "repos", rule: "repos/", safety: "safe", status: "unignored" }, + { + input: "../worktrees", + safety: "unsafe", + safetyReason: "parent-traversal", + status: "unsafe", + }, + ], + scope: "local", + staleRules: [{ path: "/workspace/.git/info/exclude", rule: "old-repos/", target: "local" }], + storedPreference: null, + trackedIgnorePath: "/workspace/.gitignore", + }); + + expect(findings.map((finding) => finding.code)).toEqual([ + "MANAGED_IGNORE_MISSING", + "MANAGED_IGNORE_UNSAFE_PATH", + "MANAGED_IGNORE_STALE_RULE", + ]); + }); +}); diff --git a/tests/unit/lib/json-output.test.ts b/tests/unit/lib/json-output.test.ts index 0138f94..3c3d63b 100644 --- a/tests/unit/lib/json-output.test.ts +++ b/tests/unit/lib/json-output.test.ts @@ -5,6 +5,7 @@ import { unknownErrorToJsonError, } from "../../../src/lib/json-output.ts"; import { EmptyRepositoryFiltersError } from "../../../src/lib/repo-filter.ts"; +import { ManagedIgnoreError } from "../../../src/lib/managed-ignore.ts"; import { describe, expect, test } from "vitest"; describe("json output envelopes", () => { @@ -57,4 +58,19 @@ describe("json output envelopes", () => { message: "Explicitly empty repository filters: --only, --group", }); }); + + test("preserves structured managed-ignore reconciliation errors", () => { + const error = new ManagedIgnoreError("inspection failed", { + attempted: false, + changed: false, + phase: "inspection", + restored: false, + }); + + expect(unknownErrorToJsonError(error)).toEqual({ + code: "MANAGED_IGNORE_RECONCILIATION_FAILED", + details: error.details, + message: "inspection failed", + }); + }); }); diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts new file mode 100644 index 0000000..d6a6a3c --- /dev/null +++ b/tests/unit/managed-ignore.test.ts @@ -0,0 +1,437 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { join, normalize } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn } from "../helpers/node-runtime.ts"; +import { + classifyManagedPaths, + inspectManagedIgnore, + reconcileManagedIgnore, + restoreManagedIgnore, +} from "../../src/lib/managed-ignore.ts"; + +const testRoots: string[] = []; + +const git = async (cwd: string, args: string[]): Promise => { + const process = spawn(["git", ...args], { cwd, stderr: "pipe", stdout: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + if (exitCode !== 0) { + throw new Error(stderr); + } + return stdout.trim(); +}; + +afterEach(async () => { + await Promise.all(testRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("managed ignore path classification", () => { + test("normalizes and deduplicates safe repository-relative directories", () => { + expect(classifyManagedPaths(["./repos", "repos/", "repos\\", ".arashi\\worktrees\\"])).toEqual([ + { input: "./repos", rule: "/repos/", safety: "safe" }, + { input: ".arashi\\worktrees\\", rule: "/.arashi/worktrees/", safety: "safe" }, + ]); + }); + + test("escapes Git pattern metacharacters in configured directory names", () => { + expect(classifyManagedPaths(["!cache", "#generated", "[ab]", "star*", "maybe?"])).toEqual([ + { input: "!cache", rule: "/\\!cache/", safety: "safe" }, + { input: "#generated", rule: "/\\#generated/", safety: "safe" }, + { input: "[ab]", rule: "/\\[ab\\]/", safety: "safe" }, + { input: "star*", rule: "/star\\*/", safety: "safe" }, + { input: "maybe?", rule: "/maybe\\?/", safety: "safe" }, + ]); + }); + + test("reports unsafe root, absolute, and parent-traversal paths", () => { + expect( + classifyManagedPaths([".", "./", "../repos", "/tmp/repos", String.raw`C:\repos`]), + ).toEqual([ + { input: ".", reason: "repository-root", safety: "unsafe" }, + { input: "./", reason: "repository-root", safety: "unsafe" }, + { input: "../repos", reason: "parent-traversal", safety: "unsafe" }, + { input: "/tmp/repos", reason: "absolute", safety: "unsafe" }, + { input: String.raw`C:\repos`, reason: "absolute", safety: "unsafe" }, + ]); + }); + + test("rejects control characters that could inject managed ignore rules", () => { + expect(classifyManagedPaths(["safe\n*", "safe\0path"])).toEqual([ + { input: "safe\n*", reason: "control-character", safety: "unsafe" }, + { input: "safe\0path", reason: "control-character", safety: "unsafe" }, + ]); + }); + + test("uses Git to discover an effective tracked ignore rule", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + await writeFile(join(root, ".gitignore"), "repos/\n"); + + const inspection = await inspectManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(inspection.paths[0]).toMatchObject({ + rule: "/repos/", + source: { pattern: "repos/", type: "tracked" }, + status: "already-ignored", + }); + }); + + test("parses delimiter-safe effective source data", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const globalPath = join(root, "global:ignore"); + await writeFile(globalPath, "managed:repos/\n"); + await git(root, ["config", "--local", "core.excludesFile", globalPath]); + + const inspection = await inspectManagedIgnore({ + reposDir: "managed:repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(inspection.paths[0]).toMatchObject({ + source: { path: globalPath, pattern: "managed:repos/", type: "global" }, + status: "already-ignored", + }); + }); + + test("does not create an empty tracked ignore file when effective rules already exist", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const globalPath = join(root, "global-ignore"); + await writeFile(globalPath, "repos/\n.arashi/worktrees/\n"); + await git(root, ["config", "--local", "core.excludesFile", globalPath]); + + const result = await reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "tracked", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(result.fileChanges.tracked).toBe(false); + await expect(readFile(join(root, ".gitignore"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + test("propagates fatal Git ignore inspection failures", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const invalidExcludePath = process.platform === "win32" ? join(root, "invalid:exclude") : root; + if (process.platform === "win32") { + await writeFile(invalidExcludePath, "repos/\n"); + } + await git(root, ["config", "--local", "core.excludesFile", invalidExcludePath]); + + await expect( + reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }), + ).rejects.toMatchObject({ + code: "MANAGED_IGNORE_RECONCILIATION_FAILED", + details: { attempted: false, changed: false, phase: "inspection", restored: false }, + }); + }); + + test("resolves explicit scope before clone-local preference and defaults to local", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + await git(root, ["config", "--local", "arashi.ignoreScope", "tracked"]); + + const stored = await inspectManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + const explicit = await inspectManagedIgnore({ + reposDir: "repos", + requestedScope: "none", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(stored).toMatchObject({ scope: "tracked", storedPreference: "tracked" }); + expect(explicit).toMatchObject({ scope: "none", storedPreference: "tracked" }); + }); + + test("preserves team-owned tracked rules when no clone-local scope was selected", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const tracked = + "# BEGIN Arashi managed ignore rules\n/repos/\n/.arashi/worktrees/\n# END Arashi managed ignore rules\n"; + await writeFile(join(root, ".gitignore"), tracked); + + const result = await reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(result.changed).toBe(false); + expect(await readFile(join(root, ".gitignore"), "utf8")).toBe(tracked); + expect(await readFile(join(root, ".git", "info", "exclude"), "utf8")).not.toContain( + "BEGIN Arashi", + ); + }); + + test("migrates Arashi-owned rules when switching from local to tracked scope", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + + await reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + const result = await reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "tracked", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(await readFile(join(root, ".gitignore"), "utf8")).toContain("/repos/"); + expect(await readFile(join(root, ".git", "info", "exclude"), "utf8")).not.toContain( + "BEGIN Arashi", + ); + expect(result.fileChanges).toMatchObject({ local: true, preference: true, tracked: true }); + }); + + test("propagates failures while clearing the clone-local scope preference", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + await git(root, ["config", "--local", "arashi.ignoreScope", "tracked"]); + await writeFile(join(root, ".git", "config.lock"), "locked"); + + await expect( + reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "local", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }), + ).rejects.toMatchObject({ code: "MANAGED_IGNORE_RECONCILIATION_FAILED" }); + }); + + test("rejects a local exclude target that is a symbolic link", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const outside = join(root, "outside-local-exclude"); + await writeFile(outside, "outside\n"); + await rm(join(root, ".git", "info", "exclude")); + await symlink(outside, join(root, ".git", "info", "exclude")); + + await expect( + reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "local", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }), + ).rejects.toMatchObject({ code: "MANAGED_IGNORE_RECONCILIATION_FAILED" }); + expect(await readFile(outside, "utf8")).toBe("outside\n"); + }); + + test("allows local reconciliation when only the tracked ignore target is a symbolic link", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const outside = join(root, "outside-tracked-ignore"); + await writeFile(outside, "outside\n"); + await symlink(outside, join(root, ".gitignore")); + + const result = await reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "local", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(result.targetType).toBe("local"); + expect(result.fileChanges.local).toBe(true); + expect(await readFile(outside, "utf8")).toBe("outside\n"); + }); + + test("rejects a tracked ignore target that is a symbolic link", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const outside = join(root, "outside-ignore"); + await writeFile(outside, "outside\n"); + await symlink(outside, join(root, ".gitignore")); + + await expect( + reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "tracked", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }), + ).rejects.toMatchObject({ code: "MANAGED_IGNORE_RECONCILIATION_FAILED" }); + expect(await readFile(outside, "utf8")).toBe("outside\n"); + }); + + test("restores preference state when applying the target file fails", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + await git(root, ["config", "--local", "arashi.ignoreScope", "none"]); + await chmod(root, 0o555); + + try { + await expect( + reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "tracked", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }), + ).rejects.toMatchObject({ + code: "MANAGED_IGNORE_RECONCILIATION_FAILED", + details: { attempted: true, changed: false, phase: "apply", restored: true }, + }); + } finally { + await chmod(root, 0o755); + } + + expect(await git(root, ["config", "--local", "--get", "arashi.ignoreScope"])).toBe("none"); + await expect(readFile(join(root, ".gitignore"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + test("applies a local owned block and restores the exact prior state", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const excludePath = join(root, ".git", "info", "exclude"); + const original = await readFile(excludePath, "utf8"); + + const result = await reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(await readFile(excludePath, "utf8")).toContain( + "# BEGIN Arashi managed ignore rules\n/repos/\n/.arashi/worktrees/\n# END Arashi managed ignore rules", + ); + expect(result).toMatchObject({ attempted: true, changed: true, restored: false }); + + await restoreManagedIgnore(result); + expect(await readFile(excludePath, "utf8")).toBe(original); + expect(result).toMatchObject({ attempted: true, changed: false, restored: true }); + }); + + test("discovers local and global sources and resolves linked-worktree common excludes", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + await git(root, ["config", "user.email", "test@example.com"]); + await git(root, ["config", "user.name", "Test User"]); + await writeFile(join(root, "README.md"), "root\n"); + await git(root, ["add", "README.md"]); + await git(root, ["commit", "-m", "initial"]); + const excludePath = join(root, ".git", "info", "exclude"); + await writeFile(excludePath, "repos/\n"); + const globalPath = join(root, "test-global-ignore"); + await writeFile(globalPath, ".arashi/worktrees/\n"); + await git(root, ["config", "--local", "core.excludesFile", globalPath]); + const linked = join(root, "linked"); + await git(root, ["worktree", "add", linked, "-b", "linked"]); + + const inspection = await inspectManagedIgnore({ + reposDir: "repos", + workspaceRoot: linked, + worktreesDir: ".arashi/worktrees", + }); + + expect(normalize(inspection.localExcludePath)).toBe(normalize(await realpath(excludePath))); + expect(inspection.paths.map((path) => path.source?.type)).toEqual(["local", "global"]); + }); + + test("removes only stale owned entries and remains idempotent", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const excludePath = join(root, ".git", "info", "exclude"); + await writeFile( + excludePath, + "user-rule/\n# BEGIN Arashi managed ignore rules\n/old-repos/\n/repos/\n# END Arashi managed ignore rules\n", + ); + + await reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + const firstContent = await readFile(excludePath, "utf8"); + const second = await reconcileManagedIgnore({ + reposDir: "repos", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(firstContent).toContain("user-rule/"); + expect(firstContent).not.toContain("old-repos/"); + expect(firstContent.match(/BEGIN Arashi/g)).toHaveLength(1); + expect(second.changed).toBe(false); + }); + + test("none scope persists preference, warns, and freezes stale owned content", async () => { + const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); + testRoots.push(root); + await git(root, ["init"]); + const excludePath = join(root, ".git", "info", "exclude"); + const content = + "# BEGIN Arashi managed ignore rules\nold-repos/\n# END Arashi managed ignore rules\n"; + await writeFile(excludePath, content); + + const result = await reconcileManagedIgnore({ + reposDir: "repos", + requestedScope: "none", + workspaceRoot: root, + worktreesDir: ".arashi/worktrees", + }); + + expect(result.staleRules).toEqual([{ path: excludePath, rule: "old-repos/", target: "local" }]); + expect(result.warnings).toHaveLength(3); + expect(await readFile(excludePath, "utf8")).toBe(content); + expect(await git(root, ["config", "--local", "--get", "arashi.ignoreScope"])).toBe("none"); + }); +});