Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/3435.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(git): git status を読めなかった worktree を「変更なし」と表示しない** (#3435): `getGitStatus` は `git status --porcelain` の失敗(負荷時の 1 秒 timeout)を clean と報告していた。失敗時は `statusUnknown: true` を返し、Git ペイン・デスクトップ/モバイルのヘッダー・スキルの対象選択は「変更の有無が不明」と表示する。スキルのプレビューは不明を dirty 側(警告を出す側)に倒す
8 changes: 4 additions & 4 deletions docs/module-reference.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions locales/en/worktree.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down
3 changes: 3 additions & 0 deletions locales/ja/worktree.json
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,7 @@
},
"git": {
"uncommittedChanges": "未コミットの変更",
"statusUnknown": "変更の有無を確認できませんでした(git status が失敗)",
"askAi": "AI に質問",
"askAiTooltip": "現在の状況を AI チャットに下書きします(自動送信はされません)",
"advancedOperations": "高度な操作",
Expand Down Expand Up @@ -972,6 +973,8 @@
"refreshing": "リモートを確認中...",
"loading": "Git ステータスを読み込み中...",
"uncommitted": "未コミット",
"statusUnknown": "変更の有無が不明",
"statusUnknownTooltip": "git status を読めなかったため、未コミットの変更があるかどうか分かりません。更新すると再取得します。",
"aheadTooltip": "最後に fetch した時点のアップストリームより先行しているコミット数(リモートの最新状態とは異なる場合があります)",
"behindTooltip": "最後に fetch した時点のアップストリームより遅れているコミット数(リモートの最新状態とは異なる場合があります)",
"lastFetch": "最終 fetch: {relative}",
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/worktrees/[id]/git/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/worktrees/[id]/git/reset/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/components/mobile/MobileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ export function MobileHeader({
{gitStatus.isDirty && (
<span className="text-warning" title={tWorktree('git.uncommittedChanges')}>*</span>
)}
{gitStatus.statusUnknown && (
<span
className="text-muted-foreground"
title={tWorktree('git.statusUnknown')}
data-testid="mobile-git-status-unknown"
>
?
</span>
)}
</>
)}
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/skills/SkillTargetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
10 changes: 10 additions & 0 deletions src/components/worktree/WorktreeDetailSubComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1240,6 +1241,15 @@ export const DesktopHeader = memo(function DesktopHeader({
{gitStatus.isDirty && (
<span className="text-warning" title={tWorktree('git.uncommittedChanges')}>*</span>
)}
{gitStatus.statusUnknown && (
<span
className="text-muted-foreground"
title={tWorktree('git.statusUnknown')}
data-testid="desktop-git-status-unknown"
>
?
</span>
)}
</>
)}
{truncatedDescription && (
Expand Down
11 changes: 11 additions & 0 deletions src/components/worktree/git/panels/GitCurrentStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ export const GitCurrentStatusBar = memo(function GitCurrentStatusBar({
{gitStatus.currentBranch}
</span>

{/* Issue #3435: status could not be read — never shown as clean */}
{gitStatus.statusUnknown && (
<span
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground border border-border"
data-testid="git-status-unknown-badge"
title={t('git.currentStatus.statusUnknownTooltip')}
>
{t('git.currentStatus.statusUnknown')}
</span>
)}

{/* Dirty badge */}
{gitStatus.isDirty && (
<span
Expand Down
9 changes: 8 additions & 1 deletion src/lib/git/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const logger = createLogger('git-status');
* - Uses execFile for security (no shell interpretation)
* - 1 second timeout to prevent UI blocking
* - Returns (unknown) on error without exposing details to client
* - A failed `status --porcelain` yields `statusUnknown: true` (never "clean", Issue #3435)
*/
export async function getGitStatus(
worktreePath: string,
Expand All @@ -47,7 +48,11 @@ export async function getGitStatus(
}

const commitHash = commitOutput ?? '(unknown)';
const isDirty = statusOutput !== null && statusOutput.length > 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:
Expand All @@ -66,6 +71,8 @@ export async function getGitStatus(
isBranchMismatch,
commitHash,
isDirty,
// Only present when unknown, so successful payloads stay byte-identical.
...(statusUnknown ? { statusUnknown: true } : {}),
};
}

Expand Down
7 changes: 5 additions & 2 deletions src/lib/skills/preview-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/types/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down
13 changes: 12 additions & 1 deletion src/types/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/components/git-status-unknown-3435.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/contexts/AppUpdateContext')>()),
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(
<GitPaneProvider value={{ isMobile: false, onDiffSelect: vi.fn() }}>
<GitCurrentStatusBar
gitStatus={gitStatus}
statusLoading={false}
statusError={null}
onRefresh={vi.fn()}
/>
</GitPaneProvider>
);
}

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(<MobileHeader worktreeName="wt" status="idle" gitStatus={unknown} />);
expect(screen.getByTestId('mobile-git-status-unknown')).toHaveAttribute(
'title',
'worktree.git.statusUnknown'
);
});

it('shows no unknown marker for a clean tree', () => {
render(<MobileHeader worktreeName="wt" status="idle" gitStatus={base} />);
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(<DesktopHeader {...props} gitStatus={unknown} />);
expect(screen.getByTestId('desktop-git-status-unknown')).toHaveAttribute(
'title',
'worktree.git.statusUnknown'
);
});

it('shows no unknown marker for a clean tree', () => {
render(<DesktopHeader {...props} gitStatus={base} />);
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);
});
});
Loading
Loading