Improve preserved branch cleanup after squash merges (#6014)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-21 18:48:59 -07:00 committed by GitHub
parent 2ed4346c2d
commit 8228155e4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 650 additions and 66 deletions

View File

@ -398,6 +398,90 @@ branch refs/heads/main
expect(calls).toContain('git config --remove-section branch.feature/test')
})
it('deletes a squash-merged branch with branch-only merge commits via expected head', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
},
'git branch -d -- feature/test': {
error: new Error('branch delete failed'),
stderr: 'error: the branch feature/test is not fully merged'
},
'git config --get branch.feature/test.base': {
stdout: 'refs/remotes/origin/main\n'
},
'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': {
stdout: 'target123\n'
},
'git merge-tree --write-tree target123 refs/heads/feature/test': {
stdout: 'merged-tree\n'
},
'git rev-parse --verify --quiet target123^{tree}': {
stdout: 'target-tree\n'
},
'git rev-list --right-only --merges --count target123...refs/heads/feature/test': {
stdout: '1\n'
},
'git merge-base target123 refs/heads/feature/test': {
stdout: 'base123\n'
},
'git diff base123 refs/heads/feature/test': {
stdout: 'branch net diff\n'
},
'git patch-id --stable#1': {
stdout: 'patch123 0000000000000000000000000000000000000000\n'
},
'git rev-list --ancestry-path --max-count=201 base123..target123': {
stdout: 'squash123\n'
},
'git show --format= squash123': {
stdout: 'squash diff\n'
},
'git patch-id --stable#2': {
stdout: 'patch123 squash123\n'
},
'git merge-tree --write-tree squash123 refs/heads/feature/test': {
stdout: 'squash-tree\n'
},
'git rev-parse --verify --quiet squash123^{tree}': {
stdout: 'squash-tree\n'
}
})
await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({})
const calls = getGitCalls()
expect(calls).toContain('git update-ref -d refs/heads/feature/test def456')
expect(calls).toContain('git config --remove-section branch.feature/test')
expect(gitExecFileAsyncMock.mock.calls).toContainEqual([
['patch-id', '--stable'],
{ cwd: '/repo', stdin: 'branch net diff\n' }
])
expect(gitExecFileAsyncMock.mock.calls).toContainEqual([
['patch-id', '--stable'],
{ cwd: '/repo', stdin: 'squash diff\n' }
])
})
it('refreshes the saved remote base before deleting a safe-delete-rejected branch', async () => {
mockGitCommands({
'git worktree list --porcelain -z': {

View File

@ -250,6 +250,7 @@ type GitExecOptions = {
encoding?: BufferEncoding | 'buffer'
maxBuffer?: number
timeout?: number
stdin?: string
env?: NodeJS.ProcessEnv
signal?: AbortSignal
wslDistro?: string
@ -313,6 +314,7 @@ function killSpawnedCommandTree(child: ChildProcess): void {
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
timeout?: number
stdin?: string
}
function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer {
@ -402,6 +404,12 @@ function execFileCapture(
return
}
child.once('error', (error) => finish(error))
if (options.stdin !== undefined) {
child.stdin?.end(options.stdin)
}
// Why: Node's native execFile timeout waits for the child to exit after
// signaling it. Some CLIs ignore that signal, so reject the UI operation
// on our own timer and kill the child only as best effort.
@ -740,6 +748,7 @@ export async function gitExecFileAsync(
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
stdin: options.stdin,
// Why: never let a git read-path call block on an interactive prompt
// (issue #5308) — fail fast instead of hanging the runtime.
env: policy.env,

View File

@ -944,7 +944,11 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure(
branchHead: string,
options: GitWorktreeExecOptions = {}
): Promise<boolean> {
const runGit = (args: string[]) => gitExecFileAsync(args, gitExecOptions(repoPath, options))
const runGit = (args: string[], execOptions?: { stdin?: string }) =>
gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {})
})
const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName)
await refreshBranchCleanupTargetRefs(runGit, targetRefs)
// Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch

View File

@ -77,6 +77,90 @@ describe('removeWorktreeOp branch cleanup', () => {
)
})
it('deletes a squash-merged SSH branch with branch-only merge commits via expected head', async () => {
let zListCount = 0
const git = vi.fn<GitExec>(async (args, _cwd, opts) => {
if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') {
return { stdout: '/repo/.git\n', stderr: '' }
}
if (args[0] === 'worktree' && args[1] === 'list' && args.includes('-z')) {
zListCount += 1
return {
stdout:
zListCount === 1
? worktreeList(
{ path: '/repo', branch: 'main' },
{ path: '/repo-feature', branch: 'feature/test' }
)
: worktreeList({ path: '/repo', branch: 'main' }),
stderr: ''
}
}
if (args[0] === 'worktree' && args[1] === 'list') {
return { stdout: worktreeList({ path: '/repo', branch: 'main' }), stderr: '' }
}
if (args[0] === 'branch' && args[1] === '-d') {
throw new Error('error: the branch feature/test is not fully merged')
}
if (args[0] === 'config' && args[1] === '--get') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: 'target123\n', stderr: '' }
}
if (args[0] === 'merge-tree') {
return {
stdout: args[2] === 'squash123' ? 'squash-tree\n' : 'merged-tree\n',
stderr: ''
}
}
if (args[0] === 'rev-parse' && args.includes('target123^{tree}')) {
return { stdout: 'target-tree\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('squash123^{tree}')) {
return { stdout: 'squash-tree\n', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('--right-only')) {
return { stdout: '1\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: 'base123\n', stderr: '' }
}
if (args[0] === 'diff') {
return { stdout: 'branch net diff\n', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('--ancestry-path')) {
return { stdout: 'squash123\n', stderr: '' }
}
if (args[0] === 'show') {
return { stdout: 'squash diff\n', stderr: '' }
}
if (args[0] === 'patch-id' && opts?.stdin === 'branch net diff\n') {
return {
stdout: 'patch123 0000000000000000000000000000000000000000\n',
stderr: ''
}
}
if (args[0] === 'patch-id' && opts?.stdin === 'squash diff\n') {
return { stdout: 'patch123 squash123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({})
expect(git).toHaveBeenCalledWith(
['update-ref', '-d', 'refs/heads/feature/test', '1'],
expect.any(String)
)
expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), {
stdin: 'branch net diff\n'
})
expect(git).toHaveBeenCalledWith(['patch-id', '--stable'], expect.any(String), {
stdin: 'squash diff\n'
})
})
it('refreshes the saved remote base before deleting a safe-delete-rejected SSH branch', async () => {
const calls: { args: string[]; cwd: string }[] = []
let zListCount = 0

View File

@ -12,7 +12,8 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure(
branchName: string,
branchHead: string
): Promise<boolean> {
const runGit = (args: string[]) => git(args, repoPath)
const runGit = (args: string[], options?: { stdin?: string }) =>
options ? git(args, repoPath, options) : git(args, repoPath)
const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName)
await refreshBranchCleanupTargetRefs(runGit, targetRefs)
// Why: SSH worktrees hit the same squash-merge shape as local worktrees.

View File

@ -16,7 +16,7 @@ import { readWorkingDiffFile } from './git-working-file-read'
export type GitExec = (
args: string[],
cwd: string,
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean }
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string }
) => Promise<{ stdout: string; stderr: string }>
export type GitBufferExec = (args: string[], cwd: string) => Promise<Buffer>

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC
protocol surface so local and SSH git behavior stay in one dispatch table. */
import { execFile, spawn } from 'child_process'
import { execFile, spawn, type ExecFileOptions } from 'child_process'
import { promisify } from 'util'
import * as path from 'path'
import type { RelayDispatcher, RequestContext } from './dispatcher'
@ -51,6 +51,41 @@ const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
const BULK_CHUNK_SIZE = 100
function execFileWithStdin(
command: string,
args: string[],
options: ExecFileOptions,
stdin: string
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (
error: Error | null,
stdout: string | Buffer = '',
stderr: string | Buffer = ''
): void => {
if (settled) {
return
}
settled = true
if (error) {
reject(Object.assign(error, { stdout, stderr }))
return
}
resolve({ stdout: String(stdout), stderr: String(stderr) })
}
const child = execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
finish(error, stdout, stderr)
return
}
finish(null, stdout, stderr)
})
child.once('error', (error) => finish(error))
child.stdin?.end(stdin)
})
}
export class GitHandler {
private dispatcher: RelayDispatcher
@ -114,6 +149,7 @@ export class GitHandler {
disableOptionalLocks?: boolean
signal?: AbortSignal
nonInteractive?: boolean
stdin?: string
}
): Promise<{ stdout: string; stderr: string }> {
const env = buildRelayCommandEnv()
@ -126,13 +162,18 @@ export class GitHandler {
env.SSH_ASKPASS = ''
env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes'
}
return execFileAsync('git', args, {
const execOptions = {
cwd: expandTilde(cwd),
env,
encoding: 'utf-8',
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER,
signal: opts?.signal
})
} satisfies ExecFileOptions
if (opts?.stdin !== undefined) {
return execFileWithStdin('git', args, execOptions, opts.stdin)
}
const { stdout, stderr } = await execFileAsync('git', args, execOptions)
return { stdout: String(stdout), stderr: String(stderr) }
}
private async gitBuffer(args: string[], cwd: string): Promise<Buffer> {

View File

@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { RemoveWorktreeResult } from '../../../../shared/types'
import { showPreservedBranchToast } from './preserved-branch-toast'
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
dismiss: vi.fn()
}
}))
const mountedRoots: Root[] = []
function renderToastBody(): HTMLElement {
const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]
?.description as React.ReactElement
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
act(() => {
root.render(description)
})
return container
}
function clickButton(container: HTMLElement, label: string): void {
const button = [...container.querySelectorAll('button')].find(
(el) => el.textContent?.trim() === label
)
if (!button) {
throw new Error(`button "${label}" not found`)
}
act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
afterEach(() => {
mountedRoots.splice(0).forEach((root) => act(() => root.unmount()))
document.body.innerHTML = ''
vi.clearAllMocks()
})
describe('showPreservedBranchToast', () => {
it('renders the branch recovery action below the long description', () => {
const onForceDelete = vi.fn()
const result: RemoveWorktreeResult = {
preservedBranch: {
branchName: 'feat/notes-send-any-running-agent',
head: 'abc123'
}
}
showPreservedBranchToast(
result,
{
displayName: 'Send review notes to any running agent of a worktree',
isMainWorktree: false
},
onForceDelete
)
const body = renderToastBody()
expect(toast.warning).toHaveBeenCalledWith(
'Worktree deleted, branch kept',
expect.objectContaining({
id: 'preserved-branch:feat/notes-send-any-running-agent:abc123',
dismissible: true,
duration: Infinity
})
)
expect(body.textContent).toContain('feat/notes-send-any-running-agent')
expect(body.textContent).toContain('Send review notes to any running agent of a worktree')
clickButton(body, 'Force Delete Branch')
expect(onForceDelete).toHaveBeenCalledWith('feat/notes-send-any-running-agent', 'abc123')
expect(toast.dismiss).toHaveBeenCalledWith(
'preserved-branch:feat/notes-send-any-running-agent:abc123'
)
})
it('does not show the force-delete action without the preserved head', () => {
const result: RemoveWorktreeResult = {
preservedBranch: {
branchName: 'feature/test'
}
}
showPreservedBranchToast(result, undefined, vi.fn())
const body = renderToastBody()
expect(body.textContent).not.toContain('Force Delete Branch')
expect(toast.warning).toHaveBeenCalledWith(
'Worktree deleted, branch kept',
expect.not.objectContaining({ duration: Infinity })
)
})
})

View File

@ -0,0 +1,118 @@
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { RemoveWorktreeResult, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
type PreservedBranchWorktree = Pick<Worktree, 'displayName' | 'isMainWorktree'>
type PreservedBranchToastBodyProps = {
description: string
forceDeleteLabel: string | undefined
onForceDelete: (() => void) | undefined
}
function preservedBranchToastId(branchName: string, expectedHead: string | undefined): string {
return `preserved-branch:${branchName}:${expectedHead ?? 'unknown'}`
}
function getPreservedBranchTitle(isWorkspace: boolean): string {
return isWorkspace
? translate('auto.store.slices.worktrees.5366d13eec', 'Workspace deleted, branch kept')
: translate('auto.store.slices.worktrees.2e17f825d4', 'Worktree deleted, branch kept')
}
function getPreservedBranchDescription(
branch: string,
targetName: string | undefined,
isWorkspace: boolean
): string {
if (!targetName) {
return translate(
'auto.store.slices.worktrees.78e08cd877',
'Git could not safely delete branch "{{value0}}", so Orca kept it to avoid losing local commits.',
{ value0: branch }
)
}
return isWorkspace
? translate(
'auto.store.slices.worktrees.3b57982bf6',
'Git could not safely delete branch "{{value0}}" after deleting workspace "{{value1}}", so Orca kept it to avoid losing local commits.',
{ value0: branch, value1: targetName }
)
: translate(
'auto.store.slices.worktrees.81f13f48d2',
'Git could not safely delete branch "{{value0}}" after deleting worktree "{{value1}}", so Orca kept it to avoid losing local commits.',
{ value0: branch, value1: targetName }
)
}
// Why: Sonner's native action row pinches long branch/worktree names into a
// narrow column. Keep the native toast frame, but give the body its own footer.
function PreservedBranchToastBody({
description,
forceDeleteLabel,
onForceDelete
}: PreservedBranchToastBodyProps): React.JSX.Element {
return (
<div className="flex w-[300px] max-w-[calc(100vw-96px)] flex-col gap-3">
<p className="min-w-0 break-words text-sm leading-5 text-popover-foreground/80">
{description}
</p>
{forceDeleteLabel && onForceDelete ? (
<div className="flex min-w-0 overflow-hidden">
<Button
type="button"
variant="default"
size="sm"
className="w-full min-w-0"
onClick={onForceDelete}
>
<Trash2 className="size-3.5" />
<span className="truncate">{forceDeleteLabel}</span>
</Button>
</div>
) : null}
</div>
)
}
export function showPreservedBranchToast(
result: RemoveWorktreeResult | undefined,
worktree: PreservedBranchWorktree | undefined,
onForceDelete: (branchName: string, expectedHead: string) => void
): void {
const preservedBranch = result?.preservedBranch
const branch = preservedBranch?.branchName
if (!branch) {
return
}
const isWorkspace = worktree?.isMainWorktree === true
const targetName = worktree?.displayName?.trim()
const expectedHead = preservedBranch.head
const toastId = preservedBranchToastId(branch, expectedHead)
const forceDeleteLabel = expectedHead
? translate('auto.store.slices.worktrees.e50495aae6', 'Force Delete Branch')
: undefined
const description = getPreservedBranchDescription(branch, targetName, isWorkspace)
const forceDelete = expectedHead
? (): void => {
onForceDelete(branch, expectedHead)
toast.dismiss(toastId)
}
: undefined
toast.warning(getPreservedBranchTitle(isWorkspace), {
id: toastId,
description: (
<PreservedBranchToastBody
description={description}
forceDeleteLabel={forceDeleteLabel}
onForceDelete={forceDelete}
/>
),
dismissible: true,
...(expectedHead ? { duration: Infinity } : {})
})
}

View File

@ -199,27 +199,10 @@ describe('removeWorktree cascade', () => {
preservedBranch: { branchName: 'feature/test', head: 'def456' }
})
expect(toast.warning).toHaveBeenCalledWith('Worktree deleted, branch kept', {
description:
'Git could not safely delete branch "feature/test" after deleting worktree "Review cleanup", so Orca kept it to avoid losing local commits.',
action: {
label: 'Force Delete Branch',
onClick: expect.any(Function)
}
})
const action = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.action as
| { onClick?: () => void }
| undefined
action?.onClick?.()
await vi.waitFor(() => {
expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({
worktreeId,
branchName: 'feature/test',
expectedHead: 'def456'
})
})
expect(toast.success).toHaveBeenCalledWith('Local branch deleted', {
description: 'Deleted "feature/test".'
id: 'preserved-branch:feature/test:def456',
description: expect.anything(),
dismissible: true,
duration: Infinity
})
})

View File

@ -40,6 +40,7 @@ import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualized
import { branchName } from '@/lib/git-utils'
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import { translate } from '@/i18n/i18n'
import {
getRepoExecutionHostId,
@ -149,42 +150,6 @@ function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefi
)
}
function showPreservedBranchToast(
result: RemoveWorktreeResult | undefined,
worktree: Pick<Worktree, 'displayName' | 'isMainWorktree'> | undefined,
onForceDelete: (branchName: string, expectedHead: string) => void
): void {
const preservedBranch = result?.preservedBranch
const branch = preservedBranch?.branchName
if (!branch) {
return
}
const targetTitle = worktree?.isMainWorktree ? 'Workspace' : 'Worktree'
const targetLabel = targetTitle.toLowerCase()
const targetName = worktree?.displayName?.trim()
const deletedTarget = targetName ? ` after deleting ${targetLabel} "${targetName}"` : ''
const expectedHead = preservedBranch.head
const action = expectedHead
? {
label: translate('auto.store.slices.worktrees.e50495aae6', 'Force Delete Branch'),
onClick: () => onForceDelete(branch, expectedHead)
}
: undefined
toast.warning(
translate('auto.store.slices.worktrees.4e6496f3d2', '{{value0}} deleted, branch kept', {
value0: targetTitle
}),
{
description: translate(
'auto.store.slices.worktrees.d1d78a7baa',
'Git could not safely delete branch "{{value0}}"{{value1}}, so Orca kept it to avoid losing local commits.',
{ value0: branch, value1: deletedTarget }
),
...(action ? { action } : {})
}
)
}
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) {
return true

View File

@ -1,5 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
import { refreshBranchCleanupTargetRefs, type GitBranchCleanupExec } from './git-branch-cleanup'
import {
branchHasNoUnmergedChangesOnAnyTarget,
refreshBranchCleanupTargetRefs,
type GitBranchCleanupExec
} from './git-branch-cleanup'
function baseProofResponses(
responses: Partial<Record<string, string | Error>> = {}
): GitBranchCleanupExec {
return vi.fn<GitBranchCleanupExec>(async (args, options) => {
if (args[0] === 'patch-id' && options?.stdin === 'branch-diff') {
const response = responses.branchPatchId ?? 'branch-patch 0000000\n'
if (response instanceof Error) {
throw response
}
return { stdout: response }
}
if (args[0] === 'patch-id' && options?.stdin === 'squash-diff') {
const response = responses.squashPatchId ?? 'branch-patch squash\n'
if (response instanceof Error) {
throw response
}
return { stdout: response }
}
const key = args.join(' ')
const response =
responses[key] ??
{
'rev-parse --verify --quiet refs/remotes/origin/main^{commit}': 'target\n',
'merge-tree --write-tree target refs/heads/feature/test': 'merged-tree\n',
'rev-parse --verify --quiet target^{tree}': 'target-tree\n',
'rev-list --right-only --merges --count target...refs/heads/feature/test': '1\n',
'merge-base target refs/heads/feature/test': 'base\n',
'diff base refs/heads/feature/test': 'branch-diff',
'rev-list --ancestry-path --max-count=201 base..target': 'squash\n',
'show --format= squash': 'squash-diff',
'merge-tree --write-tree squash refs/heads/feature/test': 'squash-tree\n',
'rev-parse --verify --quiet squash^{tree}': 'squash-tree\n'
}[key] ??
''
if (response instanceof Error) {
throw response
}
return { stdout: response }
})
}
describe('refreshBranchCleanupTargetRefs', () => {
it('fetches each remote-tracking target remote once and prefers slashed remote names', async () => {
@ -43,3 +88,56 @@ describe('refreshBranchCleanupTargetRefs', () => {
).resolves.toBeUndefined()
})
})
describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
it('accepts a branch with merge commits when a target squash commit matches its net patch', async () => {
const runGit = baseProofResponses()
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
).resolves.toBe(true)
expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'branch-diff' })
expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'squash-diff' })
expect(runGit).not.toHaveBeenCalledWith(['cherry', '-v', 'target', 'refs/heads/feature/test'])
})
it('preserves a branch with merge commits when no target squash commit matches', async () => {
const runGit = baseProofResponses({ squashPatchId: 'other-patch squash\n' })
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
).resolves.toBe(false)
})
it('preserves when a matching squash candidate still changes after merging the branch', async () => {
const runGit = baseProofResponses({
'merge-tree --write-tree squash refs/heads/feature/test': 'different-tree\n'
})
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
).resolves.toBe(false)
})
it('preserves when the target squash scan exceeds the cap', async () => {
const commits = Array.from({ length: 201 }, (_, index) => `commit-${index}`).join('\n')
const runGit = baseProofResponses({
'rev-list --ancestry-path --max-count=201 base..target': `${commits}\n`
})
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
).resolves.toBe(false)
expect(runGit).not.toHaveBeenCalledWith(['show', '--format=', 'commit-0'])
})
it('preserves when patch-id cannot be computed', async () => {
const runGit = baseProofResponses({ branchPatchId: new Error('patch-id failed') })
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
).resolves.toBe(false)
})
})

View File

@ -1,12 +1,30 @@
export type GitBranchCleanupExec = (argv: string[]) => Promise<{ stdout: string }>
export type GitBranchCleanupExec = (
argv: string[],
options?: { stdin?: string }
) => Promise<{ stdout: string }>
const SQUASH_PATCH_SCAN_LIMIT = 200
async function readOptionalGitStdout(
runGit: GitBranchCleanupExec,
argv: string[],
options?: { stdin?: string }
): Promise<string | null> {
try {
const { stdout } = await runGit(argv, options)
return stdout.trim() || null
} catch {
return null
}
}
async function readOptionalGitRawStdout(
runGit: GitBranchCleanupExec,
argv: string[]
): Promise<string | null> {
try {
const { stdout } = await runGit(argv)
return stdout.trim() || null
return stdout || null
} catch {
return null
}
@ -117,6 +135,77 @@ async function branchOnlyCommitsArePatchEquivalent(
return lines.every((line) => line.startsWith('-'))
}
function parsePatchId(stdout: string | null): string | null {
const line = stdout
?.split(/\r?\n/)
.map((candidate) => candidate.trim())
.find(Boolean)
const patchId = line?.split(/\s+/)[0]
return patchId || null
}
async function computeStablePatchId(
runGit: GitBranchCleanupExec,
patchText: string | null
): Promise<string | null> {
if (!patchText) {
return null
}
return parsePatchId(
await readOptionalGitStdout(runGit, ['patch-id', '--stable'], { stdin: patchText })
)
}
async function branchNetPatchMatchesTargetSquashCommit(
runGit: GitBranchCleanupExec,
targetOid: string,
branchRef: string
): Promise<boolean> {
const mergeBase = await readOptionalGitStdout(runGit, ['merge-base', targetOid, branchRef])
if (!mergeBase) {
return false
}
const branchPatchId = await computeStablePatchId(
runGit,
await readOptionalGitRawStdout(runGit, ['diff', mergeBase, branchRef])
)
if (!branchPatchId) {
return false
}
const commits = (
await readOptionalGitStdout(runGit, [
'rev-list',
'--ancestry-path',
`--max-count=${SQUASH_PATCH_SCAN_LIMIT + 1}`,
`${mergeBase}..${targetOid}`
])
)
?.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (!commits?.length || commits.length > SQUASH_PATCH_SCAN_LIMIT) {
return false
}
for (const commitOid of commits) {
const commitPatchId = await computeStablePatchId(
runGit,
await readOptionalGitRawStdout(runGit, ['show', '--format=', commitOid])
)
// Why: a matching patch-id identifies a possible squash commit, but the
// tree merge proves the branch contributes no additional changes there.
if (
commitPatchId === branchPatchId &&
(await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef))
) {
return true
}
}
return false
}
export async function branchHasNoUnmergedChangesOnAnyTarget(
runGit: GitBranchCleanupExec,
branchName: string,
@ -133,6 +222,9 @@ export async function branchHasNoUnmergedChangesOnAnyTarget(
return true
}
if (await hasBranchOnlyMergeCommits(runGit, targetOid, branchRef)) {
if (await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef)) {
return true
}
continue
}
if (await branchOnlyCommitsArePatchEquivalent(runGit, targetOid, branchRef)) {