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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions contracts/cli-commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,13 @@
"optional": false,
"variadic": false
},
{
"flags": "--ignore-scope <scope>",
"description": "Managed Git ignore scope: local (default), tracked, or none",
"required": true,
"optional": false,
"variadic": false
},
{
"flags": "--json",
"description": "Output result as JSON",
Expand Down
23 changes: 23 additions & 0 deletions docs/commands/init.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 103 additions & 61 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof loadConfig>>["repos"][string];

const ZERO = 0;
const JSON_INDENT = 2;
const ERROR_EXIT_CODE = 1;
const CANCELLED_EXIT_CODE = 2;

Expand Down Expand Up @@ -116,6 +127,9 @@ export interface AddCommandOptions {
json?: boolean;
}

export const shouldTreatFailedCloneAsMaterialized = (destinationExists: boolean): boolean =>
destinationExists;

/**
* Result of add operation
*/
Expand All @@ -132,6 +146,8 @@ export interface AddCommandResult {
setupScriptCreated: boolean;
/** Original Git URL that was cloned */
gitUrl: string;
/** Managed ignore reconciliation retained for the materialized repository. */
managedIgnore: ManagedIgnoreReconciliation;
}

/**
Expand Down Expand Up @@ -381,6 +397,9 @@ const executeAdd = async (
workspaceRoot: string,
): Promise<AddCommandResult> => {
const operations: RollbackOperation[] = [];
let existingFailedCloneDestinationSurvives = false;
let managedIgnore: ManagedIgnoreReconciliation | undefined = undefined;
const startSpinner = (text: string) => (options.json ? undefined : spinner(text).start());

try {
// Step 1: Validate workspace is initialized
Expand All @@ -394,9 +413,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;
Expand All @@ -419,14 +438,34 @@ 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();
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");
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");
throw new AddCommandError(
`Git clone operation failed: ${(error as Error).message}`,
AddCommandErrorCode.CLONE_FAILED,
Expand All @@ -435,24 +474,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,
Expand All @@ -461,9 +500,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,
Expand All @@ -476,11 +515,14 @@ const executeAdd = async (
clonePath,
defaultBranch,
gitUrl,
managedIgnore,
repositoryName,
setupScript,
setupScriptCreated: false,
};
} catch (error) {
let managedIgnoreRestoreError: string | undefined = undefined;
let materializedStateSurvives = existingFailedCloneDestinationSurvives;
// Rollback operations in reverse order
const rollbackOperations = [...operations];
rollbackOperations.reverse();
Expand All @@ -491,11 +533,33 @@ 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 && !materializedStateSurvives) {
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,
materializedStateSurvives,
...(managedIgnoreRestoreError ? { managedIgnoreRestoreError } : {}),
});
}

// Re-throw original error
throw error;
}
Expand Down Expand Up @@ -570,24 +634,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);
Expand All @@ -597,41 +657,23 @@ 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);
await maybeRunCloneFallback(error);
}
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);
}
Expand Down
Loading
Loading