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/3436.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(git): ブランチ切替の dirty 確認が失敗・timeout したとき切替を止める** (#3436): `status --porcelain` が null(失敗・timeout)を返すと未コミット変更の上で checkout が進みえたため、GitTimeoutError(504)で拒否する。reset のデフォルトブランチ判定も HEAD を読めないときは拒否する。
6 changes: 5 additions & 1 deletion src/lib/git/git-branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ export async function checkoutBranch(
// checked_out_elsewhere guard above.
if (!force) {
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
if (status !== null && status.length > 0) {
// Fail-closed: a failed/timed-out status (null) must not be read as clean.
if (status === null) {
throw new GitTimeoutError('Could not verify working tree state');
}
if (status.length > 0) {
throw new GitDirtyError('Working tree has uncommitted changes');
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/git/git-reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type ConflictResult,
} from './git-exec';
import { getDefaultBranch } from './git-default-branch';
import { GitResetDefaultBranchError } from './git-errors';
import { GitResetDefaultBranchError, GitTimeoutError } from './git-errors';

const logger = createLogger('git-reset');

Expand All @@ -38,7 +38,8 @@ export interface ResetOptions {
*/
async function isDefaultBranchForReset(worktreePath: string): Promise<boolean> {
const current = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
if (current === null) return false;
// Fail-closed: an unreadable HEAD must not be treated as "not the default branch".
if (current === null) throw new GitTimeoutError('Could not verify current branch');
const currentBranch = current.trim();

const def = await getDefaultBranch(worktreePath);
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/lib/git/git-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
GitBranchNotMergedError,
GitBranchCheckedOutElsewhereError,
GitDirtyError,
GitTimeoutError,
GitCurrentBranchError,
GitDefaultBranchError,
} from '@/lib/git/git-errors';
Expand Down Expand Up @@ -427,6 +428,38 @@ describe('checkoutBranch (Issue #781)', () => {
});
});

describe('checkoutBranch fail-closed status (Issue #3436)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(false);
});

it('does not checkout when status --porcelain fails (non-force)', async () => {
const calls: string[][] = [];
mockExecFileAsync.mockImplementation(async (_file: string, args: string[]) => {
calls.push(args);
if (args.join(' ').includes('status --porcelain')) throw new Error('boom');
return { stdout: '' };
});

await expect(checkoutBranch('/repo', { branch: 'feature/x' })).rejects.toBeInstanceOf(
GitTimeoutError
);
expect(calls.find((a) => a[0] === 'switch')).toBeUndefined();
});

it('force checkout does not consult status', async () => {
const calls: string[][] = [];
mockExecFileAsync.mockImplementation(async (_file: string, args: string[]) => {
calls.push(args);
return { stdout: '' };
});

await checkoutBranch('/repo', { branch: 'feature/x', force: true });
expect(calls.find((a) => a.includes('status'))).toBeUndefined();
});
});

describe('createBranch (Issue #781)', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
Loading