Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -399,7 +400,7 @@ async function writeHookTemplates(hooksDir: string): Promise<void> {
// ============================================================================

/**
* Update .gitignore to exclude repos directory (idempotent)
* Update .gitignore to exclude managed directories (idempotent)
*/
function normalizeGitignorePattern(directoryPath: string): string {
let pattern = directoryPath.replace(/^\.\//, "");
Expand All @@ -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<void> {
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<void> {
const gitignorePath = join(cwd, ".gitignore");
const patterns = getManagedGitignorePatterns(reposDir, worktreesDir);

let content = "";
let originalContent: string | null = null;
Expand Down Expand Up @@ -792,13 +821,13 @@ async function executeInit(options: InitOptions): Promise<InitResult> {

// 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();
Expand Down Expand Up @@ -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) {
Expand Down
94 changes: 88 additions & 6 deletions tests/integration/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -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 });
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -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 });
Expand Down
Loading