diff --git a/changelog.d/3416.md b/changelog.d/3416.md new file mode 100644 index 000000000..a2b4c08a6 --- /dev/null +++ b/changelog.d/3416.md @@ -0,0 +1,2 @@ + +- **fix(agent-health): git の読み取りが 1 秒のタイムアウトで打ち切られたときの `git:command-failed` を ERROR から warn に下げる** (#3416): 本番ログの 127 件はすべて負荷時の `status --porcelain` 等のタイムアウト(stderr 空)だった。git が非 0 で終わった本当の失敗は ERROR のまま。ログに worktree 名(basename)と `timedOut` / `timeoutMs` を足し、次の計測で呼び出し元を数えられるようにした diff --git a/docs/module-reference.md b/docs/module-reference.md index 80365c289..d4c4295f3 100644 --- a/docs/module-reference.md +++ b/docs/module-reference.md @@ -373,7 +373,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/env-manager/env-file-service.ts` | Env Manager のファイル I/O(**サーバ専用**、`fs/promises` を import、Issue #1968)。`resolveEnvFilePath()` が 3 層のパス検証(allowlist → `isPathSafe` [SF-002] → `resolveAndValidateRealPath` [SEC-394])を順に適用。`listEnvFiles()`(ワークツリー直下のみ・非再帰。root が解決できないと `realpathSync` が投げてパス検証が fail-closed になり `[]` を返す=望ましい方向)、`readEnvFile()`(未存在はエラーでなく `exists:false`)、`writeEnvFile()`(新規作成は `mode 0o600`、検証 error があれば書かずに `INVALID_CONTENT`)、`collectKeySuggestions()`。**値も `fs` のエラーメッセージもログに出さない**(記録するのはファイル名と errno のみ) | | `src/lib/env-manager/env-api-client.ts` | Env Manager のブラウザ側 fetch(`fs` を巻き込まないため service とは別モジュール、Issue #1968)。`fetchEnvSnapshot()`(`cache: 'no-store'`)/ `saveEnvFile()` / `EnvApiError`(code と、値を含まない issues を保持) | | `src/lib/env-manager/types.ts` | Env Manager の API 型(Issue #1968)。`EnvFileSummary` / `EnvKeySuggestion` / `EnvFileDetail` / `EnvManagerResponse` / `EnvSaveResponse` / `EnvErrorResponse`。route.ts は HTTP メソッドしか export できない(`scripts/check-route-exports.mjs`、Issue #1946)ため lib 側に置く | -| `src/lib/git/git-utils.ts` | Git情報取得(getGitStatus関数、execFile使用、1秒タイムアウト)。Git情報取得・コミット履歴/diff取得(Issue #447)、getCommitsByDateRange/collectRepositoryCommitLogs追加(Issue #627)、getAheadBehind追加(`git rev-list --left-right --count @{upstream}...HEAD`・厳密パース・left=behind/right=ahead・全失敗null・非throw、`getGitStatus`は不変)(Issue #779)。Issue #780で git write 操作群追加:`parsePorcelainStatus`(`git status --porcelain` を `{staged,unstaged,untracked}` に分類、X=index/Y=worktree、`??`→untracked、`U*`/`AA`/`DD`→unmerged は unstaged バケット、rename は new path)、`getStagedStatus`、`stageFiles`(`git add --`)、`unstageFiles`(`git restore --staged --`)、`gitCommit`(`git commit -m [--amend] --`、`GitNothingToCommitError`正規化)、`getWorkingTreeDiff`(mode=staged→`git diff --cached`/unstaged→`git diff`/untracked→`git diff --no-index /dev/null`、exit-1+stdout 回収)。排他制御=module-level `writeChains: Map` 直列化+`.git/index.lock` existsSync チェック→`GitIndexLockedError`。`handleGitApiError` を 409(`GitIndexLockedError`)/400(`GitNothingToCommitError`) に拡張。write系は `execGitCommandTyped`+`GIT_WRITE_TIMEOUT_MS`。**`getGitStatus` と 1s `execGitCommand` はバイト不変(#779 invariant)**。Issue #781で branch 操作追加:read `listBranches(worktreePath, include)`(`git branch [-r]`+`symbolic-ref refs/remotes/origin/HEAD`(default)+`git worktree list --porcelain`(checkedOutWorktreePath 逆引き)+`git for-each-ref`(upstream/aheadBehind)を1s `execGitCommand` で並列実行・各 best-effort 縮退・**非throw**、`parseWorktreePorcelain`/`parseForEachRefTracking` 新パーサ)。write `checkoutBranch`(precondition=別worktree占有→`GitBranchCheckedOutElsewhereError`(force不可)/dirty→`GitDirtyError`、remote→`git switch -c --track origin/` で detached 回避 S3-008、createIfMissing→`switch -c`、force→`checkout -f`)/`createBranch`(`git branch [from] --`)/`deleteBranch`(current→`GitCurrentBranchError`/default→`GitDefaultBranchError`、`-d` not-merged→`GitBranchNotMergedError`)。全 write は `runSerializedWrite`+`execGitCommandTyped(GIT_WRITE_TIMEOUT_MS)`・branch 引数 `--` 終端。新 error class 6種+`handleGitApiError` を `{error, reason}` で 404 branch_not_found/409 not_merged/current_branch/default_branch/dirty/checked_out_elsewhere(+worktreePath) にマップ。**`getGitStatus`/1s read path は #781 でもバイト不変(invariant 回帰テストあり)**。Issue #782で stash + reset/revert(Danger Zone)追加:read `parseStashList`/`getStashList`(`git stash list --format='%gd%x09%s%x09%cI%x09%H'`・1s `execGitCommand` 非throw・失敗時 `[]` 縮退)。write `stashPush`(`git stash push [--include-untracked] [-m] --`、`No local changes`→`GitNothingToStashError`正規化)/`stashPop`/`stashApply`/`stashDrop`/`gitReset`(`git reset -- --`)/`gitRevert`(`git revert [--no-commit] --`)を `runSerializedWrite`+`GIT_WRITE_TIMEOUT_MS`+`--` 終端で実装。pop/apply/revert の conflict は `execGitConflictAware`(`execFileAsync` 直呼びで `err.stdout` から `CONFLICT ... in ` 回収し **200 `{conflict,conflictFiles}`**、pop は `stashRetained:true`、timeout→`GitTimeoutError`/not-repo→`GitNotRepoError` を手動再現・index.lock は `runSerializedWrite` 経路で担保 S3-002)。hard reset は `isDefaultBranchForReset`(`symbolic-ref refs/remotes/origin/HEAD`・**未解決時 main/master フォールバック**=deleteBranch とは意図的非対称 S3-010)で default branch を判定し専用 `GitResetDefaultBranchError`(409、delete 文言とは別文言 S3-001(b))。新 error class `GitNothingToStashError`(400)/`GitResetDefaultBranchError`(409)+`handleGitApiError` を `GitTimeoutError` 手前に additive 拡張(**既存 #780/#781 reason body バイト不変・回帰テストあり**)。危険操作(hard reset/stash drop/revert)は `logger.warn` 構造化ログ。**`getGitStatus`/1s read path は #782 でもバイト不変**。Issue #783で network 操作(push/pull/fetch)追加:`getDefaultBranch`(`symbolic-ref refs/remotes/origin/HEAD --short` を **3値**=解決名/`DEFAULT_BRANCH_UNRESOLVED`/非origin→null で返す DR1-002)+`resolveDefaultBranchName`(UNRESOLVED/null を null に畳む)共有ヘルパーを新設し、`isDefaultBranchForReset`/`deleteBranch` を**バイト不変**に集約(DR1-001、listBranches は別関心事で非集約)。`execGitNetworkAware`(`execFileAsync` 直呼び・preserve regex 不変)+`classifyNetworkStderr`(network stderr→6 typed error 単一ソース・**引数なし構築で raw stderr/credential URL 非露出** DR4-003)。`gitFetch`(非直列化=remote-tracking ref のみ・§6.1)/`gitPull`(`runSerializedWrite`+`execGitConflictAware(timeout, classifyNetwork=true)`=conflict 200・generic 失敗は raw `err.message` 非ログ DR4-002)/`gitPush`(**サーバー構築の明示 refspec `${branch}:refs/heads/${branch}`** で destination を決定論化し `push.default`/upstream bypass を封鎖 DR4-004、force 時に `resolveDefaultBranchName` と比較→`GitProtectedBranchError`(409)・unresolved=未保護=reset と非対称、`--force-with-lease` 優先、force push は `logger.warn` 構造化ログ)。`execGitConflictAware` に `timeout`/`classifyNetwork` 引数を additive 追加(#782 caller はバイト不変 DR2-007)。新 error class 6種(`GitAuthFailedError`401/`GitNonFastForwardError`409/`GitNoUpstreamError`400/`GitProtectedBranchError`409/`GitForceWithLeaseStaleError`409/`GitNetworkError`**502**)+`handleGitApiError` を `GitTimeoutError` 手前に additive 拡張(既存 byte-identical it 不変・DR3-003)。**`getGitStatus`/1s read path/preserve regex は #783 でもバイト不変**。Issue #1515で ahead/behind の鮮度対策:`getAheadBehind` は `{aheadBehind, reason}` を返す形に変更(`execGitCommandCapture`=非throwのまま stderr を保持する read helper を新設し、stderr を `no_upstream`/`upstream_gone`/`detached`/`error` に分類。timeout は常に `error`。**raw stderr は返さずログにも出さない**・分類 enum のみ)。`getLastFetchAt`=`git rev-parse --git-path FETCH_HEAD --git-common-dir` の両 FETCH_HEAD mtime の**新しい方**を epoch ms で返す(linked worktree はこの worktree での fetch と repo root での fetch の双方を拾う・未fetchは null・非throw)。`execGitNetworkAware` は `GIT_TERMINAL_PROMPT=0` 付きで実行(認証情報なし HTTPS remote で対話プロンプト待ちにならず即 `GitAuthFailedError`) | +| `src/lib/git/git-utils.ts` | Git情報取得(getGitStatus関数、execFile使用、1秒タイムアウト)。Git情報取得・コミット履歴/diff取得(Issue #447)、getCommitsByDateRange/collectRepositoryCommitLogs追加(Issue #627)、getAheadBehind追加(`git rev-list --left-right --count @{upstream}...HEAD`・厳密パース・left=behind/right=ahead・全失敗null・非throw、`getGitStatus`は不変)(Issue #779)。Issue #780で git write 操作群追加:`parsePorcelainStatus`(`git status --porcelain` を `{staged,unstaged,untracked}` に分類、X=index/Y=worktree、`??`→untracked、`U*`/`AA`/`DD`→unmerged は unstaged バケット、rename は new path)、`getStagedStatus`、`stageFiles`(`git add --`)、`unstageFiles`(`git restore --staged --`)、`gitCommit`(`git commit -m [--amend] --`、`GitNothingToCommitError`正規化)、`getWorkingTreeDiff`(mode=staged→`git diff --cached`/unstaged→`git diff`/untracked→`git diff --no-index /dev/null`、exit-1+stdout 回収)。排他制御=module-level `writeChains: Map` 直列化+`.git/index.lock` existsSync チェック→`GitIndexLockedError`。`handleGitApiError` を 409(`GitIndexLockedError`)/400(`GitNothingToCommitError`) に拡張。write系は `execGitCommandTyped`+`GIT_WRITE_TIMEOUT_MS`。**`getGitStatus` と 1s `execGitCommand` はバイト不変(#779 invariant)**。Issue #781で branch 操作追加:read `listBranches(worktreePath, include)`(`git branch [-r]`+`symbolic-ref refs/remotes/origin/HEAD`(default)+`git worktree list --porcelain`(checkedOutWorktreePath 逆引き)+`git for-each-ref`(upstream/aheadBehind)を1s `execGitCommand` で並列実行・各 best-effort 縮退・**非throw**、`parseWorktreePorcelain`/`parseForEachRefTracking` 新パーサ)。write `checkoutBranch`(precondition=別worktree占有→`GitBranchCheckedOutElsewhereError`(force不可)/dirty→`GitDirtyError`、remote→`git switch -c --track origin/` で detached 回避 S3-008、createIfMissing→`switch -c`、force→`checkout -f`)/`createBranch`(`git branch [from] --`)/`deleteBranch`(current→`GitCurrentBranchError`/default→`GitDefaultBranchError`、`-d` not-merged→`GitBranchNotMergedError`)。全 write は `runSerializedWrite`+`execGitCommandTyped(GIT_WRITE_TIMEOUT_MS)`・branch 引数 `--` 終端。新 error class 6種+`handleGitApiError` を `{error, reason}` で 404 branch_not_found/409 not_merged/current_branch/default_branch/dirty/checked_out_elsewhere(+worktreePath) にマップ。**`getGitStatus`/1s read path は #781 でもバイト不変(invariant 回帰テストあり)**。Issue #782で stash + reset/revert(Danger Zone)追加:read `parseStashList`/`getStashList`(`git stash list --format='%gd%x09%s%x09%cI%x09%H'`・1s `execGitCommand` 非throw・失敗時 `[]` 縮退)。write `stashPush`(`git stash push [--include-untracked] [-m] --`、`No local changes`→`GitNothingToStashError`正規化)/`stashPop`/`stashApply`/`stashDrop`/`gitReset`(`git reset -- --`)/`gitRevert`(`git revert [--no-commit] --`)を `runSerializedWrite`+`GIT_WRITE_TIMEOUT_MS`+`--` 終端で実装。pop/apply/revert の conflict は `execGitConflictAware`(`execFileAsync` 直呼びで `err.stdout` から `CONFLICT ... in ` 回収し **200 `{conflict,conflictFiles}`**、pop は `stashRetained:true`、timeout→`GitTimeoutError`/not-repo→`GitNotRepoError` を手動再現・index.lock は `runSerializedWrite` 経路で担保 S3-002)。hard reset は `isDefaultBranchForReset`(`symbolic-ref refs/remotes/origin/HEAD`・**未解決時 main/master フォールバック**=deleteBranch とは意図的非対称 S3-010)で default branch を判定し専用 `GitResetDefaultBranchError`(409、delete 文言とは別文言 S3-001(b))。新 error class `GitNothingToStashError`(400)/`GitResetDefaultBranchError`(409)+`handleGitApiError` を `GitTimeoutError` 手前に additive 拡張(**既存 #780/#781 reason body バイト不変・回帰テストあり**)。危険操作(hard reset/stash drop/revert)は `logger.warn` 構造化ログ。**`getGitStatus`/1s read path は #782 でもバイト不変**。Issue #783で network 操作(push/pull/fetch)追加:`getDefaultBranch`(`symbolic-ref refs/remotes/origin/HEAD --short` を **3値**=解決名/`DEFAULT_BRANCH_UNRESOLVED`/非origin→null で返す DR1-002)+`resolveDefaultBranchName`(UNRESOLVED/null を null に畳む)共有ヘルパーを新設し、`isDefaultBranchForReset`/`deleteBranch` を**バイト不変**に集約(DR1-001、listBranches は別関心事で非集約)。`execGitNetworkAware`(`execFileAsync` 直呼び・preserve regex 不変)+`classifyNetworkStderr`(network stderr→6 typed error 単一ソース・**引数なし構築で raw stderr/credential URL 非露出** DR4-003)。`gitFetch`(非直列化=remote-tracking ref のみ・§6.1)/`gitPull`(`runSerializedWrite`+`execGitConflictAware(timeout, classifyNetwork=true)`=conflict 200・generic 失敗は raw `err.message` 非ログ DR4-002)/`gitPush`(**サーバー構築の明示 refspec `${branch}:refs/heads/${branch}`** で destination を決定論化し `push.default`/upstream bypass を封鎖 DR4-004、force 時に `resolveDefaultBranchName` と比較→`GitProtectedBranchError`(409)・unresolved=未保護=reset と非対称、`--force-with-lease` 優先、force push は `logger.warn` 構造化ログ)。`execGitConflictAware` に `timeout`/`classifyNetwork` 引数を additive 追加(#782 caller はバイト不変 DR2-007)。新 error class 6種(`GitAuthFailedError`401/`GitNonFastForwardError`409/`GitNoUpstreamError`400/`GitProtectedBranchError`409/`GitForceWithLeaseStaleError`409/`GitNetworkError`**502**)+`handleGitApiError` を `GitTimeoutError` 手前に additive 拡張(既存 byte-identical it 不変・DR3-003)。**`getGitStatus`/1s read path/preserve regex は #783 でもバイト不変**。Issue #1515で ahead/behind の鮮度対策:`getAheadBehind` は `{aheadBehind, reason}` を返す形に変更(`execGitCommandCapture`=非throwのまま stderr を保持する read helper を新設し、stderr を `no_upstream`/`upstream_gone`/`detached`/`error` に分類。timeout は常に `error`。**raw stderr は返さずログにも出さない**・分類 enum のみ)。`getLastFetchAt`=`git rev-parse --git-path FETCH_HEAD --git-common-dir` の両 FETCH_HEAD mtime の**新しい方**を epoch ms で返す(linked worktree はこの worktree での fetch と repo root での fetch の双方を拾う・未fetchは null・非throw)。`execGitNetworkAware` は `GIT_TERMINAL_PROMPT=0` 付きで実行(認証情報なし HTTPS remote で対話プロンプト待ちにならず即 `GitAuthFailedError`) **Issue #3416**: `execGitCommand`(git-exec.ts)は 1 秒タイムアウトで打ち切られた読み取りを `git:command-failed` の warn(`timedOut`/`timeoutMs` つき)で記録し、git が非 0 で終わった失敗だけ ERROR のまま。両方ともログに worktree の basename を載せる。 | | `src/lib/utils.ts` | 汎用ユーティリティ関数(debounce、truncateString、escapeHtml等)。汎用ユーティリティ(withTimeout追加: Issue #627) | | `src/lib/global-state.ts` | `getOrInitGlobal(key, init)`: `globalThis[key]` に値があればそれを返し、無ければ `init` を 1 回だけ実行して置く(Issue #3231)。`src/` の `globalThis.__*` の初期化はこの呼び出しにそろえた(冒頭の規約を参照)。import を持たない | | `src/config/editable-extensions.ts` | 編集可能ファイル拡張子設定。編集可能拡張子定義・バリデーション(EDITABLE_EXTENSIONS, EXTENSION_VALIDATORS, isEditableExtension, validateContent)。.yaml/.yml 追加・YAML危険タグバリデーション(Issue #646)。TEXT_MAX_SIZE_BYTES を 2MB に引き上げ・PUT/GET 共通定数化(Issue #723) **Issue #2506: `.txt` を追加**(`EDITABLE_EXTENSIONS` は 5 → 6 要素、`EXTENSION_VALIDATORS` に `maxFileSize: TEXT_MAX_SIZE_BYTES` のエントリ)。`additionalValidation` は**付けない**。YAML の危険タグ検査(#646)が要るのは `.yaml` がどこかで **parse される**からで、プレーンテキストは parse されないため同じバイト列を拒む理由が無い。NULL バイト検出と制御文字警告は `validateContent()` の共通処理なので `.txt` にもそのまま効く。配列への追加は**末尾**(先頭挿入ではない): `NewFileDialog` がこの配列を順にドロップダウンへ描画し `selectedExt` の初期値を `'.md'` にしているため、末尾に足すことで既存の選択肢の並びと既定値が変わらない。1 要素の追加が同時に 3 つの振る舞いを変える点に注意 —— PUT の書き込み許可(`isEditableFile()`)、PC ファイルパネルのエディタ分岐(`FilePanelContent`)、そして **GET の 2MB 上限適用**(`evaluateEditableSizeLimit()` が `isEditableExtension()` で分岐するため)。3 番目は #2505 が「上限超過 = 読めない」を「上限超過 = 保存できない」に変えた後だから安全であって、#2505 以前に同じ追加をしていたら 2MB 超の `.txt` が 413 で開けなくなっていた。`EDITABLE_EXTENSIONS` / `isEditableExtension()` のシグネチャは #2507(モバイル側)が前提にするので変えないこと | diff --git a/src/lib/git/git-exec.ts b/src/lib/git/git-exec.ts index 1dab37f49..9a3243d9d 100644 --- a/src/lib/git/git-exec.ts +++ b/src/lib/git/git-exec.ts @@ -61,14 +61,36 @@ export async function execGitCommand( return stdout.trim(); } catch (error) { // Log error server-side only (no client exposure) - logger.error('git:command-failed', { + const fields = { args: args.join(' '), + worktree: path.basename(cwd), error: error instanceof Error ? error.message : 'Unknown error', - }); + }; + // Issue #3416: a read killed by the 1s timeout is an EXPECTED outcome on a + // loaded host (callers already degrade null to '(unknown)' / not dirty), so + // it is a warn. Any other failure (git exited non-zero) stays an ERROR. + if (isExecTimeout(error)) { + logger.warn('git:command-failed', { ...fields, timedOut: true, timeoutMs: GIT_COMMAND_TIMEOUT_MS }); + } else { + logger.error('git:command-failed', fields); + } return null; } } +/** + * True when execFile rejected because the child was killed by `timeout` + * (Node sets `killed` and leaves stderr empty) rather than because git failed. + */ +function isExecTimeout(error: unknown): boolean { + const err = error as { code?: string | number; killed?: boolean } | null; + return ( + err?.killed === true || + err?.code === 'ERR_CHILD_PROCESS_EXEC_TIMEOUT' || + err?.code === 'ETIMEDOUT' + ); +} + /** * Outcome of {@link execGitCommandCapture}: a non-throwing read that keeps the * stderr so the caller can CLASSIFY the failure (Issue #1515, B-1). @@ -116,10 +138,7 @@ export async function execGitCommandCapture( return { ok: true, stdout: stdout.trim(), stderr: stderr ?? '', timedOut: false }; } catch (error) { const err = error as Error & { code?: string | number; killed?: boolean; stderr?: string }; - const timedOut = - err.killed === true || - err.code === 'ERR_CHILD_PROCESS_EXEC_TIMEOUT' || - err.code === 'ETIMEDOUT'; + const timedOut = isExecTimeout(err); return { ok: false, stdout: '', diff --git a/tests/unit/lib/git/git-exec-log-level.test.ts b/tests/unit/lib/git/git-exec-log-level.test.ts new file mode 100644 index 000000000..3eb70ed23 --- /dev/null +++ b/tests/unit/lib/git/git-exec-log-level.test.ts @@ -0,0 +1,108 @@ +/** + * Issue #3416: execGitCommand log level against a REAL git binary. + * + * Production measured 127 `git:command-failed` ERRORs over ~3 days; 126 were + * `status --porcelain` and 1 `rev-parse --abbrev-ref HEAD`, all with an empty + * stderr, i.e. children killed by the 1s timeout. Those are expected on a loaded + * host (callers degrade null to '(unknown)' / not dirty) and must be a warn, + * while a genuine git failure (non-zero exit) must stay an ERROR. + * + * Uses throw-away repositories under os.tmpdir(); the real repo is never touched. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + withContext: vi.fn(), + }, +})); + +vi.mock('@/lib/logger', () => { + mockLogger.withContext.mockReturnValue(mockLogger); + return { createLogger: vi.fn(() => mockLogger) }; +}); + +import { execGitCommand } from '@/lib/git/git-exec'; + +let root: string; +let repo: string; +let notRepo: string; + +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-3416-')); + repo = path.join(root, 'repo'); + notRepo = path.join(root, 'plain'); + fs.mkdirSync(repo); + fs.mkdirSync(notRepo); + execFileSync('git', ['init', '-q'], { cwd: repo }); + // Keep `plain` from resolving to an enclosing repository. + fs.writeFileSync(path.join(notRepo, '.git'), 'gitdir: /nonexistent\n'); +}); + +afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('execGitCommand log level (Issue #3416)', () => { + it('logs a timed-out read as warn (not ERROR) and still returns null', async () => { + // A shell alias that outlives the 1s timeout; its output goes to /dev/null so + // the pipes close as soon as git is killed. + const out = await execGitCommand( + ['-c', 'alias.slow=!sleep 3 >/dev/null 2>&1', 'slow'], + repo + ); + + expect(out).toBeNull(); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'git:command-failed', + expect.objectContaining({ timedOut: true, timeoutMs: 1000, worktree: 'repo' }) + ); + }, 10000); + + it('keeps a genuine git failure (unknown revision) as ERROR', async () => { + const out = await execGitCommand(['rev-parse', '--verify', 'no-such-ref'], repo); + + expect(out).toBeNull(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'git:command-failed', + expect.objectContaining({ args: 'rev-parse --verify no-such-ref', worktree: 'repo' }) + ); + expect(mockLogger.error.mock.calls[0][1]).not.toHaveProperty('timedOut'); + }); + + it('keeps a path that is not a repository as ERROR', async () => { + const out = await execGitCommand(['status', '--porcelain'], notRepo); + + expect(out).toBeNull(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'git:command-failed', + expect.objectContaining({ args: 'status --porcelain' }) + ); + }); + + it('logs nothing when the command succeeds', async () => { + const out = await execGitCommand(['status', '--porcelain'], repo); + + expect(out).toBe(''); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); +});