diff --git a/changelog.d/3436.md b/changelog.d/3436.md new file mode 100644 index 000000000..559b283ce --- /dev/null +++ b/changelog.d/3436.md @@ -0,0 +1,2 @@ + +- **fix(git): ブランチ切替の dirty 確認が失敗・timeout したとき切替を止める** (#3436): `status --porcelain` が null(失敗・timeout)を返すと未コミット変更の上で checkout が進みえたため、GitTimeoutError(504)で拒否する。reset のデフォルトブランチ判定も HEAD を読めないときは拒否する。 diff --git a/src/lib/git/git-branches.ts b/src/lib/git/git-branches.ts index b6d212a40..40a81f69c 100644 --- a/src/lib/git/git-branches.ts +++ b/src/lib/git/git-branches.ts @@ -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'); } } diff --git a/src/lib/git/git-reset.ts b/src/lib/git/git-reset.ts index 00de6fdf1..27b741821 100644 --- a/src/lib/git/git-reset.ts +++ b/src/lib/git/git-reset.ts @@ -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'); @@ -38,7 +38,8 @@ export interface ResetOptions { */ async function isDefaultBranchForReset(worktreePath: string): Promise { 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); diff --git a/tests/unit/lib/git/git-branches.test.ts b/tests/unit/lib/git/git-branches.test.ts index 9223fbab8..032aa3f24 100644 --- a/tests/unit/lib/git/git-branches.test.ts +++ b/tests/unit/lib/git/git-branches.test.ts @@ -51,6 +51,7 @@ import { GitBranchNotMergedError, GitBranchCheckedOutElsewhereError, GitDirtyError, + GitTimeoutError, GitCurrentBranchError, GitDefaultBranchError, } from '@/lib/git/git-errors'; @@ -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();