diff --git a/README.md b/README.md index ab89f72..34b9098 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,8 @@ gx protect reset [--target ] gx sync --check [--target ] [--base ] [--json] gx sync [--target ] [--base ] [--strategy rebase|merge] [--ff-only] gx report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] -bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanup +bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup (auto base detection) +bash scripts/agent-worktree-prune.sh --force-dirty # remove stale dirty worktrees too bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` @@ -284,6 +285,7 @@ and asks `[y/N]` whether to update immediately (default is `N`). - Interactive prompt is strict (`[y/n]`) and waits for explicit answer. - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force. - In already-initialized repos, `setup` / `install` / `fix` / `doctor` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance. +- `scripts/codex-agent.sh` now auto-runs worktree prune after a Codex session; clean sandbox branches are removed automatically, dirty ones are kept. ## Advanced commands diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index efb6fa8..8ac20ed 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -489,7 +489,7 @@ function ensurePackageScripts(repoRoot, dryRun) { 'agent:codex': 'bash ./scripts/codex-agent.sh', 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', - 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev', + 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh', 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh', 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim', 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete', diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index 170664a..ced73bc 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -1,22 +1,33 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" +BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 +FORCE_DIRTY=0 + +if [[ -n "$BASE_BRANCH" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi while [[ $# -gt 0 ]]; do case "$1" in --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --dry-run) DRY_RUN=1 shift ;; + --force-dirty) + FORCE_DIRTY=1 + shift + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 exit 1 ;; esac @@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +resolve_base_branch() { + local configured="" + local current="" + + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then + printf '%s' "$configured" + return 0 + fi + + current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then + printf '%s' "$current" + return 0 + fi + + for fallback in main dev; do + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "" +} + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] Unable to infer base branch. Pass --base ." >&2 + exit 1 +fi + if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 exit 1 @@ -49,9 +100,17 @@ branch_has_worktree() { git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" } +is_clean_worktree() { + local wt="$1" + git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + removed_worktrees=0 removed_branches=0 skipped_active=0 +skipped_dirty=0 process_entry() { local wt="$1" @@ -89,6 +148,12 @@ process_entry() { return fi + if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then + skipped_dirty=$((skipped_dirty + 1)) + echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}" + return + fi + echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}" run_cmd git -C "$repo_root" worktree remove "$wt" --force removed_worktrees=$((removed_worktrees + 1)) @@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads run_cmd git -C "$repo_root" worktree prune -echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}" +echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}" if [[ "$skipped_active" -gt 0 ]]; then echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2 fi +if [[ "$skipped_dirty" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2 +fi diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index 170664a..ced73bc 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -1,22 +1,33 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" +BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 +FORCE_DIRTY=0 + +if [[ -n "$BASE_BRANCH" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi while [[ $# -gt 0 ]]; do case "$1" in --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --dry-run) DRY_RUN=1 shift ;; + --force-dirty) + FORCE_DIRTY=1 + shift + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 exit 1 ;; esac @@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +resolve_base_branch() { + local configured="" + local current="" + + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then + printf '%s' "$configured" + return 0 + fi + + current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then + printf '%s' "$current" + return 0 + fi + + for fallback in main dev; do + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "" +} + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] Unable to infer base branch. Pass --base ." >&2 + exit 1 +fi + if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 exit 1 @@ -49,9 +100,17 @@ branch_has_worktree() { git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" } +is_clean_worktree() { + local wt="$1" + git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + removed_worktrees=0 removed_branches=0 skipped_active=0 +skipped_dirty=0 process_entry() { local wt="$1" @@ -89,6 +148,12 @@ process_entry() { return fi + if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then + skipped_dirty=$((skipped_dirty + 1)) + echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}" + return + fi + echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}" run_cmd git -C "$repo_root" worktree remove "$wt" --force removed_worktrees=$((removed_worktrees + 1)) @@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads run_cmd git -C "$repo_root" worktree prune -echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}" +echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}" if [[ "$skipped_active" -gt 0 ]]; then echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2 fi +if [[ "$skipped_dirty" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2 +fi diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index a565f78..370d2d5 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -65,7 +65,13 @@ if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then exit 127 fi -if [[ ! -x "scripts/agent-branch-start.sh" ]]; then +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[codex-agent] Not inside a git repository." >&2 + exit 1 +fi +repo_root="$(git rev-parse --show-toplevel)" + +if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 exit 1 fi @@ -75,7 +81,7 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") fi -start_output="$(bash scripts/agent-branch-start.sh "${start_args[@]}")" +start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}")" printf '%s\n' "$start_output" worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" @@ -91,4 +97,32 @@ fi echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" -exec "$CODEX_BIN" "$@" +set +e +"$CODEX_BIN" "$@" +codex_exit="$?" +set -e + +cd "$repo_root" + +if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." + prune_args=() + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + prune_args+=(--base "$BASE_BRANCH") + fi + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then + echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 + fi +fi + +if [[ ! -d "$worktree_path" ]]; then + echo "[codex-agent] Auto-cleaned sandbox worktree: $worktree_path" +else + worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + echo "[codex-agent] Sandbox worktree kept: $worktree_path" + if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then + echo "[codex-agent] If finished, merge + clean with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\"" + fi +fi + +exit "$codex_exit" diff --git a/test/install.test.js b/test/install.test.js index 68ef3ad..8197e39 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -216,7 +216,7 @@ test('setup provisions workflow files and repo config', () => { assert.equal(packageJson.scripts['agent:branch:sync'], 'gx sync'); assert.equal(packageJson.scripts['agent:branch:sync:check'], 'gx sync --check'); assert.equal(packageJson.scripts['agent:safety:setup'], 'gx setup'); - assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh --base dev'); + assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh'); const agentsContent = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); assert.equal(agentsContent.includes(''), true); @@ -681,12 +681,18 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); }); -test('codex-agent launches codex inside a fresh sandbox worktree', () => { +test('codex-agent launches codex inside a fresh sandbox worktree and auto-prunes clean branches on exit', () => { const repoDir = initRepo(); seedCommit(repoDir); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-')); const fakeCodexPath = path.join(fakeBin, 'codex'); @@ -713,6 +719,8 @@ test('codex-agent launches codex inside a fresh sandbox worktree', () => { ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[codex-agent\] Launching codex in sandbox:/); + assert.match(launch.stdout, /\[codex-agent\] Session ended \(exit=0\)\. Running worktree cleanup\.\.\./); + assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -723,9 +731,10 @@ test('codex-agent launches codex inside a fresh sandbox worktree', () => { const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); - const branchResult = runCmd('git', ['-C', launchedCwd, 'branch', '--show-current'], repoDir); - assert.equal(branchResult.status, 0, branchResult.stderr || branchResult.stdout); - assert.match(branchResult.stdout.trim(), /^agent\/planner\//); + assert.equal(fs.existsSync(launchedCwd), false, 'clean codex-agent sandbox should auto-prune on exit'); + const launchedBranch = extractCreatedBranch(launch.stdout); + const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${launchedBranch}`], repoDir); + assert.notEqual(branchResult.status, 0, 'clean auto-pruned branch should be removed locally'); }); test('codex-agent supports --codex-bin override before positional arguments', () => { @@ -734,6 +743,12 @@ test('codex-agent supports --codex-bin override before positional arguments', () const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-bin-')); const fakeCodexPath = path.join(fakeBin, 'my-codex'); @@ -768,6 +783,7 @@ test('codex-agent supports --codex-bin override before positional arguments', () ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[codex-agent\] Launching .* in sandbox:/); + assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -776,6 +792,47 @@ test('codex-agent supports --codex-bin override before positional arguments', () ); const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); + assert.equal(fs.existsSync(launchedCwd), false, 'override invocation should still auto-prune clean sandbox'); +}); + +test('codex-agent keeps dirty sandbox worktrees after session exit', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-dirty-')); + const fakeCodexPath = path.join(fakeBin, 'codex'); + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env bash\n` + + `pwd > "${'${MUSAFETY_TEST_CODEX_CWD}'}"\n` + + `echo "$@" > "${'${MUSAFETY_TEST_CODEX_ARGS}'}"\n` + + `echo "dirty" > codex-dirty.txt\n`, + 'utf8', + ); + fs.chmodSync(fakeCodexPath, 0o755); + + const cwdMarker = path.join(repoDir, '.codex-agent-cwd-dirty'); + const argsMarker = path.join(repoDir, '.codex-agent-args-dirty'); + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'dirty-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], + repoDir, + { + PATH: `${fakeBin}:${process.env.PATH}`, + MUSAFETY_TEST_CODEX_CWD: cwdMarker, + MUSAFETY_TEST_CODEX_ARGS: argsMarker, + }, + ); + assert.equal(launch.status, 0, launch.stderr || launch.stdout); + assert.match(launch.stdout, /\[agent-worktree-prune\] Skipping dirty worktree/); + assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); + + const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); + assert.equal(fs.existsSync(launchedCwd), true, 'dirty sandbox should be preserved'); + assert.equal(fs.existsSync(path.join(launchedCwd, 'codex-dirty.txt')), true); }); test('sync command rebases current agent branch onto latest origin/dev', () => { @@ -1335,7 +1392,7 @@ test('worktree prune removes merged agent worktrees and branches', () => { assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(worktreePath), true); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--base', 'dev'], repoDir); + result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false); @@ -1343,6 +1400,28 @@ test('worktree prune removes merged agent worktrees and branches', () => { assert.notEqual(branchResult.status, 0, 'merged agent branch should be removed by prune'); }); +test('worktree prune preserves dirty agent worktrees unless --force-dirty is used', () => { + const repoDir = initRepo(); + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + seedCommit(repoDir); + + const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'agent__test-dirty-prune'); + result = runCmd('git', ['worktree', 'add', '-b', 'agent/test-dirty-prune', worktreePath, 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr); + + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'dirty\n', 'utf8'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /skipped_dirty=1/); + assert.equal(fs.existsSync(worktreePath), true, 'dirty worktree should remain without --force-dirty'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--force-dirty'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(worktreePath), false, 'dirty worktree should be removable with --force-dirty'); +}); + test('release fails outside the maintainer repo path', () => { const repoDir = initRepoOnBranch('main'); const result = runNode(['release'], repoDir);