From 2833a5189aae00bd2217b6a474461e65921e65bc Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 22 Feb 2026 19:21:26 -0800 Subject: [PATCH 1/2] feat: add configurable worktree base location Route worktree destination resolution through a normalized workspace setting so teams can choose consistent layouts while keeping defaults safe and predictable. Refs: https://github.com/corwinm/arashi-arashi/issues/113 --- schema/config.schema.json | 4 + src/commands/init.ts | 104 +++++++++++---- src/core/worktree.ts | 13 +- src/lib/config.ts | 39 ++++++ src/lib/worktree-location.ts | 53 ++++++++ tests/helpers/create-bare-create-workspace.ts | 3 + tests/helpers/create-child-hook-workspace.ts | 11 +- .../create.bare-rollback-guarantee.test.ts | 2 +- .../create.bare-root-success.test.ts | 2 +- .../create.non-bare-parity.test.ts | 6 +- ...reate.worktree-location-resolution.test.ts | 118 ++++++++++++++++++ tests/integration/init.test.ts | 21 ++++ .../integration/nested-worktree-paths.test.ts | 6 +- tests/unit/config.test.ts | 42 +++++++ tests/unit/worktree-location.test.ts | 44 +++++++ tests/unit/worktree-path-calculation.test.ts | 111 +++++++++++++--- 16 files changed, 525 insertions(+), 54 deletions(-) create mode 100644 src/lib/worktree-location.ts create mode 100644 tests/integration/create.worktree-location-resolution.test.ts create mode 100644 tests/unit/worktree-location.test.ts diff --git a/schema/config.schema.json b/schema/config.schema.json index 43b7724..6bae700 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -32,6 +32,10 @@ "description": "Directory where repositories are located", "type": "string" }, + "worktreesDir": { + "description": "Base directory where worktrees are created (workspace-relative)", + "type": "string" + }, "sync": { "additionalProperties": false, "description": "Optional sync command settings", diff --git a/src/commands/init.ts b/src/commands/init.ts index ae45e1d..0a39989 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,6 +13,13 @@ import * as logger from "../lib/logger.ts"; import * as filesystem from "../lib/filesystem.ts"; import { exec as gitExec } from "../lib/git.ts"; import { discoverRepositories } from "../core/repository.ts"; +import { + DEFAULT_WORKTREES_DIR, + DEFAULT_WORKTREES_GITIGNORE_ENTRY, + isDefaultWorktreesDir, + normalizeWorktreesDir, + WorktreeLocationValidationError, +} from "../lib/worktree-location.ts"; // ============================================================================ // Data Types @@ -22,6 +29,9 @@ interface InitOptions { /** Custom location for managed repositories */ reposDir?: string; + /** Base location for managed worktrees (workspace-relative) */ + worktreesDir?: string; + /** Overwrite existing configuration if present */ force?: boolean; @@ -393,17 +403,28 @@ async function writeHookTemplates(hooksDir: string): Promise { /** * Update .gitignore to exclude repos directory (idempotent) */ -async function updateGitignore(cwd: string, reposDir: string): Promise { - const gitignorePath = join(cwd, ".gitignore"); - - // Normalize repos dir for gitignore pattern - // Remove leading ./ if present - let pattern = reposDir.replace(/^\.\//, ""); - - // Ensure trailing slash for directory +function normalizeGitignorePattern(directoryPath: string): string { + let pattern = directoryPath.replace(/^\.\//, ""); if (!pattern.endsWith("/")) { pattern += "/"; } + return pattern; +} + +function 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); +} + +async function updateGitignore(cwd: string, reposDir: string, worktreesDir: string): Promise { + const gitignorePath = join(cwd, ".gitignore"); + const patterns = [normalizeGitignorePattern(reposDir)]; + if (isDefaultWorktreesDir(worktreesDir)) { + patterns.push(DEFAULT_WORKTREES_GITIGNORE_ENTRY); + } let content = ""; let originalContent: string | null = null; @@ -412,16 +433,11 @@ async function updateGitignore(cwd: string, reposDir: string): Promise { if (await filesystem.fileExists(gitignorePath)) { originalContent = await filesystem.readTextFile(gitignorePath); content = originalContent; + } - // Check if pattern already exists (idempotent) - const lines = content.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === pattern || trimmed === pattern.slice(0, -1)) { - // Pattern already exists, no need to add - return; - } - } + const missingPatterns = patterns.filter((pattern) => !hasGitignorePattern(content, pattern)); + if (missingPatterns.length === 0) { + return; } // Ensure content ends with newline before appending @@ -429,8 +445,11 @@ async function updateGitignore(cwd: string, reposDir: string): Promise { content += "\n"; } - // Append pattern with comment - content += `\n# Arashi managed repositories\n${pattern}\n`; + // Append patterns with comment + content += "\n# Arashi managed repositories\n"; + for (const pattern of missingPatterns) { + content += `${pattern}\n`; + } // Write updated .gitignore await filesystem.writeTextFile(gitignorePath, content); @@ -526,7 +545,7 @@ async function executeInit(options: InitOptions): Promise { logVerbose("No existing configuration found", options); } - // 4. Validate and resolve repos directory path + // 4. Validate and resolve repos directory/worktree paths const reposDir = options.reposDir || "./repos"; logVerbose(`Validating repos directory path: ${reposDir}`, options); @@ -541,6 +560,31 @@ async function executeInit(options: InitOptions): Promise { const absoluteReposPath = resolve(cwd, reposDir); logVerbose(`Resolved repos directory: ${absoluteReposPath}`, options); + const rawWorktreesDir = options.worktreesDir; + if (rawWorktreesDir !== undefined && !isValidPath(rawWorktreesDir)) { + return { + success: false, + error: `Invalid worktrees directory path: ${rawWorktreesDir}`, + exitCode: ExitCode.INVALID_PATH, + }; + } + + let worktreesDir = DEFAULT_WORKTREES_DIR; + try { + worktreesDir = normalizeWorktreesDir(rawWorktreesDir ?? DEFAULT_WORKTREES_DIR); + } catch (error) { + if (error instanceof WorktreeLocationValidationError) { + return { + success: false, + error: `Invalid worktrees directory path: ${rawWorktreesDir ?? DEFAULT_WORKTREES_DIR} (${error.message})`, + exitCode: ExitCode.INVALID_PATH, + }; + } + + throw error; + } + logVerbose(`Resolved worktrees directory: ${resolve(cwd, worktreesDir)}`, options); + // 5. Create .arashi directory const arashiDir = join(cwd, ".arashi"); @@ -706,6 +750,7 @@ async function executeInit(options: InitOptions): Promise { $schema: config.DEFAULT_CONFIG_SCHEMA_URL, version: "1.0.0", reposDir: reposDir, + worktreesDir, repos: discoveredRepos, }; @@ -752,12 +797,16 @@ async function executeInit(options: InitOptions): Promise { // 11. Update .gitignore const gitignorePath = join(cwd, ".gitignore"); + const managedPatterns = [normalizeGitignorePattern(reposDir)]; + if (isDefaultWorktreesDir(worktreesDir)) { + managedPatterns.push(DEFAULT_WORKTREES_GITIGNORE_ENTRY); + } if (options.dryRun) { - logDryRun("UPDATE_FILE", `${gitignorePath} (add: ${reposDir}/)`); + 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(); @@ -821,7 +870,10 @@ function displaySuccess(result: InitResult, options: InitOptions): void { } const reposDir = options.reposDir || "./repos"; - console.log(`\nUpdated .gitignore to exclude: ${reposDir}`); + console.log(`\nUpdated .gitignore to exclude: ${normalizeGitignorePattern(reposDir)}`); + if (isDefaultWorktreesDir(options.worktreesDir ?? DEFAULT_WORKTREES_DIR)) { + console.log(` • ${DEFAULT_WORKTREES_GITIGNORE_ENTRY}`); + } console.log("\nNext steps:"); if (result.discoveredCount && result.discoveredCount > 0) { @@ -880,6 +932,11 @@ export function createCommand(): Command { return new Command("init") .description("Initialize Arashi workspace in the current git repository") .option("--repos-dir ", "Custom location for managed repositories", "./repos") + .option( + "--worktrees-dir ", + "Custom base location for managed worktrees", + DEFAULT_WORKTREES_DIR, + ) .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") @@ -888,6 +945,7 @@ export function createCommand(): Command { // Commander converts --no-discover to discover: false const normalizedOptions: InitOptions = { reposDir: options.reposDir, + worktreesDir: options.worktreesDir, force: options.force, noDiscover: options.discover === false, // --no-discover sets discover: false dryRun: options.dryRun, diff --git a/src/core/worktree.ts b/src/core/worktree.ts index c0b13fb..4525b4a 100644 --- a/src/core/worktree.ts +++ b/src/core/worktree.ts @@ -17,6 +17,7 @@ import { existsSync } from "fs"; import type { Config as ArashiConfig } from "../lib/config.ts"; import { loadConfig, ConfigNotFoundError } from "../lib/config.ts"; import { isBareRepo } from "../lib/git.ts"; +import { resolveWorktreesBasePath } from "../lib/worktree-location.ts"; import type { DirtyStatus, WorktreeEntry, WorktreeInfo } from "../types/remove.ts"; // ============================================================================ @@ -641,6 +642,9 @@ export async function calculateWorktreePath( }> { // Detect repository type (or use provided type) const typeInfo = knownType ?? (await detectRepositoryType(repo, config)); + const workspaceRoot = + typeInfo.type === "child" ? join(repo.path, "..", "..") : resolve(repo.path); + const worktreeBasePath = resolveWorktreesBasePath(workspaceRoot, config.worktreesDir); // Apply appropriate path calculation strategy if (typeInfo.type === "child") { @@ -650,7 +654,7 @@ export async function calculateWorktreePath( } // Determine parent repository path (navigate up from child: ../../../) - const parentRepoPath = join(repo.path, "..", ".."); + const parentRepoPath = workspaceRoot; // Check if parent is bare to determine worktree naming const parentIsBare = await isBareRepo(parentRepoPath); @@ -659,9 +663,8 @@ export async function calculateWorktreePath( // Non-bare parent: Combine parent name + branch const parentWorktreeName = parentIsBare ? branchName : `${typeInfo.parentName}-${branchName}`; - const worktreePath = calculateChildWorktreePath(repo, parentWorktreeName, typeInfo.reposDir); - - const parentWorktreePath = join(repo.path, "..", "..", "..", parentWorktreeName); + const parentWorktreePath = join(worktreeBasePath, parentWorktreeName); + const worktreePath = join(parentWorktreePath, typeInfo.reposDir, repo.name); return { path: worktreePath, @@ -677,7 +680,7 @@ export async function calculateWorktreePath( // Bare repos: Use branch name only (e.g., 'feature-branch/') // Non-bare repos: Combine folder name + branch (e.g., 'my-repo-feature-branch/') const worktreeName = isBare ? branchName : `${repo.name}-${branchName}`; - const worktreePath = join(repo.path, "..", worktreeName); + const worktreePath = join(worktreeBasePath, worktreeName); return { path: worktreePath, diff --git a/src/lib/config.ts b/src/lib/config.ts index 8c89839..0974f54 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -10,6 +10,11 @@ import { join, dirname, basename, resolve } from "path"; import { mkdir } from "fs/promises"; import { exec, readTrackedFileFromDefaultBranch } from "./git.ts"; +import { + DEFAULT_WORKTREES_DIR, + normalizeWorktreesDir, + WorktreeLocationValidationError, +} from "./worktree-location.ts"; // ============================================================================ // Data Types @@ -52,6 +57,8 @@ export interface Config { version: ConfigVersion; /** Directory where repositories are located */ reposDir: string; + /** Base directory where worktrees are created (workspace-relative) */ + worktreesDir?: string; /** Optional workspace-level hooks settings */ hooks?: { /** Timeout in milliseconds for long-running operations */ @@ -283,6 +290,7 @@ export function generateDefaultConfig(): Config { $schema: DEFAULT_CONFIG_SCHEMA_URL, version: CURRENT_CONFIG_VERSION, reposDir: "./repos", + worktreesDir: DEFAULT_WORKTREES_DIR, repos: {}, }; } @@ -296,6 +304,8 @@ const ROOT_ALLOWED_KEYS = new Set([ "version", "reposDir", "repos_dir", + "worktreesDir", + "worktrees_dir", "repos", "discoveredRepos", "discovered_repos", @@ -482,6 +492,28 @@ function normalizeSyncConfig( }; } +function normalizeWorktreesDirConfig(value: unknown, errors: string[]): string { + if (value === undefined) { + return DEFAULT_WORKTREES_DIR; + } + + if (typeof value !== "string") { + errors.push("worktreesDir: must be a string if present"); + return DEFAULT_WORKTREES_DIR; + } + + try { + return normalizeWorktreesDir(value); + } catch (error) { + if (error instanceof WorktreeLocationValidationError) { + errors.push(`worktreesDir: ${error.message}`); + return DEFAULT_WORKTREES_DIR; + } + + throw error; + } +} + /** * Normalize legacy/snake_case config keys to canonical camelCase format. * @@ -512,11 +544,16 @@ function normalizeConfigInternal(config: unknown): { config.reposDir as string | undefined, config.repos_dir as string | undefined, ); + const worktreesDirRaw = getFirstDefined( + config.worktreesDir as string | undefined, + config.worktrees_dir as string | undefined, + ); const reposRaw = getFirstDefined( config.repos as Record | undefined, config.discoveredRepos as Record | undefined, config.discovered_repos as Record | undefined, ); + const worktreesDir = normalizeWorktreesDirConfig(worktreesDirRaw, errors); const hooks = normalizeWorkspaceHooks(config.hooks, "hooks", errors); const sync = normalizeSyncConfig(config.sync, "sync", errors); @@ -550,6 +587,7 @@ function normalizeConfigInternal(config: unknown): { const normalizedConfig: Config = { version: normalizedVersion, reposDir: normalizedReposDir, + worktreesDir, repos: normalizedRepos, }; @@ -742,6 +780,7 @@ function normalizePersistedConfig(config: Config): Config { $schema: config.$schema ?? DEFAULT_CONFIG_SCHEMA_URL, version: config.version, reposDir: config.reposDir, + worktreesDir: config.worktreesDir ?? DEFAULT_WORKTREES_DIR, repos, }; diff --git a/src/lib/worktree-location.ts b/src/lib/worktree-location.ts new file mode 100644 index 0000000..30f35c7 --- /dev/null +++ b/src/lib/worktree-location.ts @@ -0,0 +1,53 @@ +import { isAbsolute, posix, resolve } from "path"; + +export const DEFAULT_WORKTREES_DIR = ".arashi/worktrees"; +export const DEFAULT_WORKTREES_GITIGNORE_ENTRY = ".arashi/worktrees/"; + +const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[a-zA-Z]:[\\/]/; + +export class WorktreeLocationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "WorktreeLocationValidationError"; + } +} + +function normalizeSeparators(value: string): string { + return value.replaceAll("\\", "/"); +} + +export function normalizeWorktreesDir(worktreesDir: string): string { + const trimmed = worktreesDir.trim(); + if (trimmed.length === 0) { + throw new WorktreeLocationValidationError("must be a non-empty string if present"); + } + + if (isAbsolute(trimmed) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(trimmed)) { + throw new WorktreeLocationValidationError("must be a relative path"); + } + + const normalized = posix.normalize(normalizeSeparators(trimmed)); + const withoutTrailingSlash = normalized.replace(/\/+$/, ""); + + if (withoutTrailingSlash.length === 0 || withoutTrailingSlash === ".") { + return "."; + } + + return withoutTrailingSlash; +} + +export function normalizeWorktreesDirWithDefault(worktreesDir?: string): string { + if (worktreesDir === undefined) { + return DEFAULT_WORKTREES_DIR; + } + + return normalizeWorktreesDir(worktreesDir); +} + +export function resolveWorktreesBasePath(workspaceRoot: string, worktreesDir?: string): string { + return resolve(workspaceRoot, normalizeWorktreesDirWithDefault(worktreesDir)); +} + +export function isDefaultWorktreesDir(worktreesDir?: string): boolean { + return normalizeWorktreesDirWithDefault(worktreesDir) === DEFAULT_WORKTREES_DIR; +} diff --git a/tests/helpers/create-bare-create-workspace.ts b/tests/helpers/create-bare-create-workspace.ts index 484d4c0..be6afd2 100644 --- a/tests/helpers/create-bare-create-workspace.ts +++ b/tests/helpers/create-bare-create-workspace.ts @@ -12,6 +12,7 @@ export interface BareCreateWorkspace { export interface BareCreateWorkspaceOptions { includeConfig?: boolean; configReposDir?: string; + configWorktreesDir?: string; } export async function createBareCreateWorkspace( @@ -19,6 +20,7 @@ export async function createBareCreateWorkspace( ): Promise { const includeConfig = options.includeConfig ?? true; const configReposDir = options.configReposDir ?? "./repos"; + const configWorktreesDir = options.configWorktreesDir ?? ".arashi/worktrees"; const rootPath = join( tmpdir(), `arashi-bare-create-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -45,6 +47,7 @@ export async function createBareCreateWorkspace( { version: "1.0.0", reposDir: configReposDir, + worktreesDir: configWorktreesDir, repos: {}, }, null, diff --git a/tests/helpers/create-child-hook-workspace.ts b/tests/helpers/create-child-hook-workspace.ts index 29a95db..e44a635 100644 --- a/tests/helpers/create-child-hook-workspace.ts +++ b/tests/helpers/create-child-hook-workspace.ts @@ -1,6 +1,6 @@ import { mkdir, rm } from "fs/promises"; import { tmpdir } from "os"; -import { basename, join } from "path"; +import { basename, join, resolve } from "path"; export interface ChildHookWorkspace { rootPath: string; @@ -20,12 +20,14 @@ export interface ChildHookWorkspace { export interface ChildHookWorkspaceOptions { childRepoNames?: string[]; hookTimeoutMs?: number; + worktreesDir?: string; } export async function createChildHookWorkspace( options: ChildHookWorkspaceOptions = {}, ): Promise { const childRepoNames = options.childRepoNames ?? ["alpha", "beta"]; + const worktreesDir = options.worktreesDir ?? ".arashi/worktrees"; const rootPath = join( tmpdir(), `arashi-child-hooks-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -67,6 +69,7 @@ export async function createChildHookWorkspace( { version: "1.0.0", reposDir: "./repos", + worktreesDir, hooks: { timeout: options.hookTimeoutMs ?? 1000, }, @@ -81,6 +84,7 @@ export async function createChildHookWorkspace( await execGit(["commit", "-m", "Initialize workspace"], workspacePath); const workspaceName = basename(workspacePath); + const worktreesRootPath = resolve(workspacePath, worktreesDir); const childInvocationPath = childRepoPaths[childRepoNames[0]]; const nestedChildInvocationPath = join(childInvocationPath, "nested", "inside"); await mkdir(nestedChildInvocationPath, { recursive: true }); @@ -95,9 +99,10 @@ export async function createChildHookWorkspace( childRepoPaths, childInvocationPath, nestedChildInvocationPath, - getMainWorktreePath: (branchName: string) => join(rootPath, `${workspaceName}-${branchName}`), + getMainWorktreePath: (branchName: string) => + join(worktreesRootPath, `${workspaceName}-${branchName}`), getChildWorktreePath: (repoName: string, branchName: string) => - join(rootPath, `${workspaceName}-${branchName}`, "repos", repoName), + join(worktreesRootPath, `${workspaceName}-${branchName}`, "repos", repoName), cleanup: async () => { await rm(rootPath, { recursive: true, force: true }); }, diff --git a/tests/integration/create.bare-rollback-guarantee.test.ts b/tests/integration/create.bare-rollback-guarantee.test.ts index fea0b1a..be6f8c5 100644 --- a/tests/integration/create.bare-rollback-guarantee.test.ts +++ b/tests/integration/create.bare-rollback-guarantee.test.ts @@ -22,7 +22,7 @@ describe("create rollback guarantees in bare context", () => { workspace = await createBareCreateWorkspace(); const branch = "feature-bare-rollback"; - const blockedPath = join(workspace.rootPath, branch); + const blockedPath = join(workspace.bareRepoPath, ".arashi", "worktrees", branch); await Bun.write(blockedPath, "block worktree path"); const proc = Bun.spawn(["bun", CLI_ENTRY, "create", branch, "--no-hooks", "--no-progress"], { diff --git a/tests/integration/create.bare-root-success.test.ts b/tests/integration/create.bare-root-success.test.ts index ff64465..d60f59b 100644 --- a/tests/integration/create.bare-root-success.test.ts +++ b/tests/integration/create.bare-root-success.test.ts @@ -40,7 +40,7 @@ describe("create command from bare root", () => { expect(exitCode).toBe(0); expect(stderr).toContain("worktree created"); - const expectedWorktreePath = join(workspace.rootPath, branch); + const expectedWorktreePath = join(workspace.bareRepoPath, ".arashi", "worktrees", branch); expect(existsSync(expectedWorktreePath)).toBe(true); }); }); diff --git a/tests/integration/create.non-bare-parity.test.ts b/tests/integration/create.non-bare-parity.test.ts index 1337196..303d79c 100644 --- a/tests/integration/create.non-bare-parity.test.ts +++ b/tests/integration/create.non-bare-parity.test.ts @@ -45,7 +45,11 @@ describe("create command parity between non-bare and bare invocation", () => { expect(exitCode).toBe(0); expect(`${stdout}\n${stderr}`).toContain("Hook results:"); - const expectedWorktreePath = join(workspace.rootPath, branch); + const combinedOutput = `${stdout}\n${stderr}`; + const match = combinedOutput.match(/worktree created at\s+(.+)/); + expect(match).not.toBeNull(); + + const expectedWorktreePath = match?.[1]?.trim() ?? ""; expect(existsSync(expectedWorktreePath)).toBe(true); expect(existsSync(join(expectedWorktreePath, "hook-parity.log"))).toBe(true); }); diff --git a/tests/integration/create.worktree-location-resolution.test.ts b/tests/integration/create.worktree-location-resolution.test.ts new file mode 100644 index 0000000..ed3768a --- /dev/null +++ b/tests/integration/create.worktree-location-resolution.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { basename, dirname, join, resolve } from "path"; + +const CLI_ENTRY = join(import.meta.dir, "../../src/index.ts"); + +let workspacePath = ""; + +async function runGit(args: string[], cwd: string): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode === 0) { + return; + } + + const stderr = await new Response(proc.stderr).text(); + throw new Error(`git ${args.join(" ")} failed: ${stderr}`); +} + +async function writeWorkspaceConfig(worktreesDir: string): Promise { + await mkdir(join(workspacePath, ".arashi"), { recursive: true }); + await Bun.write( + join(workspacePath, ".arashi", "config.json"), + JSON.stringify( + { + version: "1.0.0", + reposDir: "./repos", + worktreesDir, + repos: {}, + }, + null, + 2, + ), + ); +} + +async function runCreate(branch: string): Promise { + const proc = Bun.spawn(["bun", CLI_ENTRY, "create", branch, "--no-hooks", "--no-progress"], { + cwd: workspacePath, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error(`create failed (exit=${exitCode})\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } +} + +describe("create command worktree location resolution", () => { + beforeEach(async () => { + workspacePath = await mkdtemp(join(tmpdir(), "arashi-worktree-location-")); + + await runGit(["init", "-b", "main"], workspacePath); + await runGit(["config", "user.name", "Test User"], workspacePath); + await runGit(["config", "user.email", "test@example.com"], workspacePath); + + await writeFile(join(workspacePath, "README.md"), "# workspace\n"); + await runGit(["add", "README.md"], workspacePath); + await runGit(["commit", "-m", "Initial"], workspacePath); + }); + + afterEach(async () => { + if (workspacePath.length > 0) { + await rm(workspacePath, { recursive: true, force: true }); + } + }); + + test("resolves ../, ., ./, .arashi/worktrees, and trailing slash variants", async () => { + const repoName = basename(workspacePath); + + await writeWorkspaceConfig("../"); + await runCreate("feature-parent"); + const parentPath = resolve(workspacePath, "..", `${repoName}-feature-parent`); + expect(await Bun.file(join(parentPath, "README.md")).exists()).toBe(true); + + await writeWorkspaceConfig("."); + await runCreate("feature-dot"); + const dotPath = resolve(workspacePath, `${repoName}-feature-dot`); + expect(await Bun.file(join(dotPath, "README.md")).exists()).toBe(true); + + await writeWorkspaceConfig("./"); + await runCreate("feature-dot-slash"); + const dotSlashPath = resolve(workspacePath, `${repoName}-feature-dot-slash`); + expect(await Bun.file(join(dotSlashPath, "README.md")).exists()).toBe(true); + expect(dirname(dotPath)).toBe(dirname(dotSlashPath)); + + await writeWorkspaceConfig(".arashi/worktrees"); + await runCreate("feature-managed"); + const managedPath = resolve( + workspacePath, + ".arashi", + "worktrees", + `${repoName}-feature-managed`, + ); + expect(await Bun.file(join(managedPath, "README.md")).exists()).toBe(true); + + await writeWorkspaceConfig(".arashi/worktrees/"); + await runCreate("feature-managed-slash"); + const managedSlashPath = resolve( + workspacePath, + ".arashi", + "worktrees", + `${repoName}-feature-managed-slash`, + ); + expect(await Bun.file(join(managedSlashPath, "README.md")).exists()).toBe(true); + expect(dirname(managedPath)).toBe(dirname(managedSlashPath)); + }); +}); diff --git a/tests/integration/init.test.ts b/tests/integration/init.test.ts index 802bf96..8b806a6 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -129,6 +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).toContain(".arashi/worktrees/"); }); test("init with custom repos directory", async () => { @@ -146,6 +147,21 @@ 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 worktrees directory does not auto-ignore custom path", 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).not.toContain("../workspace-worktrees/"); + expect(gitignoreContent).not.toContain(".arashi/worktrees/"); }); test("init with --no-discover skips repository discovery", async () => { @@ -196,6 +212,7 @@ describe("init command - success cases", () => { const gitignoreContent1 = await filesystem.readTextFile(join(testDir, ".gitignore")); const reposLineCount1 = (gitignoreContent1.match(/repos\//g) || []).length; + const worktreesLineCount1 = (gitignoreContent1.match(/\.arashi\/worktrees\//g) || []).length; // Delete config to allow re-init await rm(join(testDir, ".arashi"), { recursive: true }); @@ -205,9 +222,11 @@ describe("init command - success cases", () => { const gitignoreContent2 = await filesystem.readTextFile(join(testDir, ".gitignore")); const reposLineCount2 = (gitignoreContent2.match(/repos\//g) || []).length; + const worktreesLineCount2 = (gitignoreContent2.match(/\.arashi\/worktrees\//g) || []).length; // Verify repos/ pattern appears same number of times expect(reposLineCount2).toBe(reposLineCount1); + expect(worktreesLineCount2).toBe(worktreesLineCount1); }); test("hook templates are not overwritten if they exist", async () => { @@ -420,6 +439,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).toContain(".arashi/worktrees/"); await cleanup(testDir); }); @@ -438,6 +458,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).toContain(".arashi/worktrees/"); await cleanup(testDir); }); diff --git a/tests/integration/nested-worktree-paths.test.ts b/tests/integration/nested-worktree-paths.test.ts index b97dd9e..09b812f 100644 --- a/tests/integration/nested-worktree-paths.test.ts +++ b/tests/integration/nested-worktree-paths.test.ts @@ -71,11 +71,10 @@ describe("Nested Worktree Paths Integration", () => { expect(result.successCount).toBe(1); expect(result.failureCount).toBe(0); - const worktreePath = join(testDir, "parent-repo-feature"); + const worktreePath = join(metaRepoPath, ".arashi", "worktrees", "parent-repo-feature"); const worktreeExists = await Bun.file(join(worktreePath, "README.md")).exists(); expect(worktreeExists).toBe(true); - // Verify directory structure: parent-repo/ and parent-repo-feature/ at same level const parentExists = await Bun.file(join(metaRepoPath, "README.md")).exists(); expect(parentExists).toBe(true); }); @@ -111,8 +110,7 @@ describe("Nested Worktree Paths Integration", () => { expect(result.successCount).toBe(1); - // Verify sibling creation with correct naming - const worktreePath = join(testDir, "existing-repo-bugfix-123"); + const worktreePath = join(metaRepoPath, ".arashi", "worktrees", "existing-repo-bugfix-123"); const worktreeExists = await Bun.file(join(worktreePath, "README.md")).exists(); expect(worktreeExists).toBe(true); }); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 212844c..33b5d06 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -15,6 +15,7 @@ import { type Config, } from "../../src/lib/config"; import { join } from "path"; +import { DEFAULT_WORKTREES_DIR } from "../../src/lib/worktree-location"; describe("getConfigPath", () => { test("constructs correct path with repo path", () => { @@ -43,6 +44,7 @@ describe("generateDefaultConfig", () => { expect(config.version).toBe("1.0.0"); expect(config.reposDir).toBe("./repos"); + expect(config.worktreesDir).toBe(DEFAULT_WORKTREES_DIR); expect(config.repos).toEqual({}); }); @@ -179,6 +181,46 @@ describe("validateConfig - root level", () => { expect(normalized.version).toBe("1.0.0"); }); + test("applies default worktreesDir when omitted", () => { + const normalized = normalizeConfig({ + version: "1.0.0", + reposDir: "./repos", + repos: {}, + }); + + expect(normalized.worktreesDir).toBe(DEFAULT_WORKTREES_DIR); + }); + + test("normalizes supported worktreesDir path variants", () => { + const dotVariant = normalizeConfig({ + version: "1.0.0", + reposDir: "./repos", + worktreesDir: "./", + repos: {}, + }); + expect(dotVariant.worktreesDir).toBe("."); + + const managedVariant = normalizeConfig({ + version: "1.0.0", + reposDir: "./repos", + worktreesDir: ".arashi/worktrees/", + repos: {}, + }); + expect(managedVariant.worktreesDir).toBe(DEFAULT_WORKTREES_DIR); + }); + + test("rejects absolute worktreesDir paths", () => { + const config = { + version: "1.0.0", + reposDir: "./repos", + worktreesDir: "/tmp/worktrees", + repos: {}, + }; + + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + expect(() => validateConfig(config)).toThrow("worktreesDir"); + }); + test("rejects unknown root fields", () => { const config = { version: "1.0.0", diff --git a/tests/unit/worktree-location.test.ts b/tests/unit/worktree-location.test.ts new file mode 100644 index 0000000..6ef2bc6 --- /dev/null +++ b/tests/unit/worktree-location.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "path"; +import { + DEFAULT_WORKTREES_DIR, + isDefaultWorktreesDir, + normalizeWorktreesDir, + normalizeWorktreesDirWithDefault, + resolveWorktreesBasePath, + WorktreeLocationValidationError, +} from "../../src/lib/worktree-location.ts"; + +describe("worktree location normalization", () => { + test("normalizes supported variants", () => { + expect(normalizeWorktreesDir("./")).toBe("."); + expect(normalizeWorktreesDir("../")).toBe(".."); + expect(normalizeWorktreesDir(".arashi/worktrees/")).toBe(DEFAULT_WORKTREES_DIR); + }); + + test("uses default when omitted", () => { + expect(normalizeWorktreesDirWithDefault()).toBe(DEFAULT_WORKTREES_DIR); + }); + + test("rejects absolute path", () => { + expect(() => normalizeWorktreesDir("/tmp/worktrees")).toThrow(WorktreeLocationValidationError); + }); +}); + +describe("worktree location resolution", () => { + test("resolves base path from workspace root", () => { + const workspaceRoot = join("/tmp", "workspace"); + + expect(resolveWorktreesBasePath(workspaceRoot, "../")).toBe(resolve(workspaceRoot, "..")); + expect(resolveWorktreesBasePath(workspaceRoot, "./")).toBe(resolve(workspaceRoot)); + expect(resolveWorktreesBasePath(workspaceRoot, ".arashi/worktrees/")).toBe( + resolve(workspaceRoot, DEFAULT_WORKTREES_DIR), + ); + }); + + test("detects default directory", () => { + expect(isDefaultWorktreesDir(DEFAULT_WORKTREES_DIR)).toBe(true); + expect(isDefaultWorktreesDir(".arashi/worktrees/")).toBe(true); + expect(isDefaultWorktreesDir("../")).toBe(false); + }); +}); diff --git a/tests/unit/worktree-path-calculation.test.ts b/tests/unit/worktree-path-calculation.test.ts index 4b39abf..fa81348 100644 --- a/tests/unit/worktree-path-calculation.test.ts +++ b/tests/unit/worktree-path-calculation.test.ts @@ -92,8 +92,10 @@ describe("calculateWorktreePath", () => { const result = await calculateWorktreePath(repo, "feature-123", config); - // Non-bare repo should use: repo-name-branch-name - expect(result.path).toBe(join(testDir, "my-project-feature-123")); + // Non-bare repo defaults to workspace/.arashi/worktrees/- + expect(result.path).toBe( + join(metaRepoPath, ".arashi", "worktrees", "my-project-feature-123"), + ); expect(result.repositoryType).toBe("meta-repo"); expect(result.strategy).toBe("sibling"); expect(result.parentWorktreePath).toBeUndefined(); @@ -128,8 +130,9 @@ describe("calculateWorktreePath", () => { for (const branchName of branchNames) { const result = await calculateWorktreePath(repo, branchName, config); - // Non-bare: repo-name-branch-name - expect(result.path).toBe(join(testDir, `project-${branchName}`)); + expect(result.path).toBe( + join(metaRepoPath, ".arashi", "worktrees", `project-${branchName}`), + ); expect(result.strategy).toBe("sibling"); } }); @@ -162,8 +165,8 @@ describe("calculateWorktreePath", () => { const result = await calculateWorktreePath(repo, "feature-123", config); - // Bare repo should use: branch-name only - expect(result.path).toBe(join(testDir, "feature-123")); + // Bare repo defaults to /.arashi/worktrees/ + expect(result.path).toBe(join(bareRepoPath, ".arashi", "worktrees", "feature-123")); expect(result.repositoryType).toBe("meta-repo"); expect(result.strategy).toBe("sibling"); }); @@ -189,8 +192,7 @@ describe("calculateWorktreePath", () => { const result = await calculateWorktreePath(repo, "bugfix-789", config); - // Bare repo should use: branch-name only - expect(result.path).toBe(join(testDir, "bugfix-789")); + expect(result.path).toBe(join(bareRepoPath, ".arashi", "worktrees", "bugfix-789")); expect(result.repositoryType).toBe("standalone"); expect(result.strategy).toBe("sibling"); }); @@ -227,11 +229,21 @@ describe("calculateWorktreePath", () => { const result = await calculateWorktreePath(childRepo, "feature-123", config); - // Non-bare parent: parent-repo-feature-123/repos/child-repo - expect(result.path).toBe(join(testDir, "parent-repo-feature-123", "repos", "child-repo")); + expect(result.path).toBe( + join( + metaRepoPath, + ".arashi", + "worktrees", + "parent-repo-feature-123", + "repos", + "child-repo", + ), + ); expect(result.repositoryType).toBe("child"); expect(result.strategy).toBe("nested"); - expect(result.parentWorktreePath).toBe(join(testDir, "parent-repo-feature-123")); + expect(result.parentWorktreePath).toBe( + join(metaRepoPath, ".arashi", "worktrees", "parent-repo-feature-123"), + ); }); test("should nest child repo with branch name only when parent is bare", async () => { @@ -264,11 +276,15 @@ describe("calculateWorktreePath", () => { const result = await calculateWorktreePath(childRepo, "feature-123", config); - // Bare parent: feature-123/repos/child-repo (branch name only!) - expect(result.path).toBe(join(testDir, "feature-123", "repos", "child-repo")); + // Bare parent: /.arashi/worktrees//repos/child-repo + expect(result.path).toBe( + join(bareMetaRepoPath, ".arashi", "worktrees", "feature-123", "repos", "child-repo"), + ); expect(result.repositoryType).toBe("child"); expect(result.strategy).toBe("nested"); - expect(result.parentWorktreePath).toBe(join(testDir, "feature-123")); + expect(result.parentWorktreePath).toBe( + join(bareMetaRepoPath, ".arashi", "worktrees", "feature-123"), + ); }); test("should handle multiple child repos of bare parent consistently", async () => { @@ -307,10 +323,73 @@ describe("calculateWorktreePath", () => { for (const childRepo of childRepos) { const result = await calculateWorktreePath(childRepo, "dev", config); - expect(result.path).toBe(join(testDir, "dev", "repos", childRepo.name)); + expect(result.path).toBe( + join(bareMetaRepoPath, ".arashi", "worktrees", "dev", "repos", childRepo.name), + ); expect(result.strategy).toBe("nested"); - expect(result.parentWorktreePath).toBe(join(testDir, "dev")); + expect(result.parentWorktreePath).toBe( + join(bareMetaRepoPath, ".arashi", "worktrees", "dev"), + ); } }); }); + + describe("configured worktreesDir variants", () => { + test("resolves equivalent configured values to identical paths", async () => { + const metaRepoPath = join(testDir, "variant-repo"); + await createGitRepo(metaRepoPath, false); + await mkdir(join(metaRepoPath, ".arashi"), { recursive: true }); + await writeFile( + join(metaRepoPath, ".arashi", "config.json"), + JSON.stringify({ version: "1.0.0", reposDir: "./repos" }), + ); + + const repo: Repository = { + name: "variant-repo", + path: metaRepoPath, + defaultBranch: "main", + hasSetupScript: false, + }; + + const withDot = await calculateWorktreePath(repo, "feature-variants", { + version: "1.0.0", + reposDir: "./repos", + worktree_strategy: "same_branch", + worktreesDir: ".", + repos: {}, + }); + + const withDotSlash = await calculateWorktreePath(repo, "feature-variants", { + version: "1.0.0", + reposDir: "./repos", + worktree_strategy: "same_branch", + worktreesDir: "./", + repos: {}, + }); + + const managedNoSlash = await calculateWorktreePath(repo, "feature-variants", { + version: "1.0.0", + reposDir: "./repos", + worktree_strategy: "same_branch", + worktreesDir: ".arashi/worktrees", + repos: {}, + }); + + const managedWithSlash = await calculateWorktreePath(repo, "feature-variants", { + version: "1.0.0", + reposDir: "./repos", + worktree_strategy: "same_branch", + worktreesDir: ".arashi/worktrees/", + repos: {}, + }); + + expect(withDot.path).toBe(withDotSlash.path); + expect(withDot.path).toBe(join(metaRepoPath, "variant-repo-feature-variants")); + + expect(managedNoSlash.path).toBe(managedWithSlash.path); + expect(managedNoSlash.path).toBe( + join(metaRepoPath, ".arashi", "worktrees", "variant-repo-feature-variants"), + ); + }); + }); }); From 9e5a500c2f54de3afd9a044f1e4899c0376bab97 Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 22 Feb 2026 19:36:14 -0800 Subject: [PATCH 2/2] chore: regenerate config schema for worktreesDir Align checked-in schema with the generated output so schema validation in CI is deterministic. Refs: https://github.com/corwinm/arashi-arashi/issues/113 --- schema/config.schema.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/schema/config.schema.json b/schema/config.schema.json index 6bae700..303bce1 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -32,10 +32,6 @@ "description": "Directory where repositories are located", "type": "string" }, - "worktreesDir": { - "description": "Base directory where worktrees are created (workspace-relative)", - "type": "string" - }, "sync": { "additionalProperties": false, "description": "Optional sync command settings", @@ -50,6 +46,10 @@ "version": { "$ref": "#/definitions/ConfigVersion", "description": "Configuration schema version for migrations" + }, + "worktreesDir": { + "description": "Base directory where worktrees are created (workspace-relative)", + "type": "string" } }, "required": ["version", "reposDir", "repos"],