feat(cli): Add git repository safety check (#328)

* feat(cli): Add git repository safety check

Add validation to ensure CLI workflows only run from within a git
repository. If the user runs from a subdirectory, the CLI resolves
to the repo root. Running from a non-git directory produces a clear
error message with guidance.

- Add git.findRepoRoot() check before workflow commands
- Bypass check for version/help commands
- Add tests for git repo validation behavior

* docs: Update CLI documentation for git repository requirement

* fix(cli): Improve error handling and tests for git repo check

- Move git.findRepoRoot() inside try-catch block to ensure proper error
  handling and database cleanup on unexpected errors
- Add path existence validation before git check for clearer error messages
- Improve tests: test real git.findRepoRoot behavior instead of mocks
- Add tests for path validation and command categorization logic
This commit is contained in:
Rasmus Widing 2026-01-22 10:00:12 +02:00 committed by GitHub
parent 006fa119a9
commit c142dbbc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 8 deletions

View file

@ -206,10 +206,10 @@ docker-compose --profile with-db down
### CLI (Command Line)
Run workflows directly from the command line without needing the server:
Run workflows directly from the command line without needing the server. Workflow and isolation commands require running from within a git repository (subdirectories work - resolves to repo root).
```bash
# List available workflows
# List available workflows (requires git repo)
bun run cli workflow list
# Run a workflow

View file

@ -56,6 +56,8 @@ archon workflow run assist --cwd /path/to/your/repo "What does this codebase do?
bun run cli workflow list --cwd /path/to/your/repo
```
**Note:** Workflow and isolation commands must be run from within a git repository. Running from subdirectories works (resolves to repo root). Version and help commands work anywhere.
**Detailed documentation:** [CLI User Guide](docs/cli-user-guide.md)
---

View file

@ -38,7 +38,14 @@ packages/cli/
┌─────────────────────────────────────────────────────────────────┐
│ cli.ts:154-228 Route to command handler │
│ cli.ts:154-170 Git repository check │
│ Skip for version/help, validate and resolve to │
│ repo root for workflow/isolation commands │
└─────────────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ cli.ts:172-246 Route to command handler │
│ switch(command) → workflow | isolation | version│
└─────────────────────────────────┬───────────────────────────────┘
@ -48,7 +55,13 @@ packages/cli/
└─────────────────────────────────────────────────────────────────┘
```
**Code:** `packages/cli/src/cli.ts:106-241`
**Code:** `packages/cli/src/cli.ts:106-259`
**Git repository check:**
- Commands `workflow` and `isolation` require running from a git repository
- Commands `version` and `help` bypass this check
- When in a subdirectory, automatically resolves to repository root
- Exit code 1 if not in a git repository
---
@ -278,7 +291,7 @@ Worktrees stored at: `~/.archon/worktrees/<repo>/<branch-slug>/`
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error (logged to stderr) |
| 1 | Error (logged to stderr, including not in git repo) |
---

View file

@ -30,7 +30,7 @@ Run AI-powered workflows from your terminal.
## Quick Start
```bash
# List available workflows
# List available workflows (requires git repository)
archon workflow list --cwd /path/to/repo
# Run a workflow
@ -40,6 +40,8 @@ archon workflow run assist --cwd /path/to/repo "Explain the authentication flow"
archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth support"
```
**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version` and `help` commands work anywhere.
## Commands
### `workflow list`
@ -118,6 +120,8 @@ The CLI determines where to run based on:
1. `--cwd` flag (if provided)
2. Current directory (default)
Running from a subdirectory (e.g., `/repo/packages/cli`) automatically resolves to the git repository root (e.g., `/repo`).
When using `--branch`, workflows run inside the worktree directory.
## Environment

View file

@ -6,6 +6,7 @@
*/
import { describe, it, expect } from 'bun:test';
import { parseArgs } from 'util';
import * as git from '@archon/core/utils/git';
// Test the argument parsing logic used in cli.ts
describe('CLI argument parsing', () => {
@ -131,3 +132,117 @@ describe('Conversation ID generation', () => {
expect(ids.size).toBe(100);
});
});
describe('CLI git repo check', () => {
/**
* These tests verify the command categorization logic used in cli.ts.
* The CLI uses: requiresGitRepo = !noGitCommands.includes(command ?? '')
* where noGitCommands = ['version', 'help']
*/
describe('command categorization', () => {
// Mirror the actual noGitCommands array from cli.ts
const noGitCommands = ['version', 'help'];
// Helper that mirrors the CLI's logic
const requiresGitRepo = (command: string | undefined): boolean => {
return !noGitCommands.includes(command ?? '');
};
describe('commands that bypass git check', () => {
it('version command should not require git repo', () => {
expect(requiresGitRepo('version')).toBe(false);
});
it('help command should not require git repo', () => {
expect(requiresGitRepo('help')).toBe(false);
});
});
describe('commands that require git repo', () => {
it('workflow command should require git repo', () => {
expect(requiresGitRepo('workflow')).toBe(true);
});
it('isolation command should require git repo', () => {
expect(requiresGitRepo('isolation')).toBe(true);
});
it('undefined command should require git repo (fail with unknown command later)', () => {
expect(requiresGitRepo(undefined)).toBe(true);
});
it('unknown commands should require git repo', () => {
expect(requiresGitRepo('unknown')).toBe(true);
});
});
});
describe('findRepoRoot behavior', () => {
// Test the actual git.findRepoRoot function with real directories
it('should find repo root from current test directory', async () => {
// This test file is inside a git repo, so findRepoRoot should work
const result = await git.findRepoRoot(process.cwd());
expect(result).not.toBeNull();
// The repo root should contain a .git directory or be the worktree root
expect(result).toMatch(/remote-coding-agent/);
});
it('should find repo root from a subdirectory', async () => {
// Use __dirname which is the directory containing this test file
// This is a real subdirectory (packages/cli/src) that should resolve to repo root
const subdirectory = import.meta.dir;
const result = await git.findRepoRoot(subdirectory);
// Should resolve to repo root (remote-coding-agent), not packages/cli/src
expect(result).not.toBeNull();
expect(result).toMatch(/remote-coding-agent$/);
expect(result).not.toContain('/packages/cli/src');
});
it('should return null for system directories outside any git repo', async () => {
// /tmp is typically not inside a git repo
// Note: This test may need adjustment if /tmp happens to be inside a repo
const result = await git.findRepoRoot('/tmp');
expect(result).toBeNull();
});
});
describe('path validation', () => {
// The CLI now validates that the path exists before calling findRepoRoot
// This tests the logic pattern used in cli.ts
const { existsSync } = require('fs');
it('should detect existing directories', () => {
expect(existsSync(process.cwd())).toBe(true);
expect(existsSync('/tmp')).toBe(true);
});
it('should detect non-existent directories', () => {
expect(existsSync('/this/path/definitely/does/not/exist/12345')).toBe(false);
});
});
describe('error messages', () => {
// Verify the exact error messages used in cli.ts for documentation purposes
const ERROR_MESSAGES = {
notGitRepo: [
'Error: Not in a git repository.',
'The Archon CLI must be run from within a git repository.',
'Either navigate to a git repo or use --cwd to specify one.',
],
dirNotExist: (path: string) => `Error: Directory does not exist: ${path}`,
};
it('should have actionable git repo error message', () => {
// Verify the messages include guidance
expect(ERROR_MESSAGES.notGitRepo[0]).toContain('Not in a git repository');
expect(ERROR_MESSAGES.notGitRepo[2]).toContain('--cwd');
});
it('should have clear directory error message', () => {
const msg = ERROR_MESSAGES.dirNotExist('/nonexistent');
expect(msg).toContain('Directory does not exist');
expect(msg).toContain('/nonexistent');
});
});
});

View file

@ -49,6 +49,7 @@ import {
} from './commands/workflow';
import { isolationListCommand, isolationCleanupCommand } from './commands/isolation';
import { closeDatabase } from '@archon/core';
import * as git from '@archon/core/utils/git';
/**
* Print usage information
@ -150,7 +151,31 @@ async function main(): Promise<number> {
const command = positionals[0];
const subcommand = positionals[1];
// Commands that don't require git repo validation
const noGitCommands = ['version', 'help'];
const requiresGitRepo = !noGitCommands.includes(command ?? '');
try {
// Validate working directory exists
let effectiveCwd = cwd;
if (requiresGitRepo) {
if (!existsSync(cwd)) {
console.error(`Error: Directory does not exist: ${cwd}`);
return 1;
}
// Validate git repository and resolve to root
const repoRoot = await git.findRepoRoot(cwd);
if (!repoRoot) {
console.error('Error: Not in a git repository.');
console.error('The Archon CLI must be run from within a git repository.');
console.error('Either navigate to a git repo or use --cwd to specify one.');
return 1;
}
// Use repo root as working directory (handles subdirectory case)
effectiveCwd = repoRoot;
}
switch (command) {
case 'version':
await versionCommand();
@ -163,7 +188,7 @@ async function main(): Promise<number> {
case 'workflow':
switch (subcommand) {
case 'list':
await workflowListCommand(cwd);
await workflowListCommand(effectiveCwd);
break;
case 'run': {
@ -175,7 +200,7 @@ async function main(): Promise<number> {
const userMessage = positionals.slice(3).join(' ') || '';
// Conditionally construct options to satisfy discriminated union
const options = branchName !== undefined ? { branchName, noWorktree } : {};
await workflowRunCommand(cwd, workflowName, userMessage, options);
await workflowRunCommand(effectiveCwd, workflowName, userMessage, options);
break;
}