From 227535ec9099bed0b04f2a97dcde23ca7100699c Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 8 Oct 2026 16:24:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(agent-health):=20git=20=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E5=8F=96=E3=82=8A=E3=81=AE=E3=82=BF=E3=82=A4=E3=83=A0?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E3=82=92=20git:command-failed=20?= =?UTF-8?q?=E3=81=AE=20warn=20=E3=81=AB=E4=B8=8B=E3=81=92=E3=82=8B=20(#341?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 失敗の内訳(本番ログを読み取りのみで集計) 対象: MyCodeBranchDesk/logs/server.log(0件) .1(103件) .2(17件) .3(7件) = 計127件 (2026-10-05T02 〜 2026-10-08T00)。ERROR の [git*] はこのキー以外 0 件。 - (a) サブコマンド: `status --porcelain` 126 件 / `rev-parse --abbrev-ref HEAD` 1 件 - (b) エラー文言: 127 件すべて `Command failed: git \n` で stderr が空。 git 自身が失敗すると stderr に `fatal: ...` が付くので、空 = execFile の timeout(GIT_COMMAND_TIMEOUT_MS=1000ms)で kill された子プロセス。 `not a git repository`・上流なし・HEAD なし等の文言は 0 件。 - (c) 呼び出し元: ログに cwd が無く worktree 単位では数えられない。 `status --porcelain` を 1 秒枠で呼ぶのは getGitStatus(git-status.ts:36。 GitPane 5 秒ポーリング / GET /api/worktrees/[id] 等)・preview-diff.ts:473・ git-branches.ts:322。rev-parse と status が同じ回で落ちたのは 1 回だけで、 他は status だけが 1 秒を超えている=getGitStatus の 3 並列のうち一番重い読み取り。 時間分布: 10-07T17 に 35 件(17:36〜17:48 に約 22 秒間隔で連続。同時刻は CommandAgent の worktree で command-code が稼働中=cargo のビルド負荷)、 他は 1 時間あたり 1〜11 件。list:slow(WARN) の前後 60 秒に重なるのは 127 件中 39 件。 - 平常時の実測(git --no-optional-locks status --porcelain、書き込みなし): CommandMate の worktree 群 27〜48ms、CommandAgent の worktree 群 213〜325ms。 1 秒枠に対し平常時は 3〜40 倍の余裕があり、超えるのはホスト負荷の時だけ。 ## 判断 - 切り分け: 想定内の結果。無駄な再試行・消えた worktree への繰り返し呼び出しは 見つからない(失敗文言に存在しないパスの痕跡なし)。呼び出し側は null を '(unknown)' / not dirty として既に扱っている。 - 閾値は変えない: 1 秒は Issue #779 の「UI を止めない」ための上限で、平常時は 十分な余裕がある。原因は負荷であり、閾値を上げても負荷時のポーリング応答が 遅くなるだけで失敗の原因は消えない。 - 直したこと(src/lib/git/git-exec.ts execGitCommand): timeout で kill された失敗は `git:command-failed` の warn(timedOut/timeoutMs つき)、 git が非 0 で終わった失敗は従来どおり ERROR。キー `git:command-failed` は scripts/agent-health のメトリクスと tests/unit/lib/agent-health/*, tests/unit/scripts/agent-health/*, tests/unit/logger.test.ts が参照しているので残した。 次の計測で呼び出し元を数えられるよう、ログに worktree(cwd の basename)を足した。 timeout 判定は execGitCommandCapture と同じ条件を isExecTimeout に寄せて共用。 - 期待値: 127 件すべてが warn になるので、ERROR は 0 件近くまで減る見込み (翌日以降の計測で別途確認)。 ## 対になる場所 - 同じ ERROR キーの 2 か所目 git-exec.ts:192(execGitCommandTyped): timeout は その前で GitTimeoutError に変わり到達しないので、到達するのは本当の失敗だけ。直さない。 - 同じ「logger.error で失敗を握りつぶす」型: git-log.ts:220 git:commit-log-failed、 git-diff.ts:132 git:working-diff-failed、git-exec.ts git:conflict-aware-failed。 本番ログでの ERROR は 0 件で、下げる根拠がないので直さない。 - execGitCommandCapture の timeout 判定は同じ条件の写し → isExecTimeout を共用。 execGitCommandTyped / execGitConflictAware / execGitNetworkAware / git-diff.ts の 同条件は timeout を型付きエラーに変える別の契約なので触らない。 - 古い動きを固定したテスト: execGitCommand の失敗を logger.error で固定したテストは 無い(tests/unit/lib/git, tests/unit/git-utils.test.ts を確認)。 - docs: git-exec.ts の行は docs/module-reference.md に無い。注記は断片で出す。 ## テスト tests/unit/lib/git/git-exec-log-level.test.ts(os.tmpdir() 配下の一時 git リポジトリ): timeout → warn のみ・ERROR なし(陽性)/unknown revision・リポジトリでないパス → ERROR のまま(陰性)/成功 → ログなし。 ## 本文に無い指摘 - 本文に無い指摘: src/lib/git/git-status.ts:50 status が timeout で null のとき isDirty=false になり、負荷時に dirty な worktree が clean と表示される。 - 本文に無い指摘: src/lib/git/git-branches.ts:322 checkout の dirty ガードが status の null(timeout を含む)を clean として通すため、負荷時に force なしの checkout が未コミット変更の上で進みうる(git 自身の拒否が最後の砦)。 Co-Authored-By: Claude Opus 5.5 --- changelog.d/3416.md | 2 + src/lib/git/git-exec.ts | 31 ++++- tests/unit/lib/git/git-exec-log-level.test.ts | 108 ++++++++++++++++++ 3 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 changelog.d/3416.md create mode 100644 tests/unit/lib/git/git-exec-log-level.test.ts 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/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(); + }); +}); From a35d6986cf086e8d3dcaefb7c3a9d976fc8de386 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 8 Oct 2026 17:33:09 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(module-reference):=20Issue=20#3416=20?= =?UTF-8?q?=E3=81=AE=E6=B3=A8=E8=A8=98=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/module-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(モバイル側)が前提にするので変えないこと |