From d5fc7af0a097c3a1457e0366d00b23d3034705c7 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 13:59:04 -0700 Subject: [PATCH 1/8] feat: reconcile managed Git ignore rules --- README.md | 22 +- contracts/cli-commands.json | 7 + docs/commands/init.md | 23 + docs/configuration.md | 14 + src/commands/add.ts | 110 +++-- src/commands/clone.ts | 23 + src/commands/create.ts | 38 +- src/commands/init.ts | 353 +++++++-------- src/commands/pull.ts | 124 ++++-- src/lib/doctor.ts | 83 ++++ src/lib/json-output.ts | 15 + src/lib/managed-ignore.ts | 515 ++++++++++++++++++++++ src/lib/pull-types.ts | 3 + tests/integration/clone.test.ts | 34 +- tests/integration/create.defaults.test.ts | 15 + tests/integration/doctor.test.ts | 2 +- tests/integration/init.test.ts | 128 +++++- tests/integration/json-cli-output.test.ts | 25 +- tests/integration/pull.test.ts | 40 +- tests/unit/doctor-managed-ignore.test.ts | 29 ++ tests/unit/lib/json-output.test.ts | 16 + tests/unit/managed-ignore.test.ts | 283 ++++++++++++ 22 files changed, 1615 insertions(+), 287 deletions(-) create mode 100644 docs/commands/init.md create mode 100644 src/lib/managed-ignore.ts create mode 100644 tests/unit/doctor-managed-ignore.test.ts create mode 100644 tests/unit/managed-ignore.test.ts 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..8c3a41f 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; @@ -132,6 +143,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 +394,7 @@ const executeAdd = async ( workspaceRoot: string, ): Promise => { const operations: RollbackOperation[] = []; + let managedIgnore: ManagedIgnoreReconciliation | undefined = undefined; try { // Step 1: Validate workspace is initialized @@ -419,6 +433,17 @@ 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(); try { @@ -476,11 +501,13 @@ const executeAdd = async ( clonePath, defaultBranch, gitUrl, + managedIgnore, repositoryName, setupScript, setupScriptCreated: false, }; } catch (error) { + let managedIgnoreRestoreError: string | undefined = undefined; // Rollback operations in reverse order const rollbackOperations = [...operations]; rollbackOperations.reverse(); @@ -496,6 +523,21 @@ const executeAdd = async ( } } + if (managedIgnore?.changed) { + 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, + ...(managedIgnoreRestoreError ? { managedIgnoreRestoreError } : {}), + }); + } + // Re-throw original error throw error; } @@ -570,24 +612,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 +635,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 +648,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..9065fd5 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 { @@ -312,6 +319,17 @@ export async function executeClone( .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) { @@ -360,6 +378,10 @@ export async function executeClone( await writeConfig(workspaceRoot, config); } + if (cloned.length === ZERO && managedIgnore.changed) { + await restoreManagedIgnore(managedIgnore); + } + if (failed.length > 0) { if (!options.json) { warn(`Clone completed with failures (${failed.length}).`); @@ -379,6 +401,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..31ba1c6 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -42,6 +42,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"]; @@ -154,12 +160,14 @@ interface CreateSummaryJsonOptions { branchName: string; dirtyWorkspaceGuidance?: ReturnType; moveSummary?: MoveSummary | null; + managedIgnore?: ManagedIgnoreReconciliation; summary: OperationSummary; } const createSummaryJsonData = ({ branchName, dirtyWorkspaceGuidance, + managedIgnore, moveSummary, summary, }: CreateSummaryJsonOptions) => ({ @@ -169,6 +177,7 @@ const createSummaryJsonData = ({ errorSummary: summary.errorSummary, failureCount: summary.failureCount, hookOutcomes: summary.hookOutcomes, + managedIgnore, moveSummary, nextSteps: summary.nextSteps, repositories: summary.repositoryResults.map((result) => ({ @@ -258,6 +267,7 @@ export interface CreateCommandDependencies { resolveCurrentBranch?: (path: string) => Promise; applyRepositoryFilter?: typeof applyRepositoryFilter; createCoordinatedWorktrees?: typeof createCoordinatedWorktrees; + reconcileManagedIgnore?: typeof reconcileManagedIgnore; launchSwitchTarget?: ( candidate: SwitchCandidate, options: { sesh?: boolean }, @@ -631,6 +641,7 @@ export async function executeCreate( }); const filterRepositories = deps.applyRepositoryFilter ?? applyRepositoryFilter; const runCreate = deps.createCoordinatedWorktrees ?? createCoordinatedWorktrees; + const reconcileIgnore = deps.reconcileManagedIgnore ?? reconcileManagedIgnore; // 1. Resolve invocation context and load configuration const context = await resolveInvocationContext(); @@ -807,8 +818,27 @@ export async function executeCreate( workspaceRoot: context.workspaceRoot, }; + // A bare repository has no work tree whose untracked paths Git can ignore. + const managedIgnore = + context.repositoryType === "bare" + ? undefined + : await reconcileIgnore({ + dryRun: options.dryRun, + reposDir: arashiConfig.reposDir, + workspaceRoot: context.workspaceRoot, + worktreesDir: arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json && managedIgnore) { + for (const warning of managedIgnore.warnings) { + warn(warning); + } + } + // 6. Execute coordinated worktree creation const summary = await runCreate(branchName, selectedRepos, worktreeOptions); + if (summary.rolledBack && managedIgnore?.changed) { + await restoreManagedIgnore(managedIgnore); + } const dirtyGuidanceContext = options.dryRun ? null : await resolvePostCreateDirtyGuidance(context, arashiConfig, branchName); @@ -825,7 +855,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..080ed7e 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,11 @@ 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 { + reconcileManagedIgnore, + restoreManagedIgnore, + type ManagedIgnoreReconciliation, +} from "../lib/managed-ignore.ts"; type Config = Parameters[1]; type RepoConfig = Config["repos"][string]; @@ -76,6 +80,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 +112,15 @@ 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; + + /** Whether only an existing workspace preference was reconciled */ + preferenceOnly?: boolean; } interface InitDependencies { @@ -129,7 +148,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 */ @@ -184,8 +203,10 @@ 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(); @@ -193,9 +214,13 @@ const executeRollback = async (): Promise => { for (const op of reversedOps) { try { 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}`); + if (!quiet) { + warn(` • Failed to rollback: ${op.path} - ${(error as Error).message}`); + } } } @@ -321,9 +346,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 +402,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 +616,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 // ============================================================================ @@ -712,7 +633,7 @@ export const executeInit = async ( 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 +648,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 +698,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 +718,7 @@ export const executeInit = async ( if (!isValidPath(reposDir)) { if (initRoot.bootstrapped && !options.dryRun) { - await executeRollback(); + await executeRollback(options.quiet); } return { @@ -786,7 +735,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 +752,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 +767,38 @@ 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 = 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 restoreManagedIgnore(managedIgnore); + }, + 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 +812,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 +829,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 +843,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 +860,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 +868,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 +884,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 +898,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 +925,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 +935,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 +956,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 +979,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 +993,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 +1009,7 @@ export const executeInit = async ( discoveredCount, exitCode: ExitCode.SUCCESS, hooksPath: hooksDir, + managedIgnore, reposPath: absoluteReposPath, success: true, workspaceRoot, @@ -1061,12 +1017,16 @@ 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 } + : {}), success: false, workspaceRoot: cwd, }; @@ -1077,7 +1037,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 +1058,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 +1147,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 +1198,14 @@ 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 ?? { + 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..6fe229e 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,58 @@ const executePull = async (options: PullCommandOptions): Promise => if (filterResult.emptyIntersection) { throw new CliUsageError("No repositories matched the combined --only/--group filters"); } + return 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)}`, + ); + }); - const repositories = filterResult.selected; + 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 = 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 +128,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 +190,39 @@ const executePull = async (options: PullCommandOptions): Promise => info(formatResultLine(lastResult)); } } + + const parentResult = results.at(-ONE); + if ( + repo.path === workspaceRoot && + parentResult?.repositoryId === repo.name && + parentResult.status === "updated" + ) { + repositoriesResult = await loadWorkspaceRepositories(workspaceRoot).catch((error): never => { + throw new CliUsageError( + `Failed to reload pulled workspace configuration: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + const postPullSelection = excludeWorkspaceRoot( + selectRepositories(repositoriesResult.repositories, options), + workspaceRoot, + ); + repositories.splice(index + ONE, repositories.length, ...postPullSelection); + total = repositories.length; + timeoutMs = repositoriesResult.config.hooks?.timeout; + 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}`); + } + } + } } - 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..a343cfd --- /dev/null +++ b/src/lib/managed-ignore.ts @@ -0,0 +1,515 @@ +import { dirname, isAbsolute, posix, resolve, win32 } from "path"; +import { 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" | "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 { + fileContent: string | null; + filePath?: 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 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 rule = `${normalized}/`; + 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 { + // Git exits 1 when a config key is absent. + } + 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 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 source = await inspectEffectiveSource( + workspaceRoot, + classification.rule, + 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 trackedIgnorePath = resolve(workspaceRoot, ".gitignore"); + 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 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 { + // The default is represented by an absent key. + } + 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; + let targetPath: string | undefined; + if (targetType === "local") { + targetPath = inspection.localExcludePath; + } else if (targetType === "tracked") { + targetPath = inspection.trackedIgnorePath; + } + const targetContent = targetPath ? await readOptionalFile(targetPath) : null; + const currentOwnedRules = getOwnedRules(targetContent); + const safeRules = new Set( + inspection.paths.flatMap((path) => (path.safety === "safe" && path.rule ? [path.rule] : [])), + ); + const staleRules: ManagedIgnoreStaleRule[] = targetType + ? currentOwnedRules + .filter((rule) => !safeRules.has(rule)) + .map((rule) => ({ path: targetPath ?? "", rule, target: targetType })) + : inspection.staleRules; + const missingPaths = inspection.paths.filter( + (path) => path.safety === "safe" && path.status === "unignored" && 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 desiredRules = [ + ...currentOwnedRules.filter((rule) => safeRules.has(rule)), + ...plannedRules, + ].filter((rule, index, rules) => rules.indexOf(rule) === index); + const nextContent = targetPath ? replaceOwnedBlock(targetContent, desiredRules) : targetContent; + const fileWouldChange = targetPath !== undefined && nextContent !== targetContent; + const preferenceWouldChange = + options.requestedScope !== undefined && + (inspection.scope === "local" + ? inspection.storedPreference !== null + : inspection.storedPreference !== inspection.scope); + const result: ManagedIgnoreReconciliation = { + ...inspection, + appliedRules: [], + attempted: fileWouldChange || 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, + }; + + reconciliationSnapshots.set(result, { + fileContent: targetContent, + filePath: targetPath, + preference: inspection.storedPreference, + workspaceRoot: options.workspaceRoot, + }); + if (options.dryRun) { + return result; + } + + let mutationStarted = false; + try { + if (preferenceWouldChange) { + mutationStarted = true; + await updateStoredPreference(options.workspaceRoot, inspection.scope); + result.fileChanges.preference = true; + } + if (fileWouldChange && targetPath && nextContent !== null) { + mutationStarted = true; + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, nextContent); + result.fileChanges[targetType as "local" | "tracked"] = 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 = fileWouldChange || 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."); + } + if (snapshot.filePath) { + if (snapshot.fileContent === null) { + try { + await unlink(snapshot.filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } else { + await writeFile(snapshot.filePath, snapshot.fileContent); + } + } + 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/integration/clone.test.ts b/tests/integration/clone.test.ts index 4554113..11059ac 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 }); diff --git a/tests/integration/create.defaults.test.ts b/tests/integration/create.defaults.test.ts index 66d7547..d13ab28 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, 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..ac3a178 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; @@ -491,7 +573,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 +581,8 @@ describe("init command - rollback behavior", () => { // Verify .arashi directory removed expect(await fileExists(join(testDir, ".arashi"))).toBe(false); + // Managed ignore state is part of the same rollback boundary. + expect(await fileExists(join(testDir, ".gitignore"))).toBe(false); }); test("rolls back when config write fails due to permissions", async () => { @@ -593,7 +677,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 +696,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 +801,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:"); @@ -876,13 +960,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 +1026,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..03a39c7 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,27 @@ 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" }); + }); + 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..16c0811 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,44 @@ describe("pull command", () => { 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..e0acc45 --- /dev/null +++ b/tests/unit/managed-ignore.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { join } 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("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("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 () => { + 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"]); + await git(root, ["config", "--local", "core.excludesFile", root]); + + 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("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\nrepos/\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(inspection.localExcludePath).toBe(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\nold-repos/\nrepos/\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"); + }); +}); From 05c4708292b241169ea51f46886f40436da05c2d Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 14:09:30 -0700 Subject: [PATCH 2/8] test: make managed ignore coverage portable --- tests/unit/managed-ignore.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts index e0acc45..4a8c43f 100644 --- a/tests/unit/managed-ignore.test.ts +++ b/tests/unit/managed-ignore.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "vitest"; import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, normalize } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "../helpers/node-runtime.ts"; import { @@ -69,6 +69,9 @@ describe("managed ignore path classification", () => { }); 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"]); @@ -113,7 +116,8 @@ describe("managed ignore path classification", () => { const root = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-")); testRoots.push(root); await git(root, ["init"]); - await git(root, ["config", "--local", "core.excludesFile", root]); + const invalidExcludePath = process.platform === "win32" ? join(root, "invalid:exclude") : root; + await git(root, ["config", "--local", "core.excludesFile", invalidExcludePath]); await expect( reconcileManagedIgnore({ @@ -227,7 +231,7 @@ describe("managed ignore path classification", () => { worktreesDir: ".arashi/worktrees", }); - expect(inspection.localExcludePath).toBe(await realpath(excludePath)); + expect(normalize(inspection.localExcludePath)).toBe(normalize(await realpath(excludePath))); expect(inspection.paths.map((path) => path.source?.type)).toEqual(["local", "global"]); }); From 5ed99733825778ecf66c589b0a274780b067adc8 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 14:15:14 -0700 Subject: [PATCH 3/8] test: force fatal ignore inspection on Windows --- tests/unit/managed-ignore.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts index 4a8c43f..47d6f0d 100644 --- a/tests/unit/managed-ignore.test.ts +++ b/tests/unit/managed-ignore.test.ts @@ -117,6 +117,9 @@ describe("managed ignore path classification", () => { 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( From b37c7ebc07fdcc576a1669bc194f35e12940437b Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 15:08:08 -0700 Subject: [PATCH 4/8] fix: address managed ignore review feedback --- src/commands/add.ts | 47 ++++-- src/commands/clone.ts | 27 +++- src/commands/create.ts | 57 +++++-- src/commands/init.ts | 128 +++++++++++++-- src/commands/pull.ts | 83 ++++++---- src/lib/managed-ignore.ts | 187 ++++++++++++++++------ tests/integration/clone.test.ts | 38 +++++ tests/integration/create.defaults.test.ts | 84 ++++++++++ tests/integration/init.test.ts | 57 ++++++- tests/integration/json-cli-output.test.ts | 1 + tests/integration/pull.test.ts | 18 +++ tests/unit/managed-ignore.test.ts | 113 ++++++++++++- 12 files changed, 703 insertions(+), 137 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 8c3a41f..1772966 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -395,6 +395,7 @@ const executeAdd = async ( ): Promise => { const operations: RollbackOperation[] = []; let managedIgnore: ManagedIgnoreReconciliation | undefined = undefined; + const startSpinner = (text: string) => (options.json ? undefined : spinner(text).start()); try { // Step 1: Validate workspace is initialized @@ -408,9 +409,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; @@ -445,13 +446,17 @@ const executeAdd = async ( } // 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"); + if (!clonePathExistedBefore && (await runtime.file(clonePath).exists())) { + 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, @@ -460,24 +465,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, @@ -486,9 +491,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, @@ -508,6 +513,7 @@ const executeAdd = async ( }; } catch (error) { let managedIgnoreRestoreError: string | undefined = undefined; + let materializedStateSurvives = false; // Rollback operations in reverse order const rollbackOperations = [...operations]; rollbackOperations.reverse(); @@ -518,12 +524,18 @@ 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) { + if (managedIgnore?.changed && !materializedStateSurvives) { try { await restoreManagedIgnore(managedIgnore); } catch (restoreError) { @@ -534,6 +546,7 @@ const executeAdd = async ( throw new AddCommandError(error.message, error.code, { ...error.context, managedIgnore, + materializedStateSurvives, ...(managedIgnoreRestoreError ? { managedIgnoreRestoreError } : {}), }); } diff --git a/src/commands/clone.ts b/src/commands/clone.ts index 9065fd5..44ea132 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -315,6 +315,7 @@ 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)); @@ -359,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, @@ -374,11 +386,18 @@ 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 && managedIgnore.changed) { + if (cloned.length === ZERO && !residualMaterializedState && managedIgnore.changed) { await restoreManagedIgnore(managedIgnore); } diff --git a/src/commands/create.ts b/src/commands/create.ts index 31ba1c6..ed06140 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -16,6 +16,7 @@ import { createCoordinatedWorktrees, } from "../core/worktree.ts"; import { basename, resolve } from "path"; +import { existsSync } from "node:fs"; import { buildDirtyGuidance, buildMovePlan, @@ -261,6 +262,7 @@ export interface ResolvedCreateDefaults { export interface CreateCommandDependencies { resolveCreateInvocationContext?: (invocationPath?: string) => Promise; + resolveManagedIgnoreWorkspaceRoot?: (context: CreateInvocationContext) => Promise; loadConfigWithFallback?: typeof loadConfigWithFallback; discoverRepositories?: typeof discoverRepositories; isGitRepository?: (path: string) => Promise; @@ -268,6 +270,8 @@ export interface CreateCommandDependencies { applyRepositoryFilter?: typeof applyRepositoryFilter; createCoordinatedWorktrees?: typeof createCoordinatedWorktrees; reconcileManagedIgnore?: typeof reconcileManagedIgnore; + restoreManagedIgnore?: typeof restoreManagedIgnore; + pathExists?: (path: string) => boolean; launchSwitchTarget?: ( candidate: SwitchCandidate, options: { sesh?: boolean }, @@ -321,6 +325,29 @@ 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()); + const linkedWorktree = worktreePaths.find( + (path) => resolve(path) !== resolve(context.executionPath), + ); + if (!linkedWorktree) { + throw new CreateSetupError( + "Managed ignore reconciliation from a bare repository requires an existing linked worktree.", + ); + } + return linkedWorktree; +} + const isGitRepository = async (path: string): Promise => { try { await exec(["rev-parse", "--git-dir"], path); @@ -630,6 +657,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; @@ -642,6 +671,8 @@ 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(); @@ -818,17 +849,14 @@ export async function executeCreate( workspaceRoot: context.workspaceRoot, }; - // A bare repository has no work tree whose untracked paths Git can ignore. - const managedIgnore = - context.repositoryType === "bare" - ? undefined - : await reconcileIgnore({ - dryRun: options.dryRun, - reposDir: arashiConfig.reposDir, - workspaceRoot: context.workspaceRoot, - worktreesDir: arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, - }); - if (!options.json && managedIgnore) { + const managedIgnoreWorkspaceRoot = await resolveIgnoreWorkspaceRoot(context); + const managedIgnore = await reconcileIgnore({ + dryRun: options.dryRun, + reposDir: arashiConfig.reposDir, + workspaceRoot: managedIgnoreWorkspaceRoot, + worktreesDir: arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, + }); + if (!options.json) { for (const warning of managedIgnore.warnings) { warn(warning); } @@ -836,8 +864,11 @@ export async function executeCreate( // 6. Execute coordinated worktree creation const summary = await runCreate(branchName, selectedRepos, worktreeOptions); - if (summary.rolledBack && managedIgnore?.changed) { - await restoreManagedIgnore(managedIgnore); + 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 diff --git a/src/commands/init.ts b/src/commands/init.ts index 080ed7e..6a03fce 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -42,9 +42,11 @@ 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]; @@ -58,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 // ============================================================================ @@ -119,6 +188,9 @@ interface InitResult { /** 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; } @@ -136,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; } @@ -193,6 +268,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 */ @@ -210,6 +296,7 @@ const executeRollback = async (quiet = false): Promise => { const reversedOps = [...operations]; reversedOps.reverse(); + const failures: Array<{ message: string; path: string }> = []; for (const op of reversedOps) { try { @@ -218,6 +305,10 @@ const executeRollback = async (quiet = false): Promise => { info(` • Rolled back: ${op.path}`); } } catch (error) { + 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}`); } @@ -225,6 +316,9 @@ const executeRollback = async (quiet = false): Promise => { } operations.length = ZERO; + if (failures.length > ZERO) { + throw new InitRollbackError(failures); + } }; // ============================================================================ @@ -629,6 +723,7 @@ export const executeInit = async ( ): Promise => { const startTime = Date.now(); const cwd = deps.cwd ?? process.cwd(); + const restoreIgnore = deps.restoreManagedIgnore ?? restoreManagedIgnore; operations.length = ZERO; @@ -769,13 +864,16 @@ export const executeInit = async ( // Reconcile before any managed directories are materialized. logVerbose("Reconciling managed Git ignore rules...", options); - const managedIgnore = await reconcileManagedIgnore({ - dryRun: options.dryRun, - reposDir, - requestedScope: options.ignoreScope, - workspaceRoot, - worktreesDir, - }); + 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", @@ -788,7 +886,7 @@ export const executeInit = async ( addOperation({ path: managedIgnore.targetPath ?? "clone-local arashi.ignoreScope", rollback: async () => { - await restoreManagedIgnore(managedIgnore); + await restoreIgnore(managedIgnore); }, type: "MANAGED_IGNORE", }); @@ -1027,6 +1125,9 @@ export const executeInit = async ( ...(structuredError.code === "MANAGED_IGNORE_RECONCILIATION_FAILED" ? { managedIgnoreFailure: structuredError } : {}), + ...(structuredError.code === "INIT_ROLLBACK_FAILED" + ? { rollbackFailure: structuredError } + : {}), success: false, workspaceRoot: cwd, }; @@ -1200,11 +1301,12 @@ Existing effective tracked, local, or global rules are honored. Arashi never mod writeJsonEnvelope( createJsonErrorEnvelope( "init", - result.managedIgnoreFailure ?? { - code: `INIT_${result.exitCode}`, - details: { exitCode: result.exitCode, workspaceRoot: result.workspaceRoot }, - message: result.error || "Unknown error", - }, + result.managedIgnoreFailure ?? + result.rollbackFailure ?? { + code: `INIT_${result.exitCode}`, + details: { exitCode: result.exitCode, workspaceRoot: result.workspaceRoot }, + message: result.error || "Unknown error", + }, ), ); } else { diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 6fe229e..41fe73e 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -100,14 +100,17 @@ const executePull = async (options: PullCommandOptions): Promise => ...repositories.filter((repository) => repository !== selectedParent), ]; } - let 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}`); + 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) { @@ -192,33 +195,47 @@ const executePull = async (options: PullCommandOptions): Promise => } const parentResult = results.at(-ONE); - if ( - repo.path === workspaceRoot && - parentResult?.repositoryId === repo.name && - parentResult.status === "updated" - ) { - repositoriesResult = await loadWorkspaceRepositories(workspaceRoot).catch((error): never => { - throw new CliUsageError( - `Failed to reload pulled workspace configuration: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - const postPullSelection = excludeWorkspaceRoot( - selectRepositories(repositoriesResult.repositories, options), - workspaceRoot, - ); - repositories.splice(index + ONE, repositories.length, ...postPullSelection); - total = repositories.length; - timeoutMs = repositoriesResult.config.hooks?.timeout; - 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 (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; + } } } diff --git a/src/lib/managed-ignore.ts b/src/lib/managed-ignore.ts index a343cfd..9c0ff60 100644 --- a/src/lib/managed-ignore.ts +++ b/src/lib/managed-ignore.ts @@ -1,5 +1,5 @@ import { dirname, isAbsolute, posix, resolve, win32 } from "path"; -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +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"; @@ -11,7 +11,11 @@ export interface SafeManagedPath { safety: "safe"; } -export type UnsafeManagedPathReason = "absolute" | "parent-traversal" | "repository-root"; +export type UnsafeManagedPathReason = + | "absolute" + | "control-character" + | "parent-traversal" + | "repository-root"; export interface UnsafeManagedPath { input: string; @@ -93,8 +97,7 @@ export class ManagedIgnoreError extends Error { } interface ManagedIgnoreSnapshot { - fileContent: string | null; - filePath?: string; + files: Array<{ content: string | null; path: string }>; preference: string | null; workspaceRoot: string; } @@ -108,6 +111,14 @@ export const classifyManagedPaths = (paths: string[]): ManagedPathClassification 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 === ".") { @@ -123,7 +134,8 @@ export const classifyManagedPaths = (paths: string[]): ManagedPathClassification continue; } - const rule = `${normalized}/`; + const escaped = normalized.replace(/([*?[\]])/g, "\\$1").replace(/^([#!])/, "\\$1"); + const rule = `/${escaped}/`; if (seen.has(rule)) { continue; } @@ -205,8 +217,10 @@ export const inspectManagedIgnore = async ({ workspaceRoot, ); storedValue = result.stdout.trim() || null; - } catch { - // Git exits 1 when a config key is absent. + } 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)) { @@ -221,6 +235,8 @@ export const inspectManagedIgnore = async ({ 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"); + await assertNotSymlink(trackedIgnorePath); const classifications = classifyManagedPaths([reposDir, worktreesDir]); const paths: ManagedIgnorePathResult[] = []; @@ -234,11 +250,11 @@ export const inspectManagedIgnore = async ({ }); continue; } - const source = await inspectEffectiveSource( - workspaceRoot, - classification.rule, - localExcludePath, - ); + const effectivePath = `${posix + .normalize(classification.input.replaceAll("\\", "/")) + .replace(/^\.\//, "") + .replace(/\/$/, "")}/`; + const source = await inspectEffectiveSource(workspaceRoot, effectivePath, localExcludePath); paths.push({ input: classification.input, rule: classification.rule, @@ -249,7 +265,6 @@ export const inspectManagedIgnore = async ({ } const safeRules = new Set(paths.flatMap((path) => (path.rule ? [path.rule] : []))); - const trackedIgnorePath = resolve(workspaceRoot, ".gitignore"); const staleRules: ManagedIgnoreStaleRule[] = []; for (const [target, path] of [ ["local", localExcludePath], @@ -282,6 +297,19 @@ const readOptionalFile = async (path: string): Promise => { } }; +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 []; @@ -331,8 +359,10 @@ const updateStoredPreference = async ( if (scope === "local") { try { await gitExec(["config", "--local", "--unset-all", "arashi.ignoreScope"], workspaceRoot); - } catch { - // The default is represented by an absent key. + } catch (error) { + if (!(error instanceof ArashiError && error.context.exitCode === 5)) { + throw error; + } } return; } @@ -353,25 +383,51 @@ export const reconcileManagedIgnore = async ( ); } const targetType = inspection.scope === "none" ? undefined : inspection.scope; - let targetPath: string | undefined; - if (targetType === "local") { - targetPath = inspection.localExcludePath; - } else if (targetType === "tracked") { - targetPath = inspection.trackedIgnorePath; - } - const targetContent = targetPath ? await readOptionalFile(targetPath) : null; - const currentOwnedRules = getOwnedRules(targetContent); + 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: ManagedIgnoreStaleRule[] = targetType - ? currentOwnedRules - .filter((rule) => !safeRules.has(rule)) - .map((rule) => ({ path: targetPath ?? "", rule, target: targetType })) - : inspection.staleRules; - const missingPaths = inspection.paths.filter( - (path) => path.safety === "safe" && path.status === "unignored" && 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" @@ -385,12 +441,37 @@ export const reconcileManagedIgnore = async ( ), ] : []; - const desiredRules = [ - ...currentOwnedRules.filter((rule) => safeRules.has(rule)), - ...plannedRules, - ].filter((rule, index, rules) => rules.indexOf(rule) === index); - const nextContent = targetPath ? replaceOwnedBlock(targetContent, desiredRules) : targetContent; - const fileWouldChange = targetPath !== undefined && nextContent !== targetContent; + 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" @@ -399,7 +480,7 @@ export const reconcileManagedIgnore = async ( const result: ManagedIgnoreReconciliation = { ...inspection, appliedRules: [], - attempted: fileWouldChange || preferenceWouldChange, + attempted: filePlans.length > 0 || preferenceWouldChange, changed: false, fileChanges: { local: false, preference: false, tracked: false }, paths: inspection.paths.map((path) => @@ -416,8 +497,10 @@ export const reconcileManagedIgnore = async ( }; reconciliationSnapshots.set(result, { - fileContent: targetContent, - filePath: targetPath, + files: (["local", "tracked"] as const).map((type) => ({ + content: fileStates[type].content, + path: fileStates[type].path, + })), preference: inspection.storedPreference, workspaceRoot: options.workspaceRoot, }); @@ -432,13 +515,17 @@ export const reconcileManagedIgnore = async ( await updateStoredPreference(options.workspaceRoot, inspection.scope); result.fileChanges.preference = true; } - if (fileWouldChange && targetPath && nextContent !== null) { + for (const type of filePlans) { + const nextContent = nextContents[type]; + if (nextContent === null) { + continue; + } mutationStarted = true; - await mkdir(dirname(targetPath), { recursive: true }); - await writeFile(targetPath, nextContent); - result.fileChanges[targetType as "local" | "tracked"] = true; - result.appliedRules = plannedRules; + await mkdir(dirname(fileStates[type].path), { recursive: true }); + await writeFile(fileStates[type].path, nextContent); + result.fileChanges[type] = true; } + result.appliedRules = plannedRules; } catch (error) { result.changed = mutationStarted; if (mutationStarted) { @@ -477,7 +564,7 @@ export const reconcileManagedIgnore = async ( { cause: error }, ); } - result.changed = fileWouldChange || preferenceWouldChange; + result.changed = filePlans.length > 0 || preferenceWouldChange; return result; }; @@ -488,17 +575,17 @@ export const restoreManagedIgnore = async ( if (!snapshot) { throw new Error("Managed ignore reconciliation cannot be restored."); } - if (snapshot.filePath) { - if (snapshot.fileContent === null) { + for (const file of snapshot.files) { + if (file.content === null) { try { - await unlink(snapshot.filePath); + await unlink(file.path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } } else { - await writeFile(snapshot.filePath, snapshot.fileContent); + await writeFile(file.path, file.content); } } if (snapshot.preference === null) { diff --git a/tests/integration/clone.test.ts b/tests/integration/clone.test.ts index 11059ac..58aa960 100644 --- a/tests/integration/clone.test.ts +++ b/tests/integration/clone.test.ts @@ -218,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.defaults.test.ts b/tests/integration/create.defaults.test.ts index d13ab28..6bf182f 100644 --- a/tests/integration/create.defaults.test.ts +++ b/tests/integration/create.defaults.test.ts @@ -244,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/init.test.ts b/tests/integration/init.test.ts index ac3a178..a59ef14 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -533,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(); @@ -585,6 +612,34 @@ describe("init command - rollback behavior", () => { expect(await fileExists(join(testDir, ".gitignore"))).toBe(false); }); + test("reports incomplete managed-ignore rollback details", async () => { + await writeFile(join(testDir, "repos"), "blocks directory creation"); + + const result = await executeInit( + { noDiscover: true, quiet: true }, + { + cwd: testDir, + restoreManagedIgnore: async () => { + throw new Error("simulated restore failure"); + }, + }, + ); + + expect(result).toMatchObject({ + rollbackFailure: { + code: "INIT_ROLLBACK_FAILED", + details: { + failures: [ + expect.objectContaining({ + message: "simulated restore failure", + }), + ], + }, + }, + success: false, + }); + }); + test("rolls back when config write fails due to permissions", async () => { // This test requires platform-specific permission handling // Skip on Windows where chmod behaves differently @@ -897,7 +952,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/"); }); diff --git a/tests/integration/json-cli-output.test.ts b/tests/integration/json-cli-output.test.ts index 03a39c7..2aed8ac 100644 --- a/tests/integration/json-cli-output.test.ts +++ b/tests/integration/json-cli-output.test.ts @@ -538,6 +538,7 @@ describe("CLI JSON output contract", () => { 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 () => { diff --git a/tests/integration/pull.test.ts b/tests/integration/pull.test.ts index 16c0811..a0fc6f2 100644 --- a/tests/integration/pull.test.ts +++ b/tests/integration/pull.test.ts @@ -196,6 +196,24 @@ 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 () => { diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts index 47d6f0d..53b5d45 100644 --- a/tests/unit/managed-ignore.test.ts +++ b/tests/unit/managed-ignore.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "vitest"; -import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +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"; @@ -32,8 +32,18 @@ afterEach(async () => { 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" }, + { 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" }, ]); }); @@ -49,6 +59,13 @@ describe("managed ignore path classification", () => { ]); }); + 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); @@ -62,7 +79,7 @@ describe("managed ignore path classification", () => { }); expect(inspection.paths[0]).toMatchObject({ - rule: "repos/", + rule: "/repos/", source: { pattern: "repos/", type: "tracked" }, status: "already-ignored", }); @@ -156,6 +173,90 @@ describe("managed ignore path classification", () => { 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 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; @@ -202,7 +303,7 @@ describe("managed ignore path classification", () => { }); expect(await readFile(excludePath, "utf8")).toContain( - "# BEGIN Arashi managed ignore rules\nrepos/\n.arashi/worktrees/\n# END Arashi managed ignore rules", + "# 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 }); @@ -245,7 +346,7 @@ describe("managed ignore path classification", () => { const excludePath = join(root, ".git", "info", "exclude"); await writeFile( excludePath, - "user-rule/\n# BEGIN Arashi managed ignore rules\nold-repos/\nrepos/\n# END Arashi managed ignore rules\n", + "user-rule/\n# BEGIN Arashi managed ignore rules\n/old-repos/\n/repos/\n# END Arashi managed ignore rules\n", ); await reconcileManagedIgnore({ From 3a2d9ff7d7fddfc50792d8931cf5fc961bff2f28 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 15:13:59 -0700 Subject: [PATCH 5/8] fix: resolve bare worktree reconciliation portably --- src/commands/create.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/commands/create.ts b/src/commands/create.ts index ed06140..dc375bb 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -337,15 +337,19 @@ export async function resolveManagedIgnoreWorkspaceRoot( .split(/\r?\n/) .filter((line) => line.startsWith("worktree ")) .map((line) => line.slice("worktree ".length).trim()); - const linkedWorktree = worktreePaths.find( - (path) => resolve(path) !== resolve(context.executionPath), - ); - if (!linkedWorktree) { - throw new CreateSetupError( - "Managed ignore reconciliation from a bare repository requires an existing linked worktree.", - ); + 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. + } } - return linkedWorktree; + throw new CreateSetupError( + "Managed ignore reconciliation from a bare repository requires an existing linked worktree.", + ); } const isGitRepository = async (path: string): Promise => { From 4bb8768cf3c6d7195e51d532c21b9cc3e6dfa34e Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 15:26:19 -0700 Subject: [PATCH 6/8] fix: reject symlinked local ignore targets --- src/lib/managed-ignore.ts | 3 +++ tests/unit/managed-ignore.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/lib/managed-ignore.ts b/src/lib/managed-ignore.ts index 9c0ff60..1743309 100644 --- a/src/lib/managed-ignore.ts +++ b/src/lib/managed-ignore.ts @@ -236,6 +236,7 @@ export const inspectManagedIgnore = async ({ const localPathResult = await gitExec(["rev-parse", "--git-path", "info/exclude"], workspaceRoot); const localExcludePath = resolveGitPath(workspaceRoot, localPathResult.stdout.trim()); const trackedIgnorePath = resolve(workspaceRoot, ".gitignore"); + await assertNotSymlink(localExcludePath); await assertNotSymlink(trackedIgnorePath); const classifications = classifyManagedPaths([reposDir, worktreesDir]); const paths: ManagedIgnorePathResult[] = []; @@ -522,6 +523,7 @@ export const reconcileManagedIgnore = async ( } mutationStarted = true; await mkdir(dirname(fileStates[type].path), { recursive: true }); + await assertNotSymlink(fileStates[type].path); await writeFile(fileStates[type].path, nextContent); result.fileChanges[type] = true; } @@ -585,6 +587,7 @@ export const restoreManagedIgnore = async ( } } } else { + await assertNotSymlink(file.path); await writeFile(file.path, file.content); } } diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts index 53b5d45..8785b11 100644 --- a/tests/unit/managed-ignore.test.ts +++ b/tests/unit/managed-ignore.test.ts @@ -235,6 +235,29 @@ describe("managed ignore path classification", () => { ).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("rejects a tracked ignore target that is a symbolic link", async () => { if (process.platform === "win32") { return; From 906d3bfb4ea6bf4cb47994faefce01b13d3b5db5 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 15:48:02 -0700 Subject: [PATCH 7/8] fix: address final managed ignore review findings --- src/commands/add.ts | 13 +++- src/commands/create.ts | 63 ++++++++++++++++--- src/commands/init.ts | 12 ++++ src/lib/managed-ignore.ts | 16 ++--- tests/helpers/create-bare-create-workspace.ts | 6 +- tests/integration/add.test.ts | 6 ++ .../create.bare-root-success.test.ts | 26 ++++++++ tests/integration/init.test.ts | 27 +++----- tests/unit/managed-ignore.test.ts | 23 +++++++ 9 files changed, 154 insertions(+), 38 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 1772966..e32435f 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -127,6 +127,9 @@ export interface AddCommandOptions { json?: boolean; } +export const shouldTreatFailedCloneAsMaterialized = (destinationExists: boolean): boolean => + destinationExists; + /** * Result of add operation */ @@ -394,6 +397,7 @@ 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()); @@ -453,7 +457,12 @@ const executeAdd = async ( operations.push({ path: clonePath, reversible: true, type: "clone" }); s2?.succeed("Repository cloned"); } catch (error) { - if (!clonePathExistedBefore && (await runtime.file(clonePath).exists())) { + 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"); @@ -513,7 +522,7 @@ const executeAdd = async ( }; } catch (error) { let managedIgnoreRestoreError: string | undefined = undefined; - let materializedStateSurvives = false; + let materializedStateSurvives = existingFailedCloneDestinationSurvives; // Rollback operations in reverse order const rollbackOperations = [...operations]; rollbackOperations.reverse(); diff --git a/src/commands/create.ts b/src/commands/create.ts index dc375bb..b70cd11 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -15,8 +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, @@ -62,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"] >; @@ -347,11 +351,32 @@ export async function resolveManagedIgnoreWorkspaceRoot( // Ignore stale worktree-list entries and continue looking for a usable work tree. } } - throw new CreateSetupError( - "Managed ignore reconciliation from a bare repository requires an existing linked worktree.", - ); + const temporaryParent = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-worktree-")); + const temporaryWorktree = join(temporaryParent, "worktree"); + try { + await exec(["worktree", "add", "--detach", temporaryWorktree, "HEAD"], 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); @@ -854,12 +879,30 @@ export async function executeCreate( }; const managedIgnoreWorkspaceRoot = await resolveIgnoreWorkspaceRoot(context); - const managedIgnore = await reconcileIgnore({ - dryRun: options.dryRun, - reposDir: arashiConfig.reposDir, - workspaceRoot: managedIgnoreWorkspaceRoot, - worktreesDir: arashiConfig.worktreesDir ?? DEFAULT_WORKTREES_DIR, - }); + 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); diff --git a/src/commands/init.ts b/src/commands/init.ts index 6a03fce..e95e008 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -237,6 +237,9 @@ interface Operation { /** Rollback function */ rollback: () => Promise; + + /** Final-state guard for rollbacks that would remove required coverage */ + shouldRollback?: () => Promise; } interface InitResolution { @@ -300,6 +303,12 @@ const executeRollback = async (quiet = false): Promise => { 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(); if (!quiet) { info(` • Rolled back: ${op.path}`); @@ -888,6 +897,9 @@ export const executeInit = async ( rollback: async () => { await restoreIgnore(managedIgnore); }, + shouldRollback: async () => + !(await fileExists(absoluteReposPath)) && + !(await fileExists(resolve(workspaceRoot, worktreesDir))), type: "MANAGED_IGNORE", }); } diff --git a/src/lib/managed-ignore.ts b/src/lib/managed-ignore.ts index 1743309..d994fe3 100644 --- a/src/lib/managed-ignore.ts +++ b/src/lib/managed-ignore.ts @@ -236,8 +236,6 @@ export const inspectManagedIgnore = async ({ const localPathResult = await gitExec(["rev-parse", "--git-path", "info/exclude"], workspaceRoot); const localExcludePath = resolveGitPath(workspaceRoot, localPathResult.stdout.trim()); const trackedIgnorePath = resolve(workspaceRoot, ".gitignore"); - await assertNotSymlink(localExcludePath); - await assertNotSymlink(trackedIgnorePath); const classifications = classifyManagedPaths([reposDir, worktreesDir]); const paths: ManagedIgnorePathResult[] = []; @@ -497,14 +495,12 @@ export const reconcileManagedIgnore = async ( warnings, }; - reconciliationSnapshots.set(result, { - files: (["local", "tracked"] as const).map((type) => ({ - content: fileStates[type].content, - path: fileStates[type].path, - })), + const snapshot: ManagedIgnoreSnapshot = { + files: [], preference: inspection.storedPreference, workspaceRoot: options.workspaceRoot, - }); + }; + reconciliationSnapshots.set(result, snapshot); if (options.dryRun) { return result; } @@ -524,6 +520,10 @@ export const reconcileManagedIgnore = async ( 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; } diff --git a/tests/helpers/create-bare-create-workspace.ts b/tests/helpers/create-bare-create-workspace.ts index b9119a5..ed15974 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,9 @@ 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); + } 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/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/init.test.ts b/tests/integration/init.test.ts index a59ef14..2b409bc 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -608,36 +608,29 @@ describe("init command - rollback behavior", () => { // Verify .arashi directory removed expect(await fileExists(join(testDir, ".arashi"))).toBe(false); - // Managed ignore state is part of the same rollback boundary. - expect(await fileExists(join(testDir, ".gitignore"))).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("reports incomplete managed-ignore rollback details", async () => { + 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 () => { - throw new Error("simulated restore failure"); + restoreCalled = true; }, }, ); - expect(result).toMatchObject({ - rollbackFailure: { - code: "INIT_ROLLBACK_FAILED", - details: { - failures: [ - expect.objectContaining({ - message: "simulated restore failure", - }), - ], - }, - }, - success: false, - }); + 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 () => { diff --git a/tests/unit/managed-ignore.test.ts b/tests/unit/managed-ignore.test.ts index 8785b11..d6a6a3c 100644 --- a/tests/unit/managed-ignore.test.ts +++ b/tests/unit/managed-ignore.test.ts @@ -258,6 +258,29 @@ describe("managed ignore path classification", () => { 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; From 5316f5f9fe49594ca4ac9b0f7ef57b6699dfd0fb Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 14 Jul 2026 15:51:13 -0700 Subject: [PATCH 8/8] fix: resolve bare create source branch portably --- src/commands/create.ts | 15 ++++++++++++++- tests/helpers/create-bare-create-workspace.ts | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/create.ts b/src/commands/create.ts index b70cd11..c3bafcd 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -354,7 +354,20 @@ export async function resolveManagedIgnoreWorkspaceRoot( const temporaryParent = await mkdtemp(join(tmpdir(), "arashi-managed-ignore-worktree-")); const temporaryWorktree = join(temporaryParent, "worktree"); try { - await exec(["worktree", "add", "--detach", temporaryWorktree, "HEAD"], context.executionPath); + 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; diff --git a/tests/helpers/create-bare-create-workspace.ts b/tests/helpers/create-bare-create-workspace.ts index ed15974..db44dc4 100644 --- a/tests/helpers/create-bare-create-workspace.ts +++ b/tests/helpers/create-bare-create-workspace.ts @@ -67,6 +67,8 @@ export async function createBareCreateWorkspace( if (createLinkedWorktree) { await execGit(["worktree", "add", worktreePath, "main"], bareRepoPath); + } else { + await execGit(["symbolic-ref", "HEAD", "refs/heads/unborn"], bareRepoPath); } return {