diff --git a/changelog.d/3435.md b/changelog.d/3435.md new file mode 100644 index 000000000..dc9c86479 --- /dev/null +++ b/changelog.d/3435.md @@ -0,0 +1,2 @@ + +- **fix(git): git status を読めなかった worktree を「変更なし」と表示しない** (#3435): `getGitStatus` は `git status --porcelain` の失敗(負荷時の 1 秒 timeout)を clean と報告していた。失敗時は `statusUnknown: true` を返し、Git ペイン・デスクトップ/モバイルのヘッダー・スキルの対象選択は「変更の有無が不明」と表示する。スキルのプレビューは不明を dirty 側(警告を出す側)に倒す diff --git a/docs/module-reference.md b/docs/module-reference.md index d8221d1a2..e88452dba 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`) **Issue #3416**: `execGitCommand`(git-exec.ts)は 1 秒タイムアウトで打ち切られた読み取りを `git:command-failed` の warn(`timedOut`/`timeoutMs` つき)で記録し、git が非 0 で終わった失敗だけ ERROR のまま。両方ともログに worktree の basename を載せる。 | +| `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 を載せる。 **Issue #3435**: `getGitStatus` は `status --porcelain` 失敗(1s timeout 等)を clean と報告しない。`statusUnknown: true` を付ける(成功時はキー無し=応答は不変)。`isDirty` は boolean のまま。 | | `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(モバイル側)が前提にするので変えないこと | @@ -630,7 +630,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/components/worktree/git/GitPaneContext.tsx` | GitPane の ambient config Context(Issue #922)。`{isMobile, onDiffSelect, onInsertToMessage?}` を panels が prop-drill せず読む。`GitPaneProvider`/`useGitPaneContext`(Provider 外使用で throw)を export。GitPane が memo 化 value を供給し panels の不要再描画を抑止 | | `src/components/worktree/git/GitPaneLayout.tsx` | GitPane の responsive layout(Issue #818、#922で分離)。5 つの完成済み section(status/quickActions/changes/history/advanced)を ReactNode で受け、`isMobile`(Context)で Mobile 4 タブ strip(active のみ mount、`git-pane-mobile`/`git-pane-mobile-panel`)か Desktop visual grouping(`git-pane-desktop`/`git-group-{read,write,history,advanced}`)に組む純レイアウト。data/handler を持たない | | `src/components/worktree/git/gitPaneShared.tsx` | GitPane panels 共有の presentation primitive(Issue #922)。`STATUS_TEXT_COLOR`(`Record`)/`ChangesDiffMode`/`CHANGES_PREVIEW_LINES=20`/`RefreshIcon`/`AskAiButton`/`DiffLine`/`InlineError`。GitPane 本体から testid・色・文言不変で抽出 | -| `src/components/worktree/git/panels/GitCurrentStatusBar.tsx` | Current Status panel(Issue #779、#922で抽出)。`{gitStatus, statusLoading, statusError, onRefresh, refreshing?}` 受領、`isMobile` は Context 読み。`git-status-section`。Issue #1515: 🔄 は fetch 付き更新(`refreshing` で disable+spinner・aria-label は `refresh`/`refreshing` 切替、`git-status-refresh-button`)、`↑N ↓N` が無いときは理由バッジを描画(`git-status-ahead-behind-reason[data-reason]`=`REASON_KEYS` で `AheadBehindReason` を網羅的に i18n キーへ写像)、`git-status-last-fetch`=`lastFetchAt` を `formatRelativeTime`+`useLocale` で「最終 fetch: N分前」表示(null は never) | +| `src/components/worktree/git/panels/GitCurrentStatusBar.tsx` | Current Status panel(Issue #779、#922で抽出)。`{gitStatus, statusLoading, statusError, onRefresh, refreshing?}` 受領、`isMobile` は Context 読み。`git-status-section`。Issue #1515: 🔄 は fetch 付き更新(`refreshing` で disable+spinner・aria-label は `refresh`/`refreshing` 切替、`git-status-refresh-button`)、`↑N ↓N` が無いときは理由バッジを描画(`git-status-ahead-behind-reason[data-reason]`=`REASON_KEYS` で `AheadBehindReason` を網羅的に i18n キーへ写像)、`git-status-last-fetch`=`lastFetchAt` を `formatRelativeTime`+`useLocale` で「最終 fetch: N分前」表示(null は never) **Issue #3435**: `statusUnknown` のとき `git-status-unknown-badge`(`git.currentStatus.statusUnknown`)を描画。デスクトップ/モバイルのヘッダーは branch 横に `?`(`desktop-git-status-unknown` / `mobile-git-status-unknown`)。 | | `src/components/worktree/git/panels/GitNetworkOperationsBar.tsx` | Pull/Push(+optional Fetch/extraActions)panel(Issue #783、#922で抽出)。`{progressState, operation, error, conflict, conflictFiles, hasUpstream, onFetch?, onPull, onPush, onAbort, extraActions?}` 受領、elapsed-seconds tick を内包、`isMobile` は Context。`git-network-section` | | `src/components/worktree/git/panels/GitChangesPanel.tsx` | Changes panel(Issue #780/#816、#922で抽出)。staged/unstaged/untracked リスト+commit form+Commit/Commit+Push+inline preview。private `ChangedFileList` 内包、`isMobile` は Context。`git-changes-section` | | `src/components/worktree/git/panels/GitCommitHistoryPanel.tsx` | Commit History panel(Issue #447/#816、#922で抽出)。commit list+inline diff accordion+changed files+diff。`changedFilesOpen`/`diffOpen` を local state 保持、`isMobile` は Context | @@ -778,7 +778,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/error/chunk-reload.ts` | ChunkLoadError の分類+ガード付き自己修復(Issue #1404 / #2500)。React を import しない env 注入型で、`app/error.tsx` / `app/global-error.tsx` から呼ばれる。`isChunkLoadError(error)` は**構文的判定のみ**(`name === 'ChunkLoadError'` / メッセージに `Loading chunk` \| `Failed to fetch dynamically imported module`)で「なぜ落ちたか」は言わない。**Issue #2500: `classifyChunkError(error, onLine)` が `'none'` / `'network'`(オフライン)/ `'build'`(版ズレ)に分ける** — 遅延 import の失敗は「バンドルが消えた」ときも「スマホが Wi-Fi 圏外に出た」ときも同じ `ChunkLoadError` になるため、エラーだけでは証拠が足りない。判定規則は #2501 と同じ非対称性で、**`navigator.onLine === false` はオフラインの確定、`=== true` は何の証拠にもならない**(キャプティブポータル)。`recoverFromChunkError(error, env)` の戻り値は `'skipped'` / `'offline'`(#2500 で追加。リロードせず**ガードのタイムスタンプも書かない**ので、後で本物の版ズレが来たときの1回分が温存される)/ `'reloaded'` / `'guarded'`。`ChunkRecoveryEnv.onLine` は**必須**(既定値を持たせない=答えられない呼び出し元に明示させる。取り違えるとユーザーの手元でページが飛ぶ)。`recoverFromChunkErrorInBrowser()` が `navigator.onLine` / `sessionStorage`(Safari private mode の throw を吸収)/ `location.reload` を注入。復帰待ちは `retryWhenServerReturns(retry, env)`(`online` イベントは probe で裏を取ってから、到達性レポートは即座に retry。retry は高々1回、戻り値で購読解除)と `browserChunkRetryEnv({ subscribeReachability, probe })`(`window` の `online` だけ自前で張り、#2501 の2関数は呼び出し元から渡す。`src/lib` → `src/hooks` の依存を作らないため) | | `src/app/error.tsx` | ルートセグメントの error boundary(Issue #1404 / #2500)。root layout の内側なので `NextIntlClientProvider` があり `useTranslations` が使える。`recoverFromChunkErrorInBrowser()` の結果で3態に分岐: `'offline'` → 自動リロードせず `error.chunkOffline.*` を表示し、`retryWhenServerReturns()` でサーバ復帰時に `reset()` を自動実行(#2501 の `subscribeServerReachability` / `probeServerReachable` を渡す)/chunk かつオンライン → `error.chunkReload.*`+1回だけ自動リロード/それ以外 → `error.unexpected.*`+`reset()` ボタン。**`navigator.onLine` は client 専用なので判定は render ではなく `useEffect` で行う**(hydration mismatch を避ける。初期 state は `offline: false`)。`data-testid="app-error"` / `data-error-kind`(`chunk-offline` \| `chunk-reload` \| `unexpected`)をテストの足場として出す **Issue #2682**: シェルが root layout に上がったため、この boundary は `AppShellGate` の**内側**で描かれる(`AppShellGate` は pathname だけを見て error かどうかは見ない)。高さが二重にならないよう `
` を `min-h-screen` → `min-h-full` に変更。あわせて、シェル自身が投げた例外はこれまでの page 側 boundary ではなくここが拾う | | `src/app/global-error.tsx` | root layout 自体が落ちたときの最終 boundary(Issue #1404 / #2500)。`` / `` を自前で描き、**`NextIntlClientProvider` が無い**ので文言は provider 非依存の内蔵辞書(`FALLBACK_MESSAGES`、locale は cookie → `navigator.language`)、**`globals.css` も来ない**ので style は全てインライン。#2500 で `offlineTitle` / `offlineDescription` / `offlineWaiting` / `retry` を追加し、オフライン時はリロードせず `reset()` 再試行(ボタンも `reload` ではなく `retry`)。`GlobalError` が `reset` を受け取るようになったのは #2500 から | -| `src/types/models.ts` | ドメイン型の中枢。**プロンプト payload の共有 union(Issue #1738)**: `LivePromptData`(= `PromptData \| StructuredPromptWaitingData`。`/current-output` が #1725 以降返しうるライブ値。WS スナップショット→hooks→reducer→PromptPanel/MobilePromptSheet の全層がこれを参照する)・`StoredPromptData`(= `PromptData \| UnclassifiedFrameRecord \| StructuredPromptHistoryRecord`。`chat_messages.prompt_data` は監査行と列を共有しており、`ChatMessage.promptData` はこちら)・型ガード `isAnswerablePromptData()`(真=選択肢と `answer` が意味を持つ/偽=縮退形。**分岐はこの 1 つだけ**で、各所で `type === 'unclassified'` を書き直さない)。**`StructuredPromptWaitingData` を `PromptData` union に入れてはならない** — 応答経路が受け取ってはいけない値を網羅 map に生やさないため(`UNCLASSIFIED_PROMPT_TYPE` の注記)。閉じるのは union ではなく**値が通る経路の型**。`lib/session/structured-prompt` からの import は**必ず `import type`**(向こうが `UNCLASSIFIED_PROMPT_TYPE` を値 import で持ち帰るため、値 import にすると実行時循環になる) **Issue #2054**: `AgentEventSourceView`(`lib/hooks/sources/types.ts` の `AgentEventSourceStatus` のクライアント鏡。`'use client'` から import できないので宣言を複製)と `sessionStatusByInstance[].eventSource` を追加 **Issue #2070**: `SessionStatusReadingDetail`(`statusEvidence?` / `sessionStatusReason?`)を追加し `sessionStatusByCli` / `sessionStatusByInstance` に交差型で載せた。サーバは #1926 以降これを送っていたがクライアント型が名前を持っていなかった **Issue #2377**: `MessageType` に `'relay'` を追加(`'normal' \| 'prompt' \| 'prompt_response' \| 'relay'`)。role ではなく messageType なのは、relay 行が本当に user ターンであり `role` の 2 値はあらゆる reader が分岐しているため。判別子が買うのは「他セッションから委任された」と「自分のオペレータが聞いた」の区別で、History のマークとループガードがこれを読む **Issue #2505**: `FileReadOnlyReasonCode`(現状 `'FILE_TOO_LARGE'` のみの string union。次の理由が増えたとき switch が網羅性検査を受けられるよう `string` にしない)と `FileReadOnlyReason`(`code` / `message` / `limitBytes` / `sizeBytes`)を追加し、`FileContent` に `readOnly?: boolean` と `readOnlyReason?: FileReadOnlyReason` を追加。`readOnly` が真なら `readOnlyReason` は必ず在る。機械可読な `code` と整形済みの `message` を両方運ぶのは、分岐は `code` で行い表示は `message` をそのまま使うため。Issue #2506(`.txt` を編集可能化)はこの契約を無変更のまま利用する **Issue #2512**: `Worktree.autoYesByInstance?: Partial>` を追加(`sessionStatusByInstance` と同じキー。一覧 API だけが埋める。存在=有効、不在=off(期限切れを含む))。`@/types/auto-yes` からは `import type` **Issue #2755**: `MultipleChoiceOption.checked?: boolean` と `MultipleChoicePromptData.multiSelect?: boolean` を追加。チェックボックス質問では番号が「トグル」であり確定は別行なので、番号→キーに変換する全消費者(回答 UI・送信・Auto-Yes)がこの 2 つで分岐する。単一選択には**足さない**(`false` を書くと #2522 の payload 完全一致ピンが壊れる=それが検出器)。 **Issue #2838**: `Worktree.lastActivityByInstance?: Partial>`(キーは `sessionStatusByInstance` と同じ instanceId。`GET /api/worktrees` のみが埋める)。 | +| `src/types/models.ts` | ドメイン型の中枢。**プロンプト payload の共有 union(Issue #1738)**: `LivePromptData`(= `PromptData \| StructuredPromptWaitingData`。`/current-output` が #1725 以降返しうるライブ値。WS スナップショット→hooks→reducer→PromptPanel/MobilePromptSheet の全層がこれを参照する)・`StoredPromptData`(= `PromptData \| UnclassifiedFrameRecord \| StructuredPromptHistoryRecord`。`chat_messages.prompt_data` は監査行と列を共有しており、`ChatMessage.promptData` はこちら)・型ガード `isAnswerablePromptData()`(真=選択肢と `answer` が意味を持つ/偽=縮退形。**分岐はこの 1 つだけ**で、各所で `type === 'unclassified'` を書き直さない)。**`StructuredPromptWaitingData` を `PromptData` union に入れてはならない** — 応答経路が受け取ってはいけない値を網羅 map に生やさないため(`UNCLASSIFIED_PROMPT_TYPE` の注記)。閉じるのは union ではなく**値が通る経路の型**。`lib/session/structured-prompt` からの import は**必ず `import type`**(向こうが `UNCLASSIFIED_PROMPT_TYPE` を値 import で持ち帰るため、値 import にすると実行時循環になる) **Issue #2054**: `AgentEventSourceView`(`lib/hooks/sources/types.ts` の `AgentEventSourceStatus` のクライアント鏡。`'use client'` から import できないので宣言を複製)と `sessionStatusByInstance[].eventSource` を追加 **Issue #2070**: `SessionStatusReadingDetail`(`statusEvidence?` / `sessionStatusReason?`)を追加し `sessionStatusByCli` / `sessionStatusByInstance` に交差型で載せた。サーバは #1926 以降これを送っていたがクライアント型が名前を持っていなかった **Issue #2377**: `MessageType` に `'relay'` を追加(`'normal' \| 'prompt' \| 'prompt_response' \| 'relay'`)。role ではなく messageType なのは、relay 行が本当に user ターンであり `role` の 2 値はあらゆる reader が分岐しているため。判別子が買うのは「他セッションから委任された」と「自分のオペレータが聞いた」の区別で、History のマークとループガードがこれを読む **Issue #2505**: `FileReadOnlyReasonCode`(現状 `'FILE_TOO_LARGE'` のみの string union。次の理由が増えたとき switch が網羅性検査を受けられるよう `string` にしない)と `FileReadOnlyReason`(`code` / `message` / `limitBytes` / `sizeBytes`)を追加し、`FileContent` に `readOnly?: boolean` と `readOnlyReason?: FileReadOnlyReason` を追加。`readOnly` が真なら `readOnlyReason` は必ず在る。機械可読な `code` と整形済みの `message` を両方運ぶのは、分岐は `code` で行い表示は `message` をそのまま使うため。Issue #2506(`.txt` を編集可能化)はこの契約を無変更のまま利用する **Issue #2512**: `Worktree.autoYesByInstance?: Partial>` を追加(`sessionStatusByInstance` と同じキー。一覧 API だけが埋める。存在=有効、不在=off(期限切れを含む))。`@/types/auto-yes` からは `import type` **Issue #2755**: `MultipleChoiceOption.checked?: boolean` と `MultipleChoicePromptData.multiSelect?: boolean` を追加。チェックボックス質問では番号が「トグル」であり確定は別行なので、番号→キーに変換する全消費者(回答 UI・送信・Auto-Yes)がこの 2 つで分岐する。単一選択には**足さない**(`false` を書くと #2522 の payload 完全一致ピンが壊れる=それが検出器)。 **Issue #2838**: `Worktree.lastActivityByInstance?: Partial>`(キーは `sessionStatusByInstance` と同じ instanceId。`GET /api/worktrees` のみが埋める)。 **Issue #3435**: `GitStatus.statusUnknown?: boolean`(真=git status を読めず `isDirty` は無情報)。`types/git.ts` の `CheckoutResponse` / `ResetResponse` にも同名の任意フィールド。 | | `src/types/agent-transcript.ts` | 履歴行が「エージェント自身の Markdown」であることを示す provenance 語彙(Issue #2041)。`AGENT_MARKDOWN_REQUEST_ID_PREFIX='oc-turn:'` / `isAgentAuthoredMarkdown(requestId)` / `opencodeTurnRequestId(userMessageId)`。**`chat_messages.request_id` に載せる理由**: 既存・indexed・nullable で既存 producer が 1 つだけ(`parseClaudeOutput`、claude 行のみ)。`message_type` を使わなかったのは `MessageType` が 3 語 union で十数箇所が switch しているため。専用列にしなかったのは #2044 が v58 を並行で使うため(**migration を足していない**)。マーカーと冪等キーを兼ねるのは偶然ではない — どのターン由来かを名乗れないマーカーはキーになれない Issue #2121 で claude 用の prefix を追加。`CLAUDE_MARKDOWN_REQUEST_ID_PREFIX='claude-turn:'` / `AGENT_MARKDOWN_REQUEST_ID_PREFIXES`(`isAgentAuthoredMarkdown` はこの配列を走査する)/ `claudeTurnRequestId(promptUuid)`。prefix を opencode と共有しなかったのは、マーカーが冪等キーも兼ねており両者のターン ID の名前空間が別(opencode は `msg_…`、claude はレコード `uuid`)なため — 共有すると衝突が「保存済み」に化ける。`ConversationPairCard` は prefix を 1 つも名指ししていないので**無改造** Issue #2196: user 行用の prefix `CLAUDE_PROMPT_REQUEST_ID_PREFIX='claude-prompt:'` / `claudePromptRequestId(promptUuid)` を追加。assistant 行(`claude-turn:`)と**同じ uuid・別 prefix**なのは、同一 prefix だと 1 ターンの user 行と assistant 行が `findMessageByRequestId` で衝突し 2 番目の writer が降りてしまうため。**`AGENT_MARKDOWN_REQUEST_ID_PREFIXES` には入れない** — operator 自身の文章は verbatim 描画のままで、`ConversationPairCard` が無改造なのはこの 1 点による Issue #2197 で codex 用 prefix を追加。`CODEX_MARKDOWN_REQUEST_ID_PREFIX='codex-turn:'`(`AGENT_MARKDOWN_REQUEST_ID_PREFIXES` に追加)/ `codexTurnRequestId(turnId)` / `CODEX_PROMPT_REQUEST_ID_PREFIX='codex-prompt:'`(Markdown 一覧には**入れない**)/ `codexPromptRequestId(promptItemId)`。claude と違い user 行と assistant 行が**別 id 空間**なのは実測による — codex は実行中の追加プロンプトを同じターンに畳み込むため 1 ターンに `UserMessage` が複数載る(保存済み 326 ターン中 23)。turn id で user 行を keyed すると 2 通目が「保存済み」に化けて消える Issue #2198 で antigravity 用 prefix を追加。`ANTIGRAVITY_MARKDOWN_REQUEST_ID_PREFIX='antigravity-turn:'`(`AGENT_MARKDOWN_REQUEST_ID_PREFIXES` に追加)/ `antigravityTurnRequestId(conversationId, stepIndex)` / `ANTIGRAVITY_PROMPT_REQUEST_ID_PREFIX`(Markdown 一覧には**入れない**)/ `antigravityPromptRequestId(...)`。**id が 2 引数なのは実測由来** — agy には `turn_id` も per-record uuid も無く、turn を名乗れるのは `step_index` だけでこれは**会話の中でだけ一意**(41 本 1,024 レコードで重複ゼロ、ただし 10 本に欠番・1 本が非単調)。裸の `12` は他会話と衝突するので `#` にする。user 行と assistant 行が同じ組を共有するのは claude と同じ形で、agy は 1 turn に `USER_INPUT` を 1 件しか書かない(63/63)ため codex のような item 単位の分離が要らない Issue #2252 で Command Code 用 prefix を追加。`COMMAND_CODE_MARKDOWN_REQUEST_ID_PREFIX='command-code-turn:'`(`AGENT_MARKDOWN_REQUEST_ID_PREFIXES` に追加)/`commandCodeTurnRequestId(promptId)` / `COMMAND_CODE_PROMPT_REQUEST_ID_PREFIX='command-code-prompt:'`(Markdown 一覧には**入れない**)/`commandCodePromptRequestId(promptId)`。名前空間を分ける理由は claude / codex / antigravity と同じだが、ここでは**より切実**である — Command Code の record id は uuid ではなく **8 桁 hex の短い id**(`cb06ab09`)で、claude の uuid の先頭セグメントとまったく同じ形をしているため、prefix を共有すると別ツール間の衝突が「保存済み」に化ける。user 行と assistant 行が同じ id を共有するのは claude / antigravity と同じ形で、Command Code は 1 ターンにつき fresh な `role:'user'` record を 1 件しか開かない(実行中に打たれた入力は `meta.source:'steering'` として**別のターン**を開く)ため codex のような item 単位の分離が要らない Issue #2458 で保存済み行から**ターンを読み戻す**ための 2 関数を追加。`resolveAgentTurnKey(requestId)` は `AGENT_MARKDOWN_REQUEST_ID_PREFIXES` のどれかで始まる文字列に対して **`request_id` 全体(prefix 込み)** を返し、それ以外(非文字列・`req_…`・`claude-prompt:` などの user 側 prefix・suffix が空/空白のみ)は `null`。prefix を落とさないのは、`antigravity-turn:conv#0` と `command-code-turn:conv#0` のように suffix が衝突しうる id 空間で、ツールの切替を「同じターンの継続」と読ませないため。`correlatedPromptRequestId(turnKey)` は turn 行のキーを**同じターンの user 行の `request_id`** へ改名する(`claude-turn:` → `claude-prompt:`、antigravity / command-code も同様)。**codex と opencode は意図的に `null`** — codex は assistant 行が `turn_id`・user 行が `UserMessage` item 自身の id で DB 上別物(#2197)、opencode は `oc-prompt:` に相当する定数がそもそも無い(#2041)ため、ここで文字列を作ると無関係の行と対応づいてしまう。両関数とも純粋な id 操作で、その id の行が実在するか・同一 scope か・時刻が妥当かは呼び出し側(`lib/chat/chat-transcript-view`)が持つ行に対して判定する Issue #2470 で `CLAUDE_HEADLESS_TURN_ID_PREFIX='partial:'` と `claudeHeadlessTurnId(sessionId, closingRecordUuid)`(`partial::`。`claudeTurnRequestId` に渡すと `claude-turn:partial:…`)を追加。prompt 記録が読めないターンの保存行のキー。ライブ表示の `partial:` はセッションに 1 つなので使わない(2 つ目の長いターンが「保存済み」に化ける)。窓内の最初の assistant 記録も使わない(終了後の追記で窓の先頭が動きキーが変わる)。最後の assistant 記録=ターンを閉じた記録は、2026-09-11 実測(298 転写・閉じたターン 1,973 件)で閉じた後に次の prompt より前へ assistant 記録が来た例が 0 のため、全ての再読込で同じ記録を指す。`correlatedPromptRequestId` は誰も書かない `claude-prompt:partial:…` を返す(user 行なしと整合) | | `src/types/agent-session.ts` | **ブラウザ側の agent セッション型**(Issue #2042)。`lib/hooks/agent-session-telemetry` は `fs` / `crypto` を引き込むので `'use client'` から import できない。そのため wire の形を構造的に写した型(`AgentSessionView` / `AgentSessionContextView` / `AgentSessionSnapshot`)と `sumAgentSessionTokens()` をここに置く。**この合計だけが表示層の判断**で、サーバは意図的に合計しない(`tokens.total` はエージェント自身の申告のまま)。合計値は `opencode stats` の印字(17.0K)と一致し、コンテキスト占有(8,508)とは別物。null は 0 ではなく「言わなかった」なので、全部 null なら 0 ではなく null を返す Issue #2043: `AgentSessionDiffView` / `AgentSessionDiffFileView` / `hasAgentSessionDiff()` を追加し、 `AgentSessionSnapshot` に `diff?` を **optional** で追加(#2043 以前の生成箇所はすべて diff の無いペインを表すため)。 `hasAgentSessionDiff()` がパネルの表示可否の唯一の規則 = claude / codex に空パネルを出さない受入条件はここで担保 | | `src/types/opencode-instance-settings.ts` | **opencode の instance 起動設定の語彙**(Issue #2048)。 `OpencodeInstanceSettings`(`agent` / `providerId` / `modelId` / `variant`、すべて nullable=未設定)と `normalizeOpencodeInstanceSettings`。**値はシェル行に補間される**(`prepareOpencodeLaunch`)ので パターン検証は escape ではなく**拒否**(`OPENCODE_SESSION_ID_PATTERN` と同じ論拠)。model id には `/` と `:` が入る(`qwen/qwen3-coder-30b` / `deepseek-v4-flash:0731`、実測)。候補配布用の `OpencodeProviderChoice` / `OpencodeAgentChoice` / `OpencodeLaunchCatalog` もここ。 | @@ -906,7 +906,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/lib/skills/operation-reconciler.ts` | on-demand reconciliation ロジックと orphan cleanup。起動時実行は `startup-reconcile` 経由(#1234 / #1428、server-only) | | `src/lib/db/migrations/v44-skill-operations.ts` | `skill_operations` テーブル。trigger で追記専用を DB 側から強制(#1234) | | `src/lib/skills/install-plan.ts` | 決定的 receipt bytes 確定、plan token の server-side state(TTL/1回性/LRU)、drift 検出(#1233、server-only) | -| `src/lib/skills/preview-diff.ts` | install root 走査・live branch/HEAD 解決・git ignore 判定・virtual unified diff・tree hash(#1233、server-only) | +| `src/lib/skills/preview-diff.ts` | install root 走査・live branch/HEAD 解決・git ignore 判定・virtual unified diff・tree hash(#1233、server-only) **Issue #3435**: `readSkillGitTargetState` は status 失敗を `dirty: true`(警告側)に倒す。 | | `src/lib/skills/install-apply.ts` | `O_EXCL\|O_NOFOLLOW` write・same-filesystem staging・destination 不存在の自前証明・atomic rename(#1235、server-only) | | `src/lib/skills/installed-state.ts` | `skill_installations` の idempotent upsert と `listSkillInstallations`(worktree 単位の読み取り)。読み取りは #1440 の GET route から配線済み(#1235 / #1440) | | `src/lib/skills/status-scanner.ts` | 全登録 worktree 横断の適用状態走査。`installed`/`modified`/`missing`/`unmanaged`/`update_available` を算出。**走査 root は receipt の `install_roots` を正とし全 root を見る**(片側 root だけでは secondary の drift を見逃す)。走査は同期 I/O のため worker pool ではなく上限+worktree 毎の event loop yield+TTL cache で保護し、打ち切りは `truncated` で報告(#1248、server-only) | diff --git a/locales/en/worktree.json b/locales/en/worktree.json index 5a7703bfe..664d5195a 100644 --- a/locales/en/worktree.json +++ b/locales/en/worktree.json @@ -877,6 +877,7 @@ }, "git": { "uncommittedChanges": "Uncommitted changes", + "statusUnknown": "Could not check for uncommitted changes (git status failed)", "askAi": "Ask AI", "askAiTooltip": "Draft the current state into the AI chat (nothing is sent automatically)", "advancedOperations": "Advanced operations", @@ -972,6 +973,8 @@ "refreshing": "Checking the remote...", "loading": "Loading git status...", "uncommitted": "uncommitted", + "statusUnknown": "changes unknown", + "statusUnknownTooltip": "git status could not be read, so whether there are uncommitted changes is unknown. Refresh to try again.", "aheadTooltip": "commits ahead of upstream as of the last fetch (the live remote may differ)", "behindTooltip": "commits behind upstream as of the last fetch (the live remote may differ)", "lastFetch": "Last fetch: {relative}", diff --git a/locales/ja/worktree.json b/locales/ja/worktree.json index 99f78c971..0a193f43b 100644 --- a/locales/ja/worktree.json +++ b/locales/ja/worktree.json @@ -877,6 +877,7 @@ }, "git": { "uncommittedChanges": "未コミットの変更", + "statusUnknown": "変更の有無を確認できませんでした(git status が失敗)", "askAi": "AI に質問", "askAiTooltip": "現在の状況を AI チャットに下書きします(自動送信はされません)", "advancedOperations": "高度な操作", @@ -972,6 +973,8 @@ "refreshing": "リモートを確認中...", "loading": "Git ステータスを読み込み中...", "uncommitted": "未コミット", + "statusUnknown": "変更の有無が不明", + "statusUnknownTooltip": "git status を読めなかったため、未コミットの変更があるかどうか分かりません。更新すると再取得します。", "aheadTooltip": "最後に fetch した時点のアップストリームより先行しているコミット数(リモートの最新状態とは異なる場合があります)", "behindTooltip": "最後に fetch した時点のアップストリームより遅れているコミット数(リモートの最新状態とは異なる場合があります)", "lastFetch": "最終 fetch: {relative}", diff --git a/src/app/api/worktrees/[id]/git/checkout/route.ts b/src/app/api/worktrees/[id]/git/checkout/route.ts index a45a4bff6..bdc74add3 100644 --- a/src/app/api/worktrees/[id]/git/checkout/route.ts +++ b/src/app/api/worktrees/[id]/git/checkout/route.ts @@ -68,7 +68,13 @@ export async function POST( const status = await getGitStatus(worktree.path, initialBranch); return NextResponse.json( - { success: true, currentBranch: status.currentBranch, isDirty: status.isDirty }, + { + success: true, + currentBranch: status.currentBranch, + isDirty: status.isDirty, + // Issue #3435: an unread status is not "clean". + ...(status.statusUnknown ? { statusUnknown: true } : {}), + }, { status: 200 } ); } catch (error) { diff --git a/src/app/api/worktrees/[id]/git/reset/route.ts b/src/app/api/worktrees/[id]/git/reset/route.ts index 339efb793..1fe4245aa 100644 --- a/src/app/api/worktrees/[id]/git/reset/route.ts +++ b/src/app/api/worktrees/[id]/git/reset/route.ts @@ -83,7 +83,13 @@ export async function POST( const status = await getGitStatus(worktree.path, initialBranch); return NextResponse.json( - { success: true, currentBranch: status.currentBranch, isDirty: status.isDirty }, + { + success: true, + currentBranch: status.currentBranch, + isDirty: status.isDirty, + // Issue #3435: an unread status is not "clean". + ...(status.statusUnknown ? { statusUnknown: true } : {}), + }, { status: 200 } ); } catch (error) { diff --git a/src/components/mobile/MobileHeader.tsx b/src/components/mobile/MobileHeader.tsx index 0a88e942a..29bc4d20e 100644 --- a/src/components/mobile/MobileHeader.tsx +++ b/src/components/mobile/MobileHeader.tsx @@ -200,6 +200,15 @@ export function MobileHeader({ {gitStatus.isDirty && ( * )} + {gitStatus.statusUnknown && ( + + ? + + )} )} diff --git a/src/components/skills/SkillTargetSelector.tsx b/src/components/skills/SkillTargetSelector.tsx index 3ac42b9cf..eed67c8cd 100644 --- a/src/components/skills/SkillTargetSelector.tsx +++ b/src/components/skills/SkillTargetSelector.tsx @@ -63,7 +63,8 @@ export function toSkillTargetOption(worktree: Worktree): SkillTargetOption { repositoryName: worktree.repositoryDisplayName ?? worktree.repositoryName, syncedBranch: worktree.branch ?? null, liveBranch: worktree.gitStatus?.currentBranch ?? null, - dirty: worktree.gitStatus ? worktree.gitStatus.isDirty : null, + // Issue #3435: an unread `git status` is unknown, not clean. + dirty: worktree.gitStatus && !worktree.gitStatus.statusUnknown ? worktree.gitStatus.isDirty : null, agents: [...new Set(agents)], sessionRunning: worktree.isSessionRunning === true, }; diff --git a/src/components/worktree/WorktreeDetailSubComponents.tsx b/src/components/worktree/WorktreeDetailSubComponents.tsx index cec1e3afb..6444e2cf5 100644 --- a/src/components/worktree/WorktreeDetailSubComponents.tsx +++ b/src/components/worktree/WorktreeDetailSubComponents.tsx @@ -1168,6 +1168,7 @@ export const DesktopHeader = memo(function DesktopHeader({ truncatedDescription ?? '', gitStatus?.currentBranch ?? '', gitStatus?.isDirty ? 'dirty' : '', + gitStatus?.statusUnknown ? 'status-unknown' : '', showKillButton ? 'end' : '', awaitingInstruction ? 'awaiting' : '', appUpdateKey, @@ -1240,6 +1241,15 @@ export const DesktopHeader = memo(function DesktopHeader({ {gitStatus.isDirty && ( * )} + {gitStatus.statusUnknown && ( + + ? + + )} )} {truncatedDescription && ( diff --git a/src/components/worktree/git/panels/GitCurrentStatusBar.tsx b/src/components/worktree/git/panels/GitCurrentStatusBar.tsx index 58ba6f413..aeab7db67 100644 --- a/src/components/worktree/git/panels/GitCurrentStatusBar.tsx +++ b/src/components/worktree/git/panels/GitCurrentStatusBar.tsx @@ -154,6 +154,17 @@ export const GitCurrentStatusBar = memo(function GitCurrentStatusBar({ {gitStatus.currentBranch} + {/* Issue #3435: status could not be read — never shown as clean */} + {gitStatus.statusUnknown && ( + + {t('git.currentStatus.statusUnknown')} + + )} + {/* Dirty badge */} {gitStatus.isDirty && ( 0; + // Issue #3435: a failed `status` (null, typically the 1s timeout) is NOT + // clean. isDirty stays boolean for existing readers; statusUnknown says the + // value carries no information. + const statusUnknown = statusOutput === null; + const isDirty = !statusUnknown && statusOutput.length > 0; // Determine branch mismatch // No mismatch if: @@ -66,6 +71,8 @@ export async function getGitStatus( isBranchMismatch, commitHash, isDirty, + // Only present when unknown, so successful payloads stay byte-identical. + ...(statusUnknown ? { statusUnknown: true } : {}), }; } diff --git a/src/lib/skills/preview-diff.ts b/src/lib/skills/preview-diff.ts index 77e9f0330..e408b6644 100644 --- a/src/lib/skills/preview-diff.ts +++ b/src/lib/skills/preview-diff.ts @@ -173,7 +173,7 @@ export interface SkillGitTargetState { branch: string | null; /** Full 40-hex HEAD commit, or null on an unborn HEAD. */ headCommit: string | null; - /** The worktree has uncommitted changes. */ + /** The worktree has uncommitted changes (also true when `git status` could not be read). */ dirty: boolean; } @@ -484,7 +484,10 @@ export async function readSkillGitTargetState( headState, branch: branch && branch.length > 0 ? branch : null, headCommit: commit, - dirty: status !== null && status.length > 0, + // Issue #3435: an unread `status` (null, e.g. the 1s timeout) must not + // read as clean. This flag only drives the advisory WORKING_TREE_DIRTY + // warning and the target display, so the fail-safe side is "dirty". + dirty: status === null || status.length > 0, }; } diff --git a/src/types/git.ts b/src/types/git.ts index 829d306a7..3f44a2b63 100644 --- a/src/types/git.ts +++ b/src/types/git.ts @@ -109,6 +109,8 @@ export interface CheckoutResponse { success: true; currentBranch: string; isDirty: boolean; + /** Present (true) only when the post-op `git status` could not be read (Issue #3435). */ + statusUnknown?: boolean; } /** @@ -189,6 +191,8 @@ export interface ResetResponse { success: true; currentBranch: string; isDirty: boolean; + /** Present (true) only when the post-op `git status` could not be read (Issue #3435). */ + statusUnknown?: boolean; } /** diff --git a/src/types/models.ts b/src/types/models.ts index d0c5b02a0..3a0d99fc4 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -59,8 +59,19 @@ export interface GitStatus { isBranchMismatch: boolean; /** Short commit hash (e.g., "abc1234") */ commitHash: string; - /** True if there are uncommitted changes */ + /** + * True if there are uncommitted changes. False when the tree is clean OR when + * `git status` could not be read — check `statusUnknown` before treating + * false as "clean". + */ isDirty: boolean; + /** + * True when `git status --porcelain` failed (typically the 1s read timeout + * under load), so `isDirty` carries no information (Issue #3435). + * - undefined / false: `isDirty` is authoritative + * Optional so existing readers and producers keep working unchanged. + */ + statusUnknown?: boolean; /** * Remote difference (Issue #779). * - undefined: not computed (getGitStatus path / GET /api/worktrees/[id] payload) diff --git a/tests/unit/components/git-status-unknown-3435.test.tsx b/tests/unit/components/git-status-unknown-3435.test.tsx new file mode 100644 index 000000000..4f0d6243e --- /dev/null +++ b/tests/unit/components/git-status-unknown-3435.test.tsx @@ -0,0 +1,137 @@ +/** + * @vitest-environment jsdom + * + * Issue #3435: when `git status` could not be read (`statusUnknown`), no git + * status surface may look clean. Each surface shows an explicit "unknown" + * marker instead; clean / dirty rendering is unchanged. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { GitCurrentStatusBar } from '@/components/worktree/git/panels/GitCurrentStatusBar'; +import { GitPaneProvider } from '@/components/worktree/git/GitPaneContext'; +import { MobileHeader } from '@/components/mobile/MobileHeader'; +import { DesktopHeader } from '@/components/worktree/WorktreeDetailSubComponents'; +import { toSkillTargetOption } from '@/components/skills/SkillTargetSelector'; +import { makeAppUpdateValue } from '@tests/helpers/app-update-context'; +import type { GitStatus, Worktree } from '@/types/models'; + +const mockUseAppUpdate = vi.fn(); +vi.mock('@/contexts/AppUpdateContext', async (importOriginal) => ({ + ...(await importOriginal()), + useAppUpdate: () => mockUseAppUpdate(), +})); + +class NoopResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +const base: GitStatus = { + currentBranch: 'feature/3435', + initialBranch: 'feature/3435', + isBranchMismatch: false, + commitHash: 'abc1234', + isDirty: false, +}; +const unknown: GitStatus = { ...base, statusUnknown: true }; +const dirty: GitStatus = { ...base, isDirty: true }; + +beforeEach(() => { + mockUseAppUpdate.mockReturnValue(makeAppUpdateValue()); + vi.stubGlobal('ResizeObserver', NoopResizeObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function renderStatusBar(gitStatus: GitStatus) { + return render( + + + + ); +} + +describe('GitCurrentStatusBar (Issue #3435)', () => { + it('shows an unknown badge instead of looking clean', () => { + renderStatusBar(unknown); + const badge = screen.getByTestId('git-status-unknown-badge'); + expect(badge).toHaveTextContent('worktree.git.currentStatus.statusUnknown'); + expect(screen.queryByTestId('git-status-dirty-badge')).toBeNull(); + }); + + it('clean: no unknown badge, no dirty badge (unchanged)', () => { + renderStatusBar(base); + expect(screen.queryByTestId('git-status-unknown-badge')).toBeNull(); + expect(screen.queryByTestId('git-status-dirty-badge')).toBeNull(); + }); + + it('dirty: dirty badge, no unknown badge (unchanged)', () => { + renderStatusBar(dirty); + expect(screen.getByTestId('git-status-dirty-badge')).toBeInTheDocument(); + expect(screen.queryByTestId('git-status-unknown-badge')).toBeNull(); + }); +}); + +describe('MobileHeader (Issue #3435)', () => { + it('marks the branch as unknown when status could not be read', () => { + render(); + expect(screen.getByTestId('mobile-git-status-unknown')).toHaveAttribute( + 'title', + 'worktree.git.statusUnknown' + ); + }); + + it('shows no unknown marker for a clean tree', () => { + render(); + expect(screen.getByTestId('mobile-branch-name')).toBeInTheDocument(); + expect(screen.queryByTestId('mobile-git-status-unknown')).toBeNull(); + }); +}); + +describe('DesktopHeader (Issue #3435)', () => { + const props = { + worktreeName: 'wt', + repositoryName: 'CommandMate', + status: 'idle' as const, + onInfoClick: vi.fn(), + onWorktreeStatusChange: vi.fn(), + worktreeStatus: 'in_progress' as const, + }; + + it('marks the branch as unknown when status could not be read', () => { + render(); + expect(screen.getByTestId('desktop-git-status-unknown')).toHaveAttribute( + 'title', + 'worktree.git.statusUnknown' + ); + }); + + it('shows no unknown marker for a clean tree', () => { + render(); + expect(screen.getByTestId('desktop-branch-name')).toBeInTheDocument(); + expect(screen.queryByTestId('desktop-git-status-unknown')).toBeNull(); + }); +}); + +describe('toSkillTargetOption (Issue #3435)', () => { + const worktree = { id: 'w', name: 'wt', repositoryName: 'r' } as Worktree; + + it('maps an unread status to dirty=null (unknown), not clean', () => { + expect(toSkillTargetOption({ ...worktree, gitStatus: unknown }).dirty).toBeNull(); + }); + + it('keeps clean / dirty unchanged', () => { + expect(toSkillTargetOption({ ...worktree, gitStatus: base }).dirty).toBe(false); + expect(toSkillTargetOption({ ...worktree, gitStatus: dirty }).dirty).toBe(true); + }); +}); diff --git a/tests/unit/lib/git/git-status-unknown-3435.test.ts b/tests/unit/lib/git/git-status-unknown-3435.test.ts new file mode 100644 index 000000000..2a5ab0c22 --- /dev/null +++ b/tests/unit/lib/git/git-status-unknown-3435.test.ts @@ -0,0 +1,119 @@ +/** + * Issue #3435: a failed `git status --porcelain` (typically the 1s read + * timeout under load) must not be reported as a clean working tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockExecFileAsync, mockLogger } = vi.hoisted(() => ({ + mockExecFileAsync: vi.fn(), + 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) }; +}); + +vi.mock('child_process', () => ({ execFile: vi.fn() })); +vi.mock('util', () => ({ promisify: () => mockExecFileAsync })); + +import { getGitStatus } from '@/lib/git/git-status'; +import { readSkillGitTargetState } from '@/lib/skills/preview-diff'; + +/** execFile rejects the way a timeout kill does. */ +function timeoutError(): Error { + return Object.assign(new Error('Command failed: git status --porcelain'), { + killed: true, + signal: 'SIGTERM', + code: null, + }); +} + +function stubGit(statusResult: { stdout: string } | Error, extra: Record = {}) { + mockExecFileAsync.mockImplementation(async (_file: string, args: string[]) => { + const joined = args.join(' '); + if (joined === 'status --porcelain') { + if (statusResult instanceof Error) throw statusResult; + return statusResult; + } + for (const [key, stdout] of Object.entries(extra)) { + if (joined.includes(key)) return { stdout }; + } + if (joined.includes('--abbrev-ref')) return { stdout: 'main\n' }; + if (joined.includes('--short HEAD')) return { stdout: 'abc1234\n' }; + return { stdout: '' }; + }); +} + +describe('getGitStatus when status cannot be read (Issue #3435)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports statusUnknown (not clean) when status --porcelain times out', async () => { + stubGit(timeoutError()); + + const status = await getGitStatus('/repo', 'main'); + + expect(status.statusUnknown).toBe(true); + // isDirty stays boolean for existing readers; it carries no information here. + expect(status.isDirty).toBe(false); + expect(status.currentBranch).toBe('main'); + }); + + it('keeps a clean tree byte-identical (no statusUnknown key)', async () => { + stubGit({ stdout: '' }); + + const status = await getGitStatus('/repo', 'main'); + + expect(status).toEqual({ + currentBranch: 'main', + initialBranch: 'main', + isBranchMismatch: false, + commitHash: 'abc1234', + isDirty: false, + }); + expect('statusUnknown' in status).toBe(false); + }); + + it('keeps a dirty tree as dirty (no statusUnknown key)', async () => { + stubGit({ stdout: ' M src/a.ts\n' }); + + const status = await getGitStatus('/repo', 'main'); + + expect(status.isDirty).toBe(true); + expect('statusUnknown' in status).toBe(false); + }); +}); + +describe('readSkillGitTargetState when status cannot be read (Issue #3435)', () => { + const sha = 'a'.repeat(40); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('treats a failed status as dirty (fail-safe), never as clean', async () => { + stubGit(timeoutError(), { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + + const state = await readSkillGitTargetState('/repo'); + + expect(state.dirty).toBe(true); + expect(state.headState).toBe('attached'); + }); + + it('keeps clean and dirty unchanged on success', async () => { + stubGit({ stdout: '' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + expect((await readSkillGitTargetState('/repo')).dirty).toBe(false); + + stubGit({ stdout: '?? new.ts\n' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` }); + expect((await readSkillGitTargetState('/repo')).dirty).toBe(true); + }); +});