From f4c99cdf30316520b8c985289f312cfe15dbee53 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 23 Feb 2026 23:53:27 -0800 Subject: [PATCH 1/2] Initial From 31d21d2b0a63512e0888319c956e2b5c7d9c13d4 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 23 Feb 2026 23:55:15 -0800 Subject: [PATCH 2/2] fix(init): restore managed worktree gitignore updates Revert the default no-mutation behavior so init again adds safe managed worktree ignore patterns while guarding against broad unsafe paths. Refs: https://github.com/corwinm/arashi-arashi/issues/120 --- src/commands/init.ts | 46 ++++++++++++++--- tests/integration/init.test.ts | 94 +++++++++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 6fdb2df..18b41bc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,6 +15,7 @@ import { exec as gitExec } from "../lib/git.ts"; import { discoverRepositories } from "../core/repository.ts"; import { DEFAULT_WORKTREES_DIR, + DEFAULT_WORKTREES_GITIGNORE_ENTRY, normalizeWorktreesDir, WorktreeLocationValidationError, } from "../lib/worktree-location.ts"; @@ -399,7 +400,7 @@ async function writeHookTemplates(hooksDir: string): Promise { // ============================================================================ /** - * Update .gitignore to exclude repos directory (idempotent) + * Update .gitignore to exclude managed directories (idempotent) */ function normalizeGitignorePattern(directoryPath: string): string { let pattern = directoryPath.replace(/^\.\//, ""); @@ -417,9 +418,37 @@ function hasGitignorePattern(content: string, pattern: string): boolean { .some((line) => line === pattern || line === alternate); } -async function updateGitignore(cwd: string, reposDir: string): Promise { - const gitignorePath = join(cwd, ".gitignore"); +function getManagedWorktreesGitignorePattern(worktreesDir: string): string | null { + const normalizedWorktreesDir = normalizeWorktreesDir(worktreesDir); + + if ( + normalizedWorktreesDir === "." || + normalizedWorktreesDir === ".." || + normalizedWorktreesDir.startsWith("../") + ) { + return null; + } + + if (normalizedWorktreesDir === DEFAULT_WORKTREES_DIR) { + return DEFAULT_WORKTREES_GITIGNORE_ENTRY; + } + + return normalizeGitignorePattern(normalizedWorktreesDir); +} + +function getManagedGitignorePatterns(reposDir: string, worktreesDir: string): string[] { const patterns = [normalizeGitignorePattern(reposDir)]; + const worktreesPattern = getManagedWorktreesGitignorePattern(worktreesDir); + if (worktreesPattern) { + patterns.push(worktreesPattern); + } + + return patterns; +} + +async function updateGitignore(cwd: string, reposDir: string, worktreesDir: string): Promise { + const gitignorePath = join(cwd, ".gitignore"); + const patterns = getManagedGitignorePatterns(reposDir, worktreesDir); let content = ""; let originalContent: string | null = null; @@ -792,13 +821,13 @@ async function executeInit(options: InitOptions): Promise { // 11. Update .gitignore const gitignorePath = join(cwd, ".gitignore"); - const managedPatterns = [normalizeGitignorePattern(reposDir)]; + const managedPatterns = getManagedGitignorePatterns(reposDir, worktreesDir); if (options.dryRun) { logDryRun("UPDATE_FILE", `${gitignorePath} (add: ${managedPatterns.join(", ")})`); } else { logVerbose("Updating .gitignore...", options); try { - await updateGitignore(cwd, reposDir); + await updateGitignore(cwd, reposDir, worktreesDir); logVerbose("✓ .gitignore updated", options); } catch (error) { await executeRollback(); @@ -862,7 +891,12 @@ function displaySuccess(result: InitResult, options: InitOptions): void { } const reposDir = options.reposDir || "./repos"; - console.log(`\nUpdated .gitignore to exclude: ${normalizeGitignorePattern(reposDir)}`); + 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}`); + } console.log("\nNext steps:"); if (result.discoveredCount && result.discoveredCount > 0) { diff --git a/tests/integration/init.test.ts b/tests/integration/init.test.ts index fc94590..5f264d4 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -129,7 +129,7 @@ describe("init command - success cases", () => { expect(await filesystem.fileExists(gitignorePath)).toBe(true); const gitignoreContent = await filesystem.readTextFile(gitignorePath); expect(gitignoreContent).toContain("repos/"); - expect(gitignoreContent).not.toContain(".arashi/worktrees/"); + expect(gitignoreContent).toContain(".arashi/worktrees/"); }); test("init with custom repos directory", async () => { @@ -147,10 +147,25 @@ describe("init command - success cases", () => { // Verify .gitignore updated with custom path const gitignoreContent = await filesystem.readTextFile(join(testDir, ".gitignore")); expect(gitignoreContent).toContain("custom-repos/"); + expect(gitignoreContent).toContain(".arashi/worktrees/"); + }); + + test("init with custom managed subdirectory adds normalized worktrees ignore entry", async () => { + const result = await runInitCommand(testDir, ["--worktrees-dir", "./workspace-worktrees/"]); + + expect(result.exitCode).toBe(0); + + const loadedConfig = await config.loadConfig(testDir); + expect(loadedConfig.worktreesDir).toBe("workspace-worktrees"); + + const gitignoreContent = await filesystem.readTextFile(join(testDir, ".gitignore")); + expect(gitignoreContent).toContain("repos/"); + expect(gitignoreContent).toContain("workspace-worktrees/"); expect(gitignoreContent).not.toContain(".arashi/worktrees/"); + expect(result.stdout).toContain("workspace-worktrees/"); }); - test("init with custom worktrees directory does not auto-ignore custom path", async () => { + test("init with parent worktrees directory does not auto-ignore unsafe path", async () => { const result = await runInitCommand(testDir, ["--worktrees-dir", "../workspace-worktrees"]); expect(result.exitCode).toBe(0); @@ -164,6 +179,22 @@ describe("init command - success cases", () => { expect(gitignoreContent).not.toContain(".arashi/worktrees/"); }); + test("init with dot worktrees directory does not auto-ignore workspace root", async () => { + const result = await runInitCommand(testDir, ["--worktrees-dir", "."]); + + expect(result.exitCode).toBe(0); + + const loadedConfig = await config.loadConfig(testDir); + expect(loadedConfig.worktreesDir).toBe("."); + + const gitignoreContent = await filesystem.readTextFile(join(testDir, ".gitignore")); + const gitignoreLines = gitignoreContent.split("\n").map((line) => line.trim()); + expect(gitignoreContent).toContain("repos/"); + expect(gitignoreLines).not.toContain("."); + expect(gitignoreLines).not.toContain("./"); + expect(gitignoreContent).not.toContain(".arashi/worktrees/"); + }); + test("init with --no-discover skips repository discovery", async () => { // Create a repo in repos directory first await mkdir(join(testDir, "repos", "test-repo", ".git"), { recursive: true }); @@ -212,7 +243,8 @@ describe("init command - success cases", () => { const gitignoreContent1 = await filesystem.readTextFile(join(testDir, ".gitignore")); const reposLineCount1 = (gitignoreContent1.match(/repos\//g) || []).length; - expect(gitignoreContent1).not.toContain(".arashi/worktrees/"); + const worktreesLineCount1 = (gitignoreContent1.match(/\.arashi\/worktrees\//g) || []).length; + expect(gitignoreContent1).toContain(".arashi/worktrees/"); // Delete config to allow re-init await rm(join(testDir, ".arashi"), { recursive: true }); @@ -222,10 +254,33 @@ describe("init command - success cases", () => { const gitignoreContent2 = await filesystem.readTextFile(join(testDir, ".gitignore")); const reposLineCount2 = (gitignoreContent2.match(/repos\//g) || []).length; - expect(gitignoreContent2).not.toContain(".arashi/worktrees/"); + const worktreesLineCount2 = (gitignoreContent2.match(/\.arashi\/worktrees\//g) || []).length; + expect(gitignoreContent2).toContain(".arashi/worktrees/"); // Verify repos/ pattern appears same number of times expect(reposLineCount2).toBe(reposLineCount1); + expect(worktreesLineCount2).toBe(worktreesLineCount1); + }); + + test(".gitignore update is idempotent with configured worktrees directory", async () => { + await runInitCommand(testDir, ["--worktrees-dir", "workspace-worktrees"]); + + const gitignoreContent1 = await filesystem.readTextFile(join(testDir, ".gitignore")); + const reposLineCount1 = (gitignoreContent1.match(/repos\//g) || []).length; + const customWorktreesLineCount1 = (gitignoreContent1.match(/workspace-worktrees\//g) || []) + .length; + + await rm(join(testDir, ".arashi"), { recursive: true }); + + await runInitCommand(testDir, ["--worktrees-dir", "./workspace-worktrees/"]); + + const gitignoreContent2 = await filesystem.readTextFile(join(testDir, ".gitignore")); + const reposLineCount2 = (gitignoreContent2.match(/repos\//g) || []).length; + const customWorktreesLineCount2 = (gitignoreContent2.match(/workspace-worktrees\//g) || []) + .length; + + expect(reposLineCount2).toBe(reposLineCount1); + expect(customWorktreesLineCount2).toBe(customWorktreesLineCount1); }); test("hook templates are not overwritten if they exist", async () => { @@ -438,7 +493,7 @@ describe("init command - edge cases", () => { expect(await filesystem.fileExists(gitignorePath)).toBe(true); const content = await filesystem.readTextFile(gitignorePath); expect(content).toContain("repos/"); - expect(content).not.toContain(".arashi/worktrees/"); + expect(content).toContain(".arashi/worktrees/"); await cleanup(testDir); }); @@ -457,7 +512,7 @@ describe("init command - edge cases", () => { const content = await filesystem.readTextFile(join(testDir, ".gitignore")); expect(content).toContain("node_modules/\n"); expect(content).toContain("repos/"); - expect(content).not.toContain(".arashi/worktrees/"); + expect(content).toContain(".arashi/worktrees/"); await cleanup(testDir); }); @@ -628,6 +683,33 @@ describe("init command - dry-run mode", () => { expect(await filesystem.fileExists(join(testDir, "custom"))).toBe(false); }); + test("--dry-run with custom worktrees directory previews managed ignore entry", async () => { + const result = await runInitCommand(testDir, [ + "--dry-run", + "--worktrees-dir", + "./workspace-worktrees/", + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("[DRY RUN] UPDATE_FILE:"); + expect(result.stdout).toContain("workspace-worktrees/"); + expect(result.stdout).not.toContain(".arashi/worktrees/"); + }); + + test("--dry-run with unsafe parent worktrees directory skips worktree ignore preview", async () => { + const result = await runInitCommand(testDir, [ + "--dry-run", + "--worktrees-dir", + "../workspace-worktrees", + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("[DRY RUN] UPDATE_FILE:"); + expect(result.stdout).toContain("add: repos/"); + expect(result.stdout).not.toContain("../workspace-worktrees/"); + expect(result.stdout).not.toContain(".arashi/worktrees/"); + }); + test("--dry-run works with --no-discover option", async () => { // Create a repo in repos directory await mkdir(join(testDir, "repos", "test-repo", ".git"), { recursive: true });