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
4 changes: 4 additions & 0 deletions schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,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"],
Expand Down
104 changes: 81 additions & 23 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -393,17 +403,28 @@ async function writeHookTemplates(hooksDir: string): Promise<void> {
/**
* Update .gitignore to exclude repos directory (idempotent)
*/
async function updateGitignore(cwd: string, reposDir: string): Promise<void> {
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<void> {
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;
Expand All @@ -412,25 +433,23 @@ async function updateGitignore(cwd: string, reposDir: string): Promise<void> {
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
if (content && !content.endsWith("\n")) {
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);
Expand Down Expand Up @@ -526,7 +545,7 @@ async function executeInit(options: InitOptions): Promise<InitResult> {
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);

Expand All @@ -541,6 +560,31 @@ async function executeInit(options: InitOptions): Promise<InitResult> {
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");

Expand Down Expand Up @@ -706,6 +750,7 @@ async function executeInit(options: InitOptions): Promise<InitResult> {
$schema: config.DEFAULT_CONFIG_SCHEMA_URL,
version: "1.0.0",
reposDir: reposDir,
worktreesDir,
repos: discoveredRepos,
};

Expand Down Expand Up @@ -752,12 +797,16 @@ async function executeInit(options: InitOptions): Promise<InitResult> {

// 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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -880,6 +932,11 @@ export function createCommand(): Command {
return new Command("init")
.description("Initialize Arashi workspace in the current git repository")
.option("--repos-dir <path>", "Custom location for managed repositories", "./repos")
.option(
"--worktrees-dir <path>",
"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")
Expand All @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions src/core/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ============================================================================
Expand Down Expand Up @@ -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") {
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -283,6 +290,7 @@ export function generateDefaultConfig(): Config {
$schema: DEFAULT_CONFIG_SCHEMA_URL,
version: CURRENT_CONFIG_VERSION,
reposDir: "./repos",
worktreesDir: DEFAULT_WORKTREES_DIR,
repos: {},
};
}
Expand All @@ -296,6 +304,8 @@ const ROOT_ALLOWED_KEYS = new Set([
"version",
"reposDir",
"repos_dir",
"worktreesDir",
"worktrees_dir",
"repos",
"discoveredRepos",
"discovered_repos",
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, unknown> | undefined,
config.discoveredRepos as Record<string, unknown> | undefined,
config.discovered_repos as Record<string, unknown> | undefined,
);
const worktreesDir = normalizeWorktreesDirConfig(worktreesDirRaw, errors);
const hooks = normalizeWorkspaceHooks(config.hooks, "hooks", errors);
const sync = normalizeSyncConfig(config.sync, "sync", errors);

Expand Down Expand Up @@ -550,6 +587,7 @@ function normalizeConfigInternal(config: unknown): {
const normalizedConfig: Config = {
version: normalizedVersion,
reposDir: normalizedReposDir,
worktreesDir,
repos: normalizedRepos,
};

Expand Down Expand Up @@ -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,
};

Expand Down
Loading
Loading