diff --git a/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/.openspec.yaml b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/proposal.md b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/proposal.md new file mode 100644 index 0000000..1804d87 --- /dev/null +++ b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/proposal.md @@ -0,0 +1,16 @@ +## Why + +- `src/cli/main.js` still carries duplicate parser, git, and dispatch helpers even after the first modularization pass. +- Those duplicate definitions already break the current branch with `SyntaxError: Identifier 'normalizeManagedForcePath' has already been declared`. +- Keeping both copies also preserves behavior drift risk, especially around nested repo discovery and command parsing. + +## What Changes + +- Make `src/cli/main.js` import the extracted parser, git, and dispatch helpers instead of redefining them locally. +- Keep command behavior stable by moving helper ownership to the existing extracted modules only. +- Add focused regression coverage that fails if `src/cli/main.js` regains local copies of the extracted helpers. + +## Impact + +- Primary files: `src/cli/main.js`, `src/cli/args.js`, `src/cli/dispatch.js`, `src/git/index.js`, and `test/cli-args-dispatch.test.js`. +- Main risk is accidental behavior drift while deleting local helpers, so verification stays focused on syntax plus representative CLI routes. diff --git a/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/specs/cli-modularization/spec.md new file mode 100644 index 0000000..748d982 --- /dev/null +++ b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/specs/cli-modularization/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Module seams mirror operational responsibility +The CLI SHALL separate major operational seams into dedicated modules under `src/` instead of keeping duplicated helper ownership in `src/cli/main.js`. + +#### Scenario: Extracted helper ownership stays single-source +- **WHEN** maintainers inspect `src/cli/main.js` +- **THEN** parser helpers are imported from `src/cli/args.js` +- **AND** git/worktree helpers are imported from `src/git/index.js` +- **AND** command typo/deprecation helpers are imported from `src/cli/dispatch.js` +- **AND** `src/cli/main.js` does not redefine those helpers locally. + +### Requirement: Refactor preserves targeted CLI behavior +The modularization SHALL preserve the current command surface for targeted verified flows while deleting the local duplicate helpers. + +#### Scenario: Extracted helper seams remain wired through representative commands +- **WHEN** the focused CLI regression suites are run after the helper cleanup +- **THEN** representative command routes still execute through `src/cli/main.js` +- **AND** syntax/require-time failures do not occur from duplicate helper definitions. diff --git a/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/tasks.md b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/tasks.md new file mode 100644 index 0000000..e3a1475 --- /dev/null +++ b/openspec/changes/agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when all of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks, add a `BLOCKED:` line under section 4 and stop. + +## Handoff + +- Handoff: change=`agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48`; branch=`agent/codex/split-cli-main-args-dispatch-2026-04-22-13-48`; scope=`src/cli/main.js`, `src/cli/args.js`, `src/cli/dispatch.js`, `src/git/index.js`, `test/cli-args-dispatch.test.js`; action=`delete duplicate helper definitions from src/cli/main.js and keep extracted seams single-sourced`. + +## 1. Specification + +- [x] 1.1 Capture follow-up cleanup scope and acceptance criteria for the extracted CLI helper seams. +- [x] 1.2 Add a spec delta for single-source helper ownership under `cli-modularization`. + +## 2. Implementation + +- [x] 2.1 Remove duplicate parser helper definitions from `src/cli/main.js` and use `src/cli/args.js`. +- [x] 2.2 Remove duplicate git helper definitions from `src/cli/main.js` and use `src/git/index.js`. +- [x] 2.3 Remove duplicate dispatch helper definitions from `src/cli/main.js` and use `src/cli/dispatch.js`. + +## 3. Verification + +- [x] 3.1 Add/update focused regression coverage for extracted args/dispatch delegation. +- [x] 3.2 Run `node --check src/cli/main.js src/cli/args.js src/cli/dispatch.js src/git/index.js`. +- [x] 3.3 Run focused CLI regression suites covering the extracted helper seams. +- [x] 3.4 Run `openspec validate agent-codex-split-cli-main-args-dispatch-2026-04-22-13-48 --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. + +Verification note: `node --test test/cli-args-dispatch.test.js`, `node --test test/metadata.test.js`, `node --test test/setup.test.js`, `node --test test/doctor.test.js`, and `npm test` all passed after removing the remaining local parser/dispatch copies from `src/cli/main.js`. `openspec validate --specs` exited `0` with `No items found to validate` in this repo. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/split-cli-main-args-dispatch-2026-04-22-13-48 --base dev --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is removed and no local/remote refs remain for the branch. diff --git a/openspec/changes/setup-current-single-repo-alias/proposal.md b/openspec/changes/setup-current-single-repo-alias/proposal.md new file mode 100644 index 0000000..ad7b324 --- /dev/null +++ b/openspec/changes/setup-current-single-repo-alias/proposal.md @@ -0,0 +1,17 @@ +## Why + +- `gx setup` recurses into nested repos by default, so a top-level workspace can rewrite child repos when the user only wanted to bootstrap the current repo. +- `--no-recursive` already limits setup to the target repo, but users now expect the shorter `--current` alias after `gx doctor --current` shipped. +- The user explicitly wants both `gx doctor --current` and `gx setup --current` to leave nested repos under the target path untouched. + +## What Changes + +- Accept `--current` as a setup alias for the existing single-repo traversal behavior. +- Update recursive setup messaging to advertise `--current` alongside `--no-recursive`. +- Add regression coverage proving `gx setup --current` leaves nested repos unmodified. + +## Impact + +- Affected surface: `src/cli/args.js`, `src/cli/main.js`, `test/setup.test.js`. +- Expected outcome: `gx setup --current` scopes bootstrap/repair work to the target repo without mutating nested repos. +- Risk: low, because the alias reuses the existing non-recursive traversal path. diff --git a/openspec/changes/setup-current-single-repo-alias/specs/setup-workflow/spec.md b/openspec/changes/setup-current-single-repo-alias/specs/setup-workflow/spec.md new file mode 100644 index 0000000..062223c --- /dev/null +++ b/openspec/changes/setup-current-single-repo-alias/specs/setup-workflow/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: setup current alias limits installs to the target repo +The system SHALL support `gx setup --current` as an alias for the existing single-repo setup path. + +#### Scenario: current alias skips nested repo setup +- **GIVEN** a parent repo contains a nested standalone git repo +- **WHEN** `gx setup --target --current` runs +- **THEN** the setup flow SHALL install and repair only `` +- **AND** the nested repo SHALL not be traversed or modified during that run. diff --git a/openspec/changes/setup-current-single-repo-alias/tasks.md b/openspec/changes/setup-current-single-repo-alias/tasks.md new file mode 100644 index 0000000..4bbf7dc --- /dev/null +++ b/openspec/changes/setup-current-single-repo-alias/tasks.md @@ -0,0 +1,37 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks, add a `BLOCKED:` line under section 4 and stop. + +## 1. Specification + +- [x] 1.1 Capture the `gx setup --current` alias scope and acceptance criteria. +- [x] 1.2 Add normative OpenSpec coverage for the single-repo setup alias behavior. + +## 2. Implementation + +- [x] 2.1 Accept `--current` as a setup alias for the existing single-repo traversal path. +- [x] 2.2 Update recursive setup messaging to mention `--current`. +- [x] 2.3 Add a regression proving nested repos under the target path stay untouched. + +## 3. Verification + +- [x] 3.1 Run `node --check bin/multiagent-safety.js`. +- [x] 3.2 Run `node --test test/setup.test.js`. +- [x] 3.3 Run `node --test test/metadata.test.js`. +- [x] 3.4 Run `openspec validate setup-current-single-repo-alias --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. + +Verification note: `node --test test/doctor.test.js` also passed, keeping `gx doctor --current` green after moving repo-traversal parsing into `src/cli/args.js`. Direct dry-run CLI smoke proved both `doctor --current` and `setup --current` now parse, then continued into normal repo-drift/conflict handling instead of failing with `Unknown option: --current`. + +## 4. Cleanup + +- [ ] 4.1 Commit the change with a Lore commit message. +- [ ] 4.2 Run `gx branch finish --branch agent/codex/split-cli-main-args-dispatch-2026-04-22-13-48 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.3 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.4 Confirm the sandbox worktree and branch refs are gone after cleanup. + +Implementation note: the `--current` alias now routes through shared repo-traversal parsing in `src/cli/args.js`, so `gx setup --current` and `gx doctor --current` both stay on the single-repo path without mutating nested repos. diff --git a/src/cli/args.js b/src/cli/args.js index d6ae898..59f1e87 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -1,7 +1,809 @@ -function parseDoctorArgs(rawArgs, options = {}) { - return { rawArgs, options }; +const { + path, + DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, + TARGETED_FORCEABLE_MANAGED_PATHS, +} = require('../context'); +const { DEFAULT_NESTED_REPO_MAX_DEPTH } = require('../git'); + +function requireValue(rawArgs, index, flagName) { + const value = rawArgs[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +function normalizeManagedForcePath(rawPath) { + if (typeof rawPath !== 'string') { + return null; + } + const normalized = path.posix.normalize(rawPath.replace(/\\/g, '/')); + if (!normalized || normalized === '.' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) { + return null; + } + return normalized.startsWith('./') ? normalized.slice(2) : normalized; +} + +function collectForceManagedPaths(rawArgs, startIndex) { + const forceManagedPaths = []; + let nextIndex = startIndex; + + while (nextIndex + 1 < rawArgs.length) { + const candidate = rawArgs[nextIndex + 1]; + if (!candidate || candidate.startsWith('-')) { + break; + } + const normalized = normalizeManagedForcePath(candidate); + if (!normalized || !TARGETED_FORCEABLE_MANAGED_PATHS.has(normalized)) { + throw new Error(`Unknown managed path after --force: ${candidate}`); + } + forceManagedPaths.push(normalized); + nextIndex += 1; + } + + return { forceManagedPaths, nextIndex }; +} + +function parseCommonArgs(rawArgs, defaults) { + const options = { ...defaults }; + const supportsForce = Object.prototype.hasOwnProperty.call(options, 'force'); + if (supportsForce && !Array.isArray(options.forceManagedPaths)) { + options.forceManagedPaths = []; + } + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target' || arg === '-t') { + options.target = requireValue(rawArgs, index, '--target'); + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--skip-agents') { + options.skipAgents = true; + continue; + } + if (arg === '--skip-package-json') { + options.skipPackageJson = true; + continue; + } + if (arg === '--force') { + if (!supportsForce) { + throw new Error(`Unknown option: ${arg}`); + } + options.force = true; + const parsed = collectForceManagedPaths(rawArgs, index); + if (parsed.forceManagedPaths.length > 0) { + options.forceManagedPaths = Array.from( + new Set([...(options.forceManagedPaths || []), ...parsed.forceManagedPaths]), + ); + } + index = parsed.nextIndex; + continue; + } + if (arg === '--keep-stale-locks') { + options.dropStaleLocks = false; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg === '--yes-global-install') { + options.yesGlobalInstall = true; + continue; + } + if (arg === '--no-global-install') { + options.noGlobalInstall = true; + continue; + } + if (arg === '--no-gitignore') { + options.skipGitignore = true; + continue; + } + if (arg === '--allow-protected-base-write') { + options.allowProtectedBaseWrite = true; + continue; + } + if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') { + options.waitForMerge = true; + continue; + } + if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') { + options.waitForMerge = false; + continue; + } + + throw new Error(`Unknown option: ${arg}`); + } + + if (!options.target) { + throw new Error('--target requires a path value'); + } + + return options; +} + +function parseRepoTraversalArgs(rawArgs, defaults) { + const traversalDefaults = { + ...defaults, + recursive: true, + nestedMaxDepth: DEFAULT_NESTED_REPO_MAX_DEPTH, + nestedSkipDirs: [], + includeSubmodules: false, + }; + const forwardedArgs = []; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo' || arg === '--current') { + traversalDefaults.recursive = false; + continue; + } + if (arg === '--recursive' || arg === '--nested') { + traversalDefaults.recursive = true; + continue; + } + if (arg === '--max-depth') { + const raw = requireValue(rawArgs, index, '--max-depth'); + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error('--max-depth requires a positive integer'); + } + traversalDefaults.nestedMaxDepth = parsed; + index += 1; + continue; + } + if (arg === '--skip-nested') { + const raw = requireValue(rawArgs, index, '--skip-nested'); + traversalDefaults.nestedSkipDirs.push(raw); + index += 1; + continue; + } + if (arg === '--include-submodules') { + traversalDefaults.includeSubmodules = true; + continue; + } + forwardedArgs.push(arg); + } + + return parseCommonArgs(forwardedArgs, traversalDefaults); +} + +function parseSetupArgs(rawArgs, defaults) { + const setupDefaults = { + ...defaults, + parentWorkspaceView: false, + }; + const forwardedArgs = []; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--parent-workspace-view') { + setupDefaults.parentWorkspaceView = true; + continue; + } + if (arg === '--no-parent-workspace-view') { + setupDefaults.parentWorkspaceView = false; + continue; + } + forwardedArgs.push(arg); + } + + return parseRepoTraversalArgs(forwardedArgs, setupDefaults); +} + +function parseDoctorArgs(rawArgs) { + const doctorDefaults = { + target: process.cwd(), + force: false, + dropStaleLocks: true, + skipAgents: false, + skipPackageJson: false, + skipGitignore: false, + dryRun: false, + json: false, + allowProtectedBaseWrite: false, + waitForMerge: true, + verboseAutoFinish: false, + }; + const forwardedArgs = []; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--verbose-auto-finish') { + doctorDefaults.verboseAutoFinish = true; + continue; + } + if (arg === '--compact-auto-finish') { + doctorDefaults.verboseAutoFinish = false; + continue; + } + forwardedArgs.push(arg); + } + + return parseRepoTraversalArgs(forwardedArgs, doctorDefaults); +} + +function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { + const remaining = []; + let target = defaultTarget; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + target = next; + index += 1; + continue; + } + remaining.push(arg); + } + + return { target, args: remaining }; +} + +function parseReviewArgs(rawArgs) { + const parsed = parseTargetFlag(rawArgs, process.cwd()); + const passthroughArgs = [...parsed.args]; + if (passthroughArgs[0] === 'start') { + passthroughArgs.shift(); + } + return { + target: parsed.target, + passthroughArgs, + }; +} + +function parseAgentsArgs(rawArgs) { + const parsed = parseTargetFlag(rawArgs, process.cwd()); + const [subcommandRaw = '', ...rest] = parsed.args; + const subcommand = subcommandRaw || 'status'; + const options = { + target: parsed.target, + subcommand, + reviewIntervalSeconds: 30, + cleanupIntervalSeconds: 60, + idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, + }; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (arg === '--review-interval') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--review-interval requires an integer seconds value'); + } + const parsedValue = Number.parseInt(next, 10); + if (!Number.isInteger(parsedValue) || parsedValue < 5) { + throw new Error('--review-interval must be an integer >= 5 seconds'); + } + options.reviewIntervalSeconds = parsedValue; + index += 1; + continue; + } + if (arg === '--cleanup-interval') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--cleanup-interval requires an integer seconds value'); + } + const parsedValue = Number.parseInt(next, 10); + if (!Number.isInteger(parsedValue) || parsedValue < 5) { + throw new Error('--cleanup-interval must be an integer >= 5 seconds'); + } + options.cleanupIntervalSeconds = parsedValue; + index += 1; + continue; + } + if (arg === '--idle-minutes') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--idle-minutes requires an integer minutes value'); + } + const parsedValue = Number.parseInt(next, 10); + if (!Number.isInteger(parsedValue) || parsedValue < 1) { + throw new Error('--idle-minutes must be an integer >= 1'); + } + options.idleMinutes = parsedValue; + index += 1; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (!['start', 'stop', 'status'].includes(options.subcommand)) { + throw new Error(`Unknown agents subcommand: ${options.subcommand}`); + } + + return options; +} + +function parseReportArgs(rawArgs) { + const options = { + target: process.cwd(), + subcommand: '', + repo: '', + scorecardJson: '', + outputDir: '', + date: '', + dryRun: false, + json: false, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--target requires a path value'); + options.target = next; + index += 1; + continue; + } + if (arg === '--repo') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--repo requires a value like github.com/owner/repo'); + options.repo = next; + index += 1; + continue; + } + if (arg === '--scorecard-json') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--scorecard-json requires a path value'); + options.scorecardJson = next; + index += 1; + continue; + } + if (arg === '--output-dir') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--output-dir requires a path value'); + options.outputDir = next; + index += 1; + continue; + } + if (arg === '--date') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--date requires a YYYY-MM-DD value'); + options.date = next; + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + if (!options.subcommand) { + options.subcommand = arg; + continue; + } + throw new Error(`Unexpected argument: ${arg}`); + } + + return options; +} + +function parseSyncArgs(rawArgs) { + const options = { + target: process.cwd(), + check: false, + base: '', + strategy: '', + ffOnly: false, + dryRun: false, + json: false, + allAgentBranches: false, + allowNonAgent: false, + allowDirty: false, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--strategy') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--strategy requires a value (rebase|merge)'); + } + options.strategy = next; + index += 1; + continue; + } + if (arg === '--check') { + options.check = true; + continue; + } + if (arg === '--ff-only') { + options.ffOnly = true; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg === '--all-agent-branches') { + options.allAgentBranches = true; + continue; + } + if (arg === '--allow-non-agent') { + options.allowNonAgent = true; + continue; + } + if (arg === '--allow-dirty') { + options.allowDirty = true; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + return options; +} + +function parseCleanupArgs(rawArgs) { + const options = { + target: process.cwd(), + base: '', + branch: '', + dryRun: false, + forceDirty: false, + keepRemote: false, + keepCleanWorktrees: false, + includePrMerged: false, + idleMinutes: 0, + watch: false, + intervalSeconds: 60, + once: false, + maxBranches: 0, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires an agent branch value'); + } + options.branch = next; + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--force-dirty') { + options.forceDirty = true; + continue; + } + if (arg === '--keep-remote') { + options.keepRemote = true; + continue; + } + if (arg === '--keep-clean-worktrees') { + options.keepCleanWorktrees = true; + continue; + } + if (arg === '--include-pr-merged') { + options.includePrMerged = true; + continue; + } + if (arg === '--idle-minutes') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--idle-minutes requires an integer value'); + } + const parsed = Number.parseInt(next, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error('--idle-minutes must be an integer >= 0'); + } + options.idleMinutes = parsed; + index += 1; + continue; + } + if (arg === '--watch') { + options.watch = true; + continue; + } + if (arg === '--interval') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--interval requires an integer seconds value'); + } + const parsed = Number.parseInt(next, 10); + if (!Number.isInteger(parsed) || parsed < 5) { + throw new Error('--interval must be an integer >= 5 seconds'); + } + options.intervalSeconds = parsed; + index += 1; + continue; + } + if (arg === '--once') { + options.once = true; + continue; + } + if (arg === '--max-branches') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--max-branches requires an integer value'); + } + const parsed = Number.parseInt(next, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error('--max-branches must be an integer >= 1'); + } + options.maxBranches = parsed; + index += 1; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (options.watch && options.idleMinutes === 0) { + options.idleMinutes = DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES; + } + + return options; +} + +function parseMergeArgs(rawArgs) { + const options = { + target: process.cwd(), + base: '', + into: '', + branches: [], + task: '', + agent: '', + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--into') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--into requires an agent/* branch value'); + } + options.into = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires an agent/* branch value'); + } + options.branches.push(next); + index += 1; + continue; + } + if (arg === '--task') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--task requires a task value'); + } + options.task = next; + index += 1; + continue; + } + if (arg === '--agent') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--agent requires an agent value'); + } + options.agent = next; + index += 1; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (options.branches.length === 0) { + throw new Error('merge requires at least one --branch input'); + } + + return options; +} + +function parseFinishArgs(rawArgs, defaults = {}) { + const options = { + target: process.cwd(), + base: '', + branch: '', + all: false, + dryRun: false, + waitForMerge: defaults.waitForMerge ?? true, + cleanup: defaults.cleanup ?? true, + keepRemote: false, + noAutoCommit: false, + failFast: false, + commitMessage: '', + mergeMode: defaults.mergeMode || 'pr', + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires an agent/* branch value'); + } + options.branch = next; + index += 1; + continue; + } + if (arg === '--commit-message') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--commit-message requires a value'); + } + options.commitMessage = next; + index += 1; + continue; + } + if (arg === '--all') { + options.all = true; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--wait-for-merge') { + options.waitForMerge = true; + continue; + } + if (arg === '--no-wait-for-merge') { + options.waitForMerge = false; + continue; + } + if (arg === '--via-pr') { + options.mergeMode = 'pr'; + continue; + } + if (arg === '--direct-only') { + options.mergeMode = 'direct'; + continue; + } + if (arg === '--mode') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--mode requires a value'); + } + if (!['auto', 'direct', 'pr'].includes(next)) { + throw new Error(`Invalid --mode value: ${next} (expected auto|direct|pr)`); + } + options.mergeMode = next; + index += 1; + continue; + } + if (arg === '--cleanup') { + options.cleanup = true; + continue; + } + if (arg === '--no-cleanup') { + options.cleanup = false; + continue; + } + if (arg === '--keep-remote') { + options.keepRemote = true; + continue; + } + if (arg === '--no-auto-commit') { + options.noAutoCommit = true; + continue; + } + if (arg === '--fail-fast') { + options.failFast = true; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (options.branch && !options.branch.startsWith('agent/')) { + throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`); + } + + return options; } module.exports = { + requireValue, + normalizeManagedForcePath, + collectForceManagedPaths, + parseCommonArgs, + parseRepoTraversalArgs, + parseSetupArgs, parseDoctorArgs, + parseTargetFlag, + parseReviewArgs, + parseAgentsArgs, + parseReportArgs, + parseSyncArgs, + parseCleanupArgs, + parseMergeArgs, + parseFinishArgs, }; diff --git a/src/cli/main.js b/src/cli/main.js index 6f349dc..2801de4 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -8,6 +8,40 @@ const hooksModule = require('../hooks'); const sandboxModule = require('../sandbox'); const toolchainModule = require('../toolchain'); const finishModule = require('../finish'); +const { + gitRun, + resolveRepoRoot, + isGitRepo, + discoverNestedGitRepos, +} = require('../git'); +const { + run, + extractTargetedArgs, + packageAssetEnv, + runPackageAsset, + runReviewBotCommand, + invokePackageAsset, +} = require('../core/runtime'); +const { + normalizeManagedForcePath, + parseCommonArgs, + parseSetupArgs, + parseDoctorArgs, + parseTargetFlag, + parseReviewArgs, + parseAgentsArgs, + parseReportArgs, + parseSyncArgs, + parseCleanupArgs, + parseMergeArgs, + parseFinishArgs, +} = require('./args'); +const { + maybeSuggestCommand, + normalizeCommandOrThrow, + warnDeprecatedAlias, + extractFlag, +} = require('./dispatch'); let sandboxApi; let toolchainApi; @@ -93,7 +127,6 @@ const GUARDEX_REPO_TOGGLE_ENV = 'GUARDEX_ON'; const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master']; const DEFAULT_BASE_BRANCH = 'dev'; const DEFAULT_SYNC_STRATEGY = 'rebase'; -const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60; const COMPOSE_HINT_FILES = [ 'docker-compose.yml', 'docker-compose.yaml', @@ -270,13 +303,6 @@ const OMX_SCAFFOLD_FILES = new Map([ ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'], ['.omx/project-memory.json', '{}\n'], ]); -const TARGETED_FORCEABLE_MANAGED_PATHS = new Set([ - 'AGENTS.md', - '.gitignore', - ...Array.from(OMX_SCAFFOLD_FILES.keys()), - ...REQUIRED_MANAGED_REPO_FILES, - ...LEGACY_WORKFLOW_SHIMS, -]); const COMMAND_TYPO_ALIASES = new Map([ ['relaese', 'release'], ['realaese', 'release'], @@ -778,100 +804,6 @@ NOTES } } -function run(cmd, args, options = {}) { - return cp.spawnSync(cmd, args, { - encoding: 'utf8', - stdio: options.stdio || 'pipe', - cwd: options.cwd, - env: options.env ? { ...process.env, ...options.env } : process.env, - timeout: options.timeout, - }); -} - -function extractTargetedArgs(rawArgs, defaultTarget = process.cwd()) { - const passthrough = []; - let target = defaultTarget; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target' || arg === '-t') { - target = requireValue(rawArgs, index, '--target'); - index += 1; - continue; - } - passthrough.push(arg); - } - - return { target, passthrough }; -} - -function packageAssetEnv(extraEnv = {}) { - return { - GUARDEX_CLI_ENTRY: __filename, - GUARDEX_NODE_BIN: process.execPath, - ...extraEnv, - }; -} - -function packageAssetPath(assetKey) { - const assetPath = PACKAGE_SCRIPT_ASSETS[assetKey]; - if (!assetPath) { - throw new Error(`Unknown package asset: ${assetKey}`); - } - if (!fs.existsSync(assetPath)) { - throw new Error(`Missing package asset: ${assetPath}`); - } - return assetPath; -} - -function runPackageAsset(assetKey, rawArgs, options = {}) { - const assetPath = packageAssetPath(assetKey); - let cmd = 'bash'; - if (assetPath.endsWith('.py')) { - cmd = 'python3'; - } else if (assetPath.endsWith('.js')) { - cmd = process.execPath; - } - return run(cmd, [assetPath, ...rawArgs], { - cwd: options.cwd || process.cwd(), - stdio: options.stdio || 'pipe', - timeout: options.timeout, - env: packageAssetEnv(options.env), - }); -} - -function repoLocalLegacyScriptPath(repoRoot, relativePath) { - const assetPath = path.join(repoRoot, relativePath); - return fs.existsSync(assetPath) ? assetPath : null; -} - -function runReviewBotCommand(repoRoot, rawArgs, options = {}) { - const legacyScript = repoLocalLegacyScriptPath(repoRoot, 'scripts/review-bot-watch.sh'); - if (legacyScript) { - return run('bash', [legacyScript, ...rawArgs], { - cwd: repoRoot, - stdio: options.stdio || 'pipe', - timeout: options.timeout, - env: packageAssetEnv(options.env), - }); - } - return runPackageAsset('reviewBot', rawArgs, { - ...options, - cwd: repoRoot, - }); -} - -function invokePackageAsset(assetKey, rawArgs, options = {}) { - const result = runPackageAsset(assetKey, rawArgs, options); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - if (result.status !== 0) { - throw new Error(`${assetKey} command failed with status ${result.status}`); - } - process.exitCode = 0; - return result; -} - function formatElapsedDuration(ms) { const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0; if (durationMs < 1000) { @@ -1023,114 +955,6 @@ function printAutoFinishSummary(summary, options = {}) { } } -function gitRun(repoRoot, args, { allowFailure = false } = {}) { - const result = run('git', ['-C', repoRoot, ...args]); - if (!allowFailure && result.status !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`); - } - return result; -} - -function resolveRepoRoot(targetPath) { - const resolvedTarget = path.resolve(targetPath || process.cwd()); - const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']); - if (result.status !== 0) { - const stderr = (result.stderr || '').trim(); - throw new Error( - `Target is not inside a git repository: ${resolvedTarget}${stderr ? `\n${stderr}` : ''}`, - ); - } - return result.stdout.trim(); -} - -function isGitRepo(targetPath) { - const resolvedTarget = path.resolve(targetPath || process.cwd()); - const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']); - return result.status === 0; -} - -const NESTED_REPO_DEFAULT_MAX_DEPTH = 6; -const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - '.cache', - 'target', - 'vendor', - '.venv', - '.pnpm-store', -]); -function discoverNestedGitRepos(rootPath, opts = {}) { - const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH; - const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []); - const includeSubmodules = Boolean(opts.includeSubmodules); - const resolvedRoot = path.resolve(rootPath); - - const rootCommonDir = (() => { - const result = run('git', ['-C', resolvedRoot, 'rev-parse', '--git-common-dir'], { cwd: resolvedRoot }); - if (result.status !== 0) return null; - const raw = result.stdout.trim(); - if (!raw) return null; - return path.resolve(resolvedRoot, raw); - })(); - - const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir)); - const found = new Set(); - found.add(resolvedRoot); - - function shouldSkipDir(dirName) { - return NESTED_REPO_DEFAULT_SKIP_DIRS.has(dirName) || extraSkip.has(dirName); - } - - function walk(currentPath, depth) { - if (depth > maxDepth) return; - let entries; - try { - entries = fs.readdirSync(currentPath, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const entryPath = path.join(currentPath, entry.name); - - if (entry.name === '.git') { - if (entry.isDirectory()) { - if (entryPath === path.join(resolvedRoot, '.git')) continue; - found.add(path.dirname(entryPath)); - } else if (includeSubmodules && entry.isFile()) { - found.add(path.dirname(entryPath)); - } - continue; - } - - if (!entry.isDirectory() || entry.isSymbolicLink()) continue; - if (shouldSkipDir(entry.name)) continue; - if (worktreeSkipAbsolutes.includes(entryPath)) continue; - walk(entryPath, depth + 1); - } - } - - walk(resolvedRoot, 0); - - const filtered = Array.from(found).filter((repoPath) => { - if (repoPath === resolvedRoot) return true; - if (!rootCommonDir) return true; - const childResult = run('git', ['-C', repoPath, 'rev-parse', '--git-common-dir'], { cwd: repoPath }); - if (childResult.status !== 0) return true; - const childCommonDirRaw = childResult.stdout.trim(); - if (!childCommonDirRaw) return true; - const childCommonDir = path.resolve(repoPath, childCommonDirRaw); - return childCommonDir !== rootCommonDir; - }); - - const [root, ...rest] = filtered; - rest.sort((a, b) => a.localeCompare(b)); - return [root, ...rest]; -} - function toDestinationPath(relativeTemplatePath) { if (relativeTemplatePath.startsWith('scripts/')) { return relativeTemplatePath; @@ -1857,45 +1681,6 @@ function configureHooks(repoRoot, dryRun) { return { status: 'set', key: 'core.hooksPath', value: '.githooks' }; } -function requireValue(rawArgs, index, flagName) { - const value = rawArgs[index + 1]; - if (!value || value.startsWith('-')) { - throw new Error(`${flagName} requires a value`); - } - return value; -} - -function normalizeManagedForcePath(rawPath) { - if (typeof rawPath !== 'string') { - return null; - } - const normalized = path.posix.normalize(rawPath.replace(/\\/g, '/')); - if (!normalized || normalized === '.' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) { - return null; - } - return normalized.startsWith('./') ? normalized.slice(2) : normalized; -} - -function collectForceManagedPaths(rawArgs, startIndex) { - const forceManagedPaths = []; - let nextIndex = startIndex; - - while (nextIndex + 1 < rawArgs.length) { - const candidate = rawArgs[nextIndex + 1]; - if (!candidate || candidate.startsWith('-')) { - break; - } - const normalized = normalizeManagedForcePath(candidate); - if (!normalized || !TARGETED_FORCEABLE_MANAGED_PATHS.has(normalized)) { - throw new Error(`Unknown managed path after --force: ${candidate}`); - } - forceManagedPaths.push(normalized); - nextIndex += 1; - } - - return { forceManagedPaths, nextIndex }; -} - function appendForceArgs(args, options) { if (!options.force) { return; @@ -1918,190 +1703,6 @@ function shouldForceManagedPath(options, relativePath) { return normalized !== null && targetedPaths.includes(normalized); } -function parseCommonArgs(rawArgs, defaults) { - const options = { ...defaults }; - const supportsForce = Object.prototype.hasOwnProperty.call(options, 'force'); - if (supportsForce && !Array.isArray(options.forceManagedPaths)) { - options.forceManagedPaths = []; - } - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target' || arg === '-t') { - options.target = requireValue(rawArgs, index, '--target'); - index += 1; - continue; - } - if (arg === '--dry-run') { - options.dryRun = true; - continue; - } - if (arg === '--skip-agents') { - options.skipAgents = true; - continue; - } - if (arg === '--skip-package-json') { - options.skipPackageJson = true; - continue; - } - if (arg === '--force') { - if (!supportsForce) { - throw new Error(`Unknown option: ${arg}`); - } - options.force = true; - const parsed = collectForceManagedPaths(rawArgs, index); - if (parsed.forceManagedPaths.length > 0) { - options.forceManagedPaths = Array.from( - new Set([...(options.forceManagedPaths || []), ...parsed.forceManagedPaths]), - ); - } - index = parsed.nextIndex; - continue; - } - if (arg === '--keep-stale-locks') { - options.dropStaleLocks = false; - continue; - } - if (arg === '--json') { - options.json = true; - continue; - } - if (arg === '--yes-global-install') { - options.yesGlobalInstall = true; - continue; - } - if (arg === '--no-global-install') { - options.noGlobalInstall = true; - continue; - } - if (arg === '--no-gitignore') { - options.skipGitignore = true; - continue; - } - if (arg === '--allow-protected-base-write') { - options.allowProtectedBaseWrite = true; - continue; - } - if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') { - options.waitForMerge = true; - continue; - } - if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') { - options.waitForMerge = false; - continue; - } - - throw new Error(`Unknown option: ${arg}`); - } - - if (!options.target) { - throw new Error('--target requires a path value'); - } - - return options; -} - -function parseRepoTraversalArgs(rawArgs, defaults) { - const traversalDefaults = { - ...defaults, - recursive: true, - nestedMaxDepth: NESTED_REPO_DEFAULT_MAX_DEPTH, - nestedSkipDirs: [], - includeSubmodules: false, - }; - const forwardedArgs = []; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo' || arg === '--current') { - traversalDefaults.recursive = false; - continue; - } - if (arg === '--recursive' || arg === '--nested') { - traversalDefaults.recursive = true; - continue; - } - if (arg === '--max-depth') { - const raw = requireValue(rawArgs, index, '--max-depth'); - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed < 1) { - throw new Error('--max-depth requires a positive integer'); - } - traversalDefaults.nestedMaxDepth = parsed; - index += 1; - continue; - } - if (arg === '--skip-nested') { - const raw = requireValue(rawArgs, index, '--skip-nested'); - traversalDefaults.nestedSkipDirs.push(raw); - index += 1; - continue; - } - if (arg === '--include-submodules') { - traversalDefaults.includeSubmodules = true; - continue; - } - forwardedArgs.push(arg); - } - - return parseCommonArgs(forwardedArgs, traversalDefaults); -} - -function parseSetupArgs(rawArgs, defaults) { - const setupDefaults = { - ...defaults, - parentWorkspaceView: false, - }; - const forwardedArgs = []; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--parent-workspace-view') { - setupDefaults.parentWorkspaceView = true; - continue; - } - if (arg === '--no-parent-workspace-view') { - setupDefaults.parentWorkspaceView = false; - continue; - } - forwardedArgs.push(arg); - } - - return parseRepoTraversalArgs(forwardedArgs, setupDefaults); -} - -function parseDoctorArgs(rawArgs) { - const doctorDefaults = { - target: process.cwd(), - force: false, - dropStaleLocks: true, - skipAgents: false, - skipPackageJson: false, - skipGitignore: false, - dryRun: false, - json: false, - allowProtectedBaseWrite: false, - waitForMerge: true, - verboseAutoFinish: false, - }; - const forwardedArgs = []; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--verbose-auto-finish') { - doctorDefaults.verboseAutoFinish = true; - continue; - } - if (arg === '--compact-auto-finish') { - doctorDefaults.verboseAutoFinish = false; - continue; - } - forwardedArgs.push(arg); - } - - return parseRepoTraversalArgs(forwardedArgs, doctorDefaults); -} - function normalizeWorkspacePath(relativePath) { return String(relativePath || '.').replace(/\\/g, '/'); } @@ -3399,171 +3000,6 @@ function runSetupInSandbox(options, blocked, repoLabel = '') { }; } -function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { - const remaining = []; - let target = defaultTarget; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--target requires a path value'); - } - target = next; - index += 1; - continue; - } - remaining.push(arg); - } - - return { target, args: remaining }; -} - -function parseReviewArgs(rawArgs) { - const parsed = parseTargetFlag(rawArgs, process.cwd()); - const passthroughArgs = [...parsed.args]; - if (passthroughArgs[0] === 'start') { - passthroughArgs.shift(); - } - return { - target: parsed.target, - passthroughArgs, - }; -} - -function parseAgentsArgs(rawArgs) { - const parsed = parseTargetFlag(rawArgs, process.cwd()); - const [subcommandRaw = '', ...rest] = parsed.args; - const subcommand = subcommandRaw || 'status'; - const options = { - target: parsed.target, - subcommand, - reviewIntervalSeconds: 30, - cleanupIntervalSeconds: 60, - idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, - }; - - for (let index = 0; index < rest.length; index += 1) { - const arg = rest[index]; - if (arg === '--review-interval') { - const next = rest[index + 1]; - if (!next) { - throw new Error('--review-interval requires an integer seconds value'); - } - const parsedValue = Number.parseInt(next, 10); - if (!Number.isInteger(parsedValue) || parsedValue < 5) { - throw new Error('--review-interval must be an integer >= 5 seconds'); - } - options.reviewIntervalSeconds = parsedValue; - index += 1; - continue; - } - if (arg === '--cleanup-interval') { - const next = rest[index + 1]; - if (!next) { - throw new Error('--cleanup-interval requires an integer seconds value'); - } - const parsedValue = Number.parseInt(next, 10); - if (!Number.isInteger(parsedValue) || parsedValue < 5) { - throw new Error('--cleanup-interval must be an integer >= 5 seconds'); - } - options.cleanupIntervalSeconds = parsedValue; - index += 1; - continue; - } - if (arg === '--idle-minutes') { - const next = rest[index + 1]; - if (!next) { - throw new Error('--idle-minutes requires an integer minutes value'); - } - const parsedValue = Number.parseInt(next, 10); - if (!Number.isInteger(parsedValue) || parsedValue < 1) { - throw new Error('--idle-minutes must be an integer >= 1'); - } - options.idleMinutes = parsedValue; - index += 1; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - if (!['start', 'stop', 'status'].includes(options.subcommand)) { - throw new Error(`Unknown agents subcommand: ${options.subcommand}`); - } - - return options; -} - -function parseReportArgs(rawArgs) { - const options = { - target: process.cwd(), - subcommand: '', - repo: '', - scorecardJson: '', - outputDir: '', - date: '', - dryRun: false, - json: false, - }; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) throw new Error('--target requires a path value'); - options.target = next; - index += 1; - continue; - } - if (arg === '--repo') { - const next = rawArgs[index + 1]; - if (!next) throw new Error('--repo requires a value like github.com/owner/repo'); - options.repo = next; - index += 1; - continue; - } - if (arg === '--scorecard-json') { - const next = rawArgs[index + 1]; - if (!next) throw new Error('--scorecard-json requires a path value'); - options.scorecardJson = next; - index += 1; - continue; - } - if (arg === '--output-dir') { - const next = rawArgs[index + 1]; - if (!next) throw new Error('--output-dir requires a path value'); - options.outputDir = next; - index += 1; - continue; - } - if (arg === '--date') { - const next = rawArgs[index + 1]; - if (!next) throw new Error('--date requires a YYYY-MM-DD value'); - options.date = next; - index += 1; - continue; - } - if (arg === '--dry-run') { - options.dryRun = true; - continue; - } - if (arg === '--json') { - options.json = true; - continue; - } - if (arg.startsWith('-')) { - throw new Error(`Unknown option: ${arg}`); - } - if (!options.subcommand) { - options.subcommand = arg; - continue; - } - throw new Error(`Unexpected argument: ${arg}`); - } - - return options; -} function todayDateStamp() { return new Date().toISOString().slice(0, 10); @@ -4195,401 +3631,6 @@ function lockRegistryStatus(repoRoot) { return { dirty: true, untracked }; } -function parseSyncArgs(rawArgs) { - const options = { - target: process.cwd(), - check: false, - base: '', - strategy: '', - ffOnly: false, - dryRun: false, - json: false, - allAgentBranches: false, - allowNonAgent: false, - allowDirty: false, - }; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--target requires a path value'); - } - options.target = next; - index += 1; - continue; - } - if (arg === '--base') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--base requires a branch value'); - } - options.base = next; - index += 1; - continue; - } - if (arg === '--strategy') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--strategy requires a value (rebase|merge)'); - } - options.strategy = next; - index += 1; - continue; - } - if (arg === '--check') { - options.check = true; - continue; - } - if (arg === '--ff-only') { - options.ffOnly = true; - continue; - } - if (arg === '--dry-run') { - options.dryRun = true; - continue; - } - if (arg === '--json') { - options.json = true; - continue; - } - if (arg === '--all-agent-branches') { - options.allAgentBranches = true; - continue; - } - if (arg === '--allow-non-agent') { - options.allowNonAgent = true; - continue; - } - if (arg === '--allow-dirty') { - options.allowDirty = true; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - return options; -} - -function parseCleanupArgs(rawArgs) { - const options = { - target: process.cwd(), - base: '', - branch: '', - dryRun: false, - forceDirty: false, - keepRemote: false, - keepCleanWorktrees: false, - includePrMerged: false, - idleMinutes: 0, - watch: false, - intervalSeconds: 60, - once: false, - maxBranches: 0, - }; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--target requires a path value'); - } - options.target = next; - index += 1; - continue; - } - if (arg === '--base') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--base requires a branch value'); - } - options.base = next; - index += 1; - continue; - } - if (arg === '--branch') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--branch requires an agent branch value'); - } - options.branch = next; - index += 1; - continue; - } - if (arg === '--dry-run') { - options.dryRun = true; - continue; - } - if (arg === '--force-dirty') { - options.forceDirty = true; - continue; - } - if (arg === '--keep-remote') { - options.keepRemote = true; - continue; - } - if (arg === '--keep-clean-worktrees') { - options.keepCleanWorktrees = true; - continue; - } - if (arg === '--include-pr-merged') { - options.includePrMerged = true; - continue; - } - if (arg === '--idle-minutes') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--idle-minutes requires an integer value'); - } - const parsed = Number.parseInt(next, 10); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error('--idle-minutes must be an integer >= 0'); - } - options.idleMinutes = parsed; - index += 1; - continue; - } - if (arg === '--watch') { - options.watch = true; - continue; - } - if (arg === '--interval') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--interval requires an integer seconds value'); - } - const parsed = Number.parseInt(next, 10); - if (!Number.isInteger(parsed) || parsed < 5) { - throw new Error('--interval must be an integer >= 5 seconds'); - } - options.intervalSeconds = parsed; - index += 1; - continue; - } - if (arg === '--once') { - options.once = true; - continue; - } - if (arg === '--max-branches') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--max-branches requires an integer value'); - } - const parsed = Number.parseInt(next, 10); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error('--max-branches must be an integer >= 1'); - } - options.maxBranches = parsed; - index += 1; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - if (options.watch && options.idleMinutes === 0) { - options.idleMinutes = DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES; - } - - return options; -} - -function parseMergeArgs(rawArgs) { - const options = { - target: process.cwd(), - base: '', - into: '', - branches: [], - task: '', - agent: '', - }; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--target requires a path value'); - } - options.target = next; - index += 1; - continue; - } - if (arg === '--base') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--base requires a branch value'); - } - options.base = next; - index += 1; - continue; - } - if (arg === '--into') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--into requires an agent/* branch value'); - } - options.into = next; - index += 1; - continue; - } - if (arg === '--branch') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--branch requires an agent/* branch value'); - } - options.branches.push(next); - index += 1; - continue; - } - if (arg === '--task') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--task requires a task value'); - } - options.task = next; - index += 1; - continue; - } - if (arg === '--agent') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--agent requires an agent value'); - } - options.agent = next; - index += 1; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - if (options.branches.length === 0) { - throw new Error('merge requires at least one --branch input'); - } - - return options; -} - -function parseFinishArgs(rawArgs, defaults = {}) { - const options = { - target: process.cwd(), - base: '', - branch: '', - all: false, - dryRun: false, - waitForMerge: defaults.waitForMerge ?? true, - cleanup: defaults.cleanup ?? true, - keepRemote: false, - noAutoCommit: false, - failFast: false, - commitMessage: '', - mergeMode: defaults.mergeMode || 'pr', - }; - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === '--target') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--target requires a path value'); - } - options.target = next; - index += 1; - continue; - } - if (arg === '--base') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--base requires a branch value'); - } - options.base = next; - index += 1; - continue; - } - if (arg === '--branch') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--branch requires an agent/* branch value'); - } - options.branch = next; - index += 1; - continue; - } - if (arg === '--commit-message') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--commit-message requires a value'); - } - options.commitMessage = next; - index += 1; - continue; - } - if (arg === '--all') { - options.all = true; - continue; - } - if (arg === '--dry-run') { - options.dryRun = true; - continue; - } - if (arg === '--wait-for-merge') { - options.waitForMerge = true; - continue; - } - if (arg === '--no-wait-for-merge') { - options.waitForMerge = false; - continue; - } - if (arg === '--via-pr') { - options.mergeMode = 'pr'; - continue; - } - if (arg === '--direct-only') { - options.mergeMode = 'direct'; - continue; - } - if (arg === '--mode') { - const next = rawArgs[index + 1]; - if (!next) { - throw new Error('--mode requires a value'); - } - if (!['auto', 'direct', 'pr'].includes(next)) { - throw new Error(`Invalid --mode value: ${next} (expected auto|direct|pr)`); - } - options.mergeMode = next; - index += 1; - continue; - } - if (arg === '--cleanup') { - options.cleanup = true; - continue; - } - if (arg === '--no-cleanup') { - options.cleanup = false; - continue; - } - if (arg === '--keep-remote') { - options.keepRemote = true; - continue; - } - if (arg === '--no-auto-commit') { - options.noAutoCommit = true; - continue; - } - if (arg === '--fail-fast') { - options.failFast = true; - continue; - } - throw new Error(`Unknown option: ${arg}`); - } - - if (options.branch && !options.branch.startsWith('agent/')) { - throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`); - } - - return options; -} function listAgentWorktrees(repoRoot) { const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true }); @@ -6046,10 +5087,11 @@ function doctor(rawArgs) { const topRepoRoot = resolveRepoRoot(options.target); const discoveredRepos = options.recursive ? discoverNestedGitRepos(topRepoRoot, { - maxDepth: options.nestedMaxDepth, - extraSkip: options.nestedSkipDirs, - includeSubmodules: options.includeSubmodules, - }) + maxDepth: options.nestedMaxDepth, + extraSkip: options.nestedSkipDirs, + includeSubmodules: options.includeSubmodules, + skipRelativeDirs: AGENT_WORKTREE_RELATIVE_DIRS, + }) : [topRepoRoot]; if (discoveredRepos.length > 1) { @@ -6680,6 +5722,7 @@ function setup(rawArgs) { maxDepth: options.nestedMaxDepth, extraSkip: options.nestedSkipDirs, includeSubmodules: options.includeSubmodules, + skipRelativeDirs: AGENT_WORKTREE_RELATIVE_DIRS, }) : [topRepoRoot]; @@ -7552,78 +6595,6 @@ function protect(rawArgs) { throw new Error(`Unknown protect subcommand: ${subcommand}`); } -function levenshteinDistance(a, b) { - const rows = a.length + 1; - const cols = b.length + 1; - const matrix = Array.from({ length: rows }, () => Array(cols).fill(0)); - - for (let i = 0; i < rows; i += 1) matrix[i][0] = i; - for (let j = 0; j < cols; j += 1) matrix[0][j] = j; - - for (let i = 1; i < rows; i += 1) { - for (let j = 1; j < cols; j += 1) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + cost, // substitution - ); - } - } - return matrix[a.length][b.length]; -} - -function maybeSuggestCommand(command) { - let best = null; - let bestDistance = Number.POSITIVE_INFINITY; - - for (const candidate of SUGGESTIBLE_COMMANDS) { - const dist = levenshteinDistance(command, candidate); - if (dist < bestDistance) { - bestDistance = dist; - best = candidate; - } - } - - if (best && bestDistance <= 2) { - return best; - } - - return null; -} - -function normalizeCommandOrThrow(command) { - if (COMMAND_TYPO_ALIASES.has(command)) { - const mapped = COMMAND_TYPO_ALIASES.get(command); - console.log(`[${TOOL_NAME}] Interpreting '${command}' as '${mapped}'.`); - return mapped; - } - return command; -} - -function warnDeprecatedAlias(aliasName) { - const entry = DEPRECATED_COMMAND_ALIASES.get(aliasName); - if (!entry) return; - console.error( - `[${TOOL_NAME}] '${aliasName}' is deprecated and will be removed in a future major release. ` + - `Use: ${entry.hint}`, - ); -} - -function extractFlag(args, ...names) { - const flagSet = new Set(names); - let found = false; - const remaining = []; - for (const arg of args) { - if (flagSet.has(arg)) { - found = true; - } else { - remaining.push(arg); - } - } - return { found, remaining }; -} - function main() { const args = process.argv.slice(2); diff --git a/src/git/index.js b/src/git/index.js index 45edb88..f5f63c2 100644 --- a/src/git/index.js +++ b/src/git/index.js @@ -1,3 +1,4 @@ +const fs = require('node:fs'); const { path } = require('../context'); const { run } = require('../core/runtime'); @@ -41,66 +42,75 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([ '.pnpm-store', ]); +function resolveGitCommonDir(repoPath) { + const result = run('git', ['-C', repoPath, 'rev-parse', '--git-common-dir'], { cwd: repoPath }); + if (result.status !== 0) return null; + const raw = result.stdout.trim(); + if (!raw) return null; + return path.resolve(repoPath, raw); +} + function discoverNestedGitRepos(rootPath, opts = {}) { const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH; const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []); const includeSubmodules = Boolean(opts.includeSubmodules); + const skipRelativeDirs = Array.isArray(opts.skipRelativeDirs) ? opts.skipRelativeDirs.filter(Boolean) : []; const resolvedRoot = path.resolve(rootPath); if (!isGitRepo(resolvedRoot)) { throw new Error(`Target is not inside a git repository: ${resolvedRoot}`); } - const results = []; - const seen = new Set(); - - function visit(directoryPath, depth) { - const repoRoot = resolveRepoRoot(directoryPath); - if (!seen.has(repoRoot)) { - seen.add(repoRoot); - results.push(repoRoot); - } + const rootCommonDir = resolveGitCommonDir(resolvedRoot); + const skipAbsolutes = skipRelativeDirs.map((relativeDir) => path.join(resolvedRoot, relativeDir)); + const found = new Set([resolvedRoot]); - if (depth >= maxDepth) { - return; - } + function shouldSkipDir(dirName) { + return NESTED_REPO_DEFAULT_SKIP_DIRS.has(dirName) || extraSkip.has(dirName); + } - let entries = []; + function walk(currentPath, depth) { + if (depth > maxDepth) return; + let entries; try { - entries = require('node:fs').readdirSync(directoryPath, { withFileTypes: true }); + entries = fs.readdirSync(currentPath, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - if (NESTED_REPO_DEFAULT_SKIP_DIRS.has(entry.name) || extraSkip.has(entry.name)) { - continue; - } + const entryPath = path.join(currentPath, entry.name); - const childPath = path.join(directoryPath, entry.name); - const gitDir = path.join(childPath, '.git'); - if (require('node:fs').existsSync(gitDir)) { - if (!includeSubmodules) { - const gitInfo = require('node:fs').lstatSync(gitDir); - if (gitInfo.isFile()) { - continue; - } + if (entry.name === '.git') { + if (entry.isDirectory()) { + if (entryPath === path.join(resolvedRoot, '.git')) continue; + found.add(path.dirname(entryPath)); + } else if (includeSubmodules && entry.isFile()) { + found.add(path.dirname(entryPath)); } - visit(childPath, depth + 1); continue; } - visit(childPath, depth + 1); + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + if (shouldSkipDir(entry.name)) continue; + if (skipAbsolutes.includes(entryPath)) continue; + walk(entryPath, depth + 1); } } - visit(resolvedRoot, 0); - return results; + walk(resolvedRoot, 0); + + const filtered = Array.from(found).filter((repoPath) => { + if (repoPath === resolvedRoot || !rootCommonDir) return true; + const childCommonDir = resolveGitCommonDir(repoPath); + return !childCommonDir || childCommonDir !== rootCommonDir; + }); + + const [root, ...rest] = filtered; + rest.sort((a, b) => a.localeCompare(b)); + return root ? [root, ...rest] : []; } module.exports = { diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js new file mode 100644 index 0000000..348da08 --- /dev/null +++ b/test/cli-args-dispatch.test.js @@ -0,0 +1,185 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, +} = require('../src/context'); +const { + parseSetupArgs, + parseDoctorArgs, + parseAgentsArgs, + parseCleanupArgs, + parseMergeArgs, + parseFinishArgs, +} = require('../src/cli/args'); +const { + maybeSuggestCommand, + normalizeCommandOrThrow, + warnDeprecatedAlias, + extractFlag, +} = require('../src/cli/dispatch'); + +const repoRoot = path.resolve(__dirname, '..'); + +function captureConsole(methodName, fn) { + const original = console[methodName]; + const calls = []; + console[methodName] = (...args) => { + calls.push(args.join(' ')); + }; + + try { + return { result: fn(), calls }; + } finally { + console[methodName] = original; + } +} + +test('parseDoctorArgs keeps doctor-specific flags while reusing repo traversal parsing', () => { + const options = parseDoctorArgs([ + '--current', + '--force', + 'AGENTS.md', + '.gitignore', + '--verbose-auto-finish', + '--skip-package-json', + '--no-gitignore', + ]); + + assert.equal(options.target, process.cwd()); + assert.equal(options.recursive, false); + assert.equal(options.force, true); + assert.deepEqual(options.forceManagedPaths, ['AGENTS.md', '.gitignore']); + assert.equal(options.verboseAutoFinish, true); + assert.equal(options.skipPackageJson, true); + assert.equal(options.skipGitignore, true); + assert.equal(options.waitForMerge, true); +}); + +test('parseSetupArgs keeps nested traversal and parent workspace view flags', () => { + const options = parseSetupArgs([ + '--target', + '/tmp/guardex-repo', + '--no-recursive', + '--max-depth', + '4', + '--skip-nested', + 'vendor', + '--include-submodules', + '--parent-workspace-view', + ], { + force: false, + dryRun: false, + dropStaleLocks: true, + }); + + assert.equal(options.target, '/tmp/guardex-repo'); + assert.equal(options.recursive, false); + assert.equal(options.nestedMaxDepth, 4); + assert.deepEqual(options.nestedSkipDirs, ['vendor']); + assert.equal(options.includeSubmodules, true); + assert.equal(options.parentWorkspaceView, true); +}); + +test('parseAgentsArgs applies interval overrides and validates the subcommand', () => { + const options = parseAgentsArgs([ + 'start', + '--target', + '/tmp/guardex-repo', + '--review-interval', + '15', + '--cleanup-interval', + '45', + '--idle-minutes', + '12', + ]); + + assert.deepEqual(options, { + target: '/tmp/guardex-repo', + subcommand: 'start', + reviewIntervalSeconds: 15, + cleanupIntervalSeconds: 45, + idleMinutes: 12, + }); +}); + +test('parseCleanupArgs defaults idle minutes when watch mode is enabled', () => { + const options = parseCleanupArgs(['--watch']); + assert.equal(options.watch, true); + assert.equal(options.idleMinutes, DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES); +}); + +test('parseMergeArgs requires at least one agent branch', () => { + assert.throws( + () => parseMergeArgs(['--base', 'dev']), + /merge requires at least one --branch input/, + ); +}); + +test('parseFinishArgs rejects non-agent branches and preserves explicit overrides', () => { + assert.throws( + () => parseFinishArgs(['--branch', 'feature/not-agent']), + /--branch must reference an agent\/\* branch/, + ); + + const options = parseFinishArgs([ + '--branch', + 'agent/codex/example', + '--no-cleanup', + '--no-wait-for-merge', + '--direct-only', + '--keep-remote', + '--no-auto-commit', + '--fail-fast', + '--commit-message', + 'Finish the active lane', + ]); + + assert.equal(options.branch, 'agent/codex/example'); + assert.equal(options.cleanup, false); + assert.equal(options.waitForMerge, false); + assert.equal(options.mergeMode, 'direct'); + assert.equal(options.keepRemote, true); + assert.equal(options.noAutoCommit, true); + assert.equal(options.failFast, true); + assert.equal(options.commitMessage, 'Finish the active lane'); +}); + +test('dispatch helpers preserve suggestion, alias, deprecation, and flag extraction behavior', () => { + assert.equal(maybeSuggestCommand('docto'), 'doctor'); + + const alias = captureConsole('log', () => normalizeCommandOrThrow('doctro')); + assert.equal(alias.result, 'doctor'); + assert.match(alias.calls.join('\n'), /\[gitguardex\] Interpreting 'doctro' as 'doctor'\./); + + const deprecation = captureConsole('error', () => warnDeprecatedAlias('init')); + assert.match(deprecation.calls.join('\n'), /\[gitguardex\] 'init' is deprecated/); + assert.match(deprecation.calls.join('\n'), /gx setup/); + + assert.deepEqual( + extractFlag(['status', '--strict', '--json'], '--strict'), + { found: true, remaining: ['status', '--json'] }, + ); +}); + +test('cli main no longer keeps local copies of extracted parser and dispatch helpers', () => { + const source = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8'); + + assert.match(source, /require\('\.\/args'\)/); + assert.match(source, /require\('\.\/dispatch'\)/); + assert.match(source, /require\('\.\.\/git'\)/); + assert.doesNotMatch(source, /function parseDoctorArgs\(rawArgs\)/); + assert.doesNotMatch(source, /function parseSetupArgs\(rawArgs, defaults\)/); + assert.doesNotMatch(source, /function parseCleanupArgs\(rawArgs\)/); + assert.doesNotMatch(source, /function parseFinishArgs\(rawArgs, defaults = \{\}\)/); + assert.doesNotMatch(source, /function gitRun\(repoRoot, args, \{ allowFailure = false \} = \{\}\)/); + assert.doesNotMatch(source, /function resolveRepoRoot\(targetPath\)/); + assert.doesNotMatch(source, /function isGitRepo\(targetPath\)/); + assert.doesNotMatch(source, /function discoverNestedGitRepos\(rootPath, opts = \{\}\)/); + assert.doesNotMatch(source, /function maybeSuggestCommand\(command\)/); + assert.doesNotMatch(source, /function normalizeCommandOrThrow\(command\)/); + assert.doesNotMatch(source, /function warnDeprecatedAlias\(aliasName\)/); + assert.doesNotMatch(source, /function extractFlag\(args, \.\.\.names\)/); +}); diff --git a/test/metadata.test.js b/test/metadata.test.js index e163695..1f7e73c 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -2,6 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const cp = require('node:child_process'); const repoRoot = path.resolve(__dirname, '..'); const packageJsonPath = path.join(repoRoot, 'package.json'); @@ -166,6 +167,8 @@ test('cli main delegates extracted seams and keeps doctor single-source', () => const cliSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8'); const doctorDefs = cliSource.match(/function doctor\(rawArgs\)/g) || []; assert.equal(doctorDefs.length, 1, 'doctor() must not be duplicated'); + assert.doesNotMatch(cliSource, /function parseSetupArgs\(/); + assert.doesNotMatch(cliSource, /function parseDoctorArgs\(/); assert.match(cliSource, /function assertProtectedMainWriteAllowed\(options, commandName\)\s*{\s*return getSandboxApi\(\)\.assertProtectedMainWriteAllowed\(options, commandName\);\s*}/s); assert.match(cliSource, /function maybeSelfUpdateBeforeStatus\(\)\s*{\s*return getToolchainApi\(\)\.maybeSelfUpdateBeforeStatus\(\);\s*}/s); assert.match(cliSource, /function hook\(rawArgs\)\s*{\s*return hooksModule\.hook\(rawArgs, \{/s); @@ -174,6 +177,18 @@ test('cli main delegates extracted seams and keeps doctor single-source', () => assert.match(cliSource, /printOperations\('Doctor\/fix', fixPayload, (?:singleRepoOptions|options)\.dryRun\);/); }); +test('cli main module loads after extracted arg and dispatch seams move out', () => { + const result = cp.spawnSync(process.execPath, ['-e', "require('./src/cli/main.js')"], { + cwd: repoRoot, + encoding: 'utf8', + }); + assert.equal( + result.status, + 0, + `src/cli/main.js must load cleanly after seam extraction.\n${(result.stderr || result.stdout || '').trim()}`, + ); +}); + test('worktree-change detection uses normal untracked-file mode', () => { const cliSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8'); assert.match(cliSource, /'status',\s*'--porcelain',\s*'--untracked-files=normal',\s*'--'/s); diff --git a/test/setup.test.js b/test/setup.test.js index 10d1c21..675c4ae 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -703,7 +703,7 @@ test('setup --no-recursive limits install to the top-level repo', () => { ); }); -test('setup --current limits install to the top-level repo', () => { +test('setup --current limits install to the target repo only', () => { const topDir = initRepo(); const nestedA = path.join(topDir, 'apps', 'a'); fs.mkdirSync(nestedA, { recursive: true });