mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
fix: explain merged workspace cleanup and keep visible reviews fresh (#41200)
* fix(github): keep the selected workspace's PR on its one-minute refresh Follow-up reads inherited the priority of whatever entry ran last. A workspace selected while its cached PR was still fresh never got a foreground entry, so its follow-ups competed with every visible card for the shared list budget and were read about every five minutes with 25 visible open PRs. Previously selected workspaces kept priority 80 forever. Follow-ups now take foreground priority only while their workspace is selected. * feat(source-control): say when a merged workspace is safe to delete Source Control's committed list is a merge-base diff against the base ref. A squash or rebase merge copies the commits onto the base with new IDs, so the list looks the same after the merge as before it. A notice now states whether the merged review contains everything: HEAD is the review's head (or the provider confirmed it is contained) and nothing is uncommitted. * fix(sidebar): keep PR status visible on sleeping workspaces The sleeping moon replaced the review glyph, hiding whether a sleeping workspace's PR was open, failing or merged. Review status now keeps the lane; the dimmed row already shows sleep and the accessible label names both. Co-authored-by: Tobias Diez <code@tobiasdiez.com> Co-authored-by: alpenshtokk <wawesandways@gmail.com> * fix(source-control): wait for a git status read before judging a merged workspace Before the first status read the change count is zero and HEAD may be stale, so the notice could call a dirty workspace safe to delete. Use the HEAD from the latest successful status read and say nothing until one completes. * chore(i18n): ship the merged-review plural copy in the runtime English catalog * fix: satisfy design-system, cast and flaky-test gates for the merged notice Use the callout-important token instead of raw purple, let Button own its type size, build the fixture worktree with the shared factory, pin the test clock and give the long simulated-time tests a reason for their timeout. * fix(github): align selected-refresh regression with feature ownership * refactor(github): give refresh queue aliases their concrete owner * fix(source-control): preserve cleanup warnings and reserve refresh fairness * fix(source-control): describe missing merged-head proof as unverified --------- Co-authored-by: Tobias Diez <code@tobiasdiez.com> Co-authored-by: alpenshtokk <wawesandways@gmail.com>
This commit is contained in:
co-authored by
Tobias Diez
alpenshtokk
parent
a1667c0297
commit
30fa6b4398
@@ -0,0 +1,73 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { PRWatchJobRunner } from './pr-refresh-watch-jobs'
|
||||
import { PRRefreshRetryState } from './pr-refresh-retry-state'
|
||||
import { PRRefreshPacing } from './pr-refresh-pacing'
|
||||
import { PRRefreshQueue, type PRRefreshQueueEntry } from './pr-refresh-queue'
|
||||
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
|
||||
|
||||
it('yields a selected list read to another ready review before restoring its priority', () => {
|
||||
const pacing = new PRRefreshPacing()
|
||||
const queue = new PRRefreshQueue(() => {})
|
||||
const entry = (key: string, selected: boolean, priority: number): PRRefreshQueueEntry => ({
|
||||
key,
|
||||
candidate: makeCandidate({ cacheKey: key, isSelected: selected }),
|
||||
aliases: new Map(),
|
||||
reason: 'visible',
|
||||
priority,
|
||||
dueAt: 0,
|
||||
queuedAt: 0
|
||||
})
|
||||
const selected = entry('selected', true, 80)
|
||||
const other = entry('other', false, 40)
|
||||
queue.set(selected.key, selected)
|
||||
queue.set(other.key, other)
|
||||
const next = () => queue.ordered((a, b) => pacing.order(a, b))[0].key
|
||||
expect(next()).toBe('selected')
|
||||
pacing.noteBackgroundStart(selected)
|
||||
expect(next()).toBe('other')
|
||||
pacing.noteBackgroundStart(other)
|
||||
expect(next()).toBe('selected')
|
||||
pacing.noteBackgroundStart(selected)
|
||||
queue.delete(other.key)
|
||||
expect(next()).toBe('selected')
|
||||
})
|
||||
|
||||
it('preserves a visible list turn when selected reads share the budget with aged watches', () => {
|
||||
const pacing = new PRRefreshPacing()
|
||||
const queue = new PRRefreshQueue(() => {})
|
||||
const watches = new PRWatchJobRunner(queue, new PRRefreshRetryState(), { schedule() {} })
|
||||
const entry = (key: string, selected: boolean): PRRefreshQueueEntry => ({
|
||||
key,
|
||||
candidate: makeCandidate({ isSelected: selected }),
|
||||
aliases: new Map(),
|
||||
reason: 'visible',
|
||||
priority: selected ? 80 : 40,
|
||||
dueAt: 0,
|
||||
queuedAt: 0
|
||||
})
|
||||
const selected = entry('selected', true)
|
||||
const other = entry('other', false)
|
||||
const watch: PRRefreshQueueEntry = {
|
||||
...entry('watch', false),
|
||||
reason: 'watch',
|
||||
watch: { prKey: 'pr' }
|
||||
}
|
||||
for (const item of [selected, other, watch]) {
|
||||
queue.set(item.key, item)
|
||||
}
|
||||
const next = () => {
|
||||
watches.age(queue.values(), 120_000)
|
||||
return queue.ordered((a, b) => pacing.order(a, b))[0].key
|
||||
}
|
||||
const started = (item: PRRefreshQueueEntry) => {
|
||||
pacing.noteBackgroundStart(item)
|
||||
watches.noteBudgetedStart(item)
|
||||
}
|
||||
expect(next()).toBe('selected')
|
||||
started(selected)
|
||||
expect(next()).toBe('watch')
|
||||
started(watch)
|
||||
expect(next()).toBe('selected')
|
||||
started(selected)
|
||||
expect(next()).toBe('other')
|
||||
})
|
||||
@@ -167,6 +167,19 @@ export function visibleCandidateAfterOutcome(
|
||||
}
|
||||
}
|
||||
|
||||
const SELECTED_VISIBLE_PRIORITY = 80
|
||||
const LIST_VISIBLE_PRIORITY = 40
|
||||
|
||||
/** The selected workspace's follow-up wins the next list-budget slot; a deselected one rejoins list order. */
|
||||
export function visibleFollowUpPriority(
|
||||
candidate: GitHubPRRefreshCandidate,
|
||||
priority: number
|
||||
): number {
|
||||
return candidate.isSelected === true
|
||||
? Math.max(priority, SELECTED_VISIBLE_PRIORITY)
|
||||
: Math.min(priority, LIST_VISIBLE_PRIORITY)
|
||||
}
|
||||
|
||||
export function hasStaleHead(candidate: GitHubPRRefreshCandidate): boolean {
|
||||
return (
|
||||
candidate.currentHeadOid != null &&
|
||||
|
||||
@@ -19,6 +19,7 @@ export class PRRefreshPacing {
|
||||
private readonly backgroundStarts: number[] = []
|
||||
private readonly activeStartsByScope = new Map<string, number[]>()
|
||||
private lastBackgroundStartAt = 0
|
||||
private lastBudgetedStartWasSelected = false
|
||||
|
||||
clearActiveBurstWindow(windowId: number): void {
|
||||
const windowPrefix = `${windowId}::`
|
||||
@@ -29,7 +30,9 @@ export class PRRefreshPacing {
|
||||
}
|
||||
}
|
||||
|
||||
noteBackgroundStart(): void {
|
||||
noteBackgroundStart(entry: PRRefreshQueueEntry): void {
|
||||
this.lastBudgetedStartWasSelected =
|
||||
entry.reason === 'visible' && entry.candidate.isSelected === true
|
||||
const now = Date.now()
|
||||
this.lastBackgroundStartAt = now
|
||||
this.backgroundStarts.push(now)
|
||||
@@ -50,7 +53,11 @@ export class PRRefreshPacing {
|
||||
return Math.max(spacingDelay, windowDelay)
|
||||
}
|
||||
|
||||
activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number {
|
||||
order(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number {
|
||||
const priority = this.priority(b) - this.priority(a)
|
||||
if (priority !== 0) {
|
||||
return priority
|
||||
}
|
||||
if (!usesActiveRefreshPacing(a) || !usesActiveRefreshPacing(b)) {
|
||||
return 0
|
||||
}
|
||||
@@ -60,6 +67,16 @@ export class PRRefreshPacing {
|
||||
return b.queuedAt - a.queuedAt
|
||||
}
|
||||
|
||||
private priority(entry: PRRefreshQueueEntry): number {
|
||||
// Reserve the next budgeted slot for other ready reviews after a selected list read.
|
||||
return this.lastBudgetedStartWasSelected &&
|
||||
entry.reason === 'visible' &&
|
||||
entry.candidate.isSelected === true &&
|
||||
entry.bypassBackgroundBudget !== true
|
||||
? 0
|
||||
: entry.priority
|
||||
}
|
||||
|
||||
entryDelay(entry: PRRefreshQueueEntry): number {
|
||||
const activeDelay = usesActiveRefreshPacing(entry) ? this.nextActiveBurstDelay(entry) : 0
|
||||
if (activeDelay > 0) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
|
||||
|
||||
/** Retire old-branch aliases when a linked-PR key survives worktree branch changes. */
|
||||
export function setLiveAlias(
|
||||
aliases: Map<string, GitHubPRRefreshAlias>,
|
||||
alias: GitHubPRRefreshAlias
|
||||
): void {
|
||||
if (alias.worktreeId) {
|
||||
for (const [cacheKey, existing] of aliases) {
|
||||
if (cacheKey !== alias.cacheKey && existing.worktreeId === alias.worktreeId) {
|
||||
aliases.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
aliases.set(alias.cacheKey, alias)
|
||||
}
|
||||
|
||||
/** Queued aliases are newer than request-time follow-ups; preserve them with their matching candidate. */
|
||||
export function mergeFollowUpAlias(
|
||||
aliases: Map<string, GitHubPRRefreshAlias>,
|
||||
alias: GitHubPRRefreshAlias
|
||||
): GitHubPRRefreshAlias | undefined {
|
||||
if (alias.worktreeId) {
|
||||
for (const existing of aliases.values()) {
|
||||
if (existing.worktreeId === alias.worktreeId) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
}
|
||||
setLiveAlias(aliases, alias)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Manual refresh writes copied maps back; keep the latest branch per worktree on re-entry too. */
|
||||
export function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias>): void {
|
||||
const liveCacheKeyByWorktree = new Map<string, string>()
|
||||
for (const [cacheKey, alias] of aliases) {
|
||||
if (!alias.worktreeId) {
|
||||
continue
|
||||
}
|
||||
const superseded = liveCacheKeyByWorktree.get(alias.worktreeId)
|
||||
if (superseded !== undefined) {
|
||||
aliases.delete(superseded)
|
||||
}
|
||||
liveCacheKeyByWorktree.set(alias.worktreeId, cacheKey)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
isMergeabilityPendingOutcome,
|
||||
sameAliasRequestIdentity,
|
||||
validateCandidate,
|
||||
visibleCandidateAfterOutcome
|
||||
visibleCandidateAfterOutcome,
|
||||
visibleFollowUpPriority
|
||||
} from './pr-refresh-candidate-policy'
|
||||
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
|
||||
import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing'
|
||||
@@ -33,7 +34,6 @@ export type PRRefreshRegistrationCheck = (candidate: GitHubPRRefreshCandidate) =
|
||||
export class PRRefreshQueueDrainer {
|
||||
private draining = false
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: queued work can outlive its project; re-check on every removal path before Git runs.
|
||||
private registrationCheck: PRRefreshRegistrationCheck | null = null
|
||||
readonly watchJobs: PRWatchJobRunner
|
||||
|
||||
@@ -52,9 +52,7 @@ export class PRRefreshQueueDrainer {
|
||||
}
|
||||
|
||||
schedule(delay = 0): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
clearTimeout(this.timer ?? undefined)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
// The drain serves every queued repo; a scheduling call inside a WSL user act must not lend it that act.
|
||||
@@ -125,7 +123,7 @@ export class PRRefreshQueueDrainer {
|
||||
candidate: followUpCandidate,
|
||||
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
|
||||
reason: 'visible',
|
||||
priority,
|
||||
priority: visibleFollowUpPriority(followUpCandidate, priority),
|
||||
dueAt,
|
||||
queuedAt: this.queue.nextOrder(),
|
||||
bypassBackgroundBudget: pendingDueAt !== null,
|
||||
@@ -137,7 +135,7 @@ export class PRRefreshQueueDrainer {
|
||||
|
||||
private ordered(): PRRefreshQueueEntry[] {
|
||||
this.watchJobs.age(this.queue.values(), Date.now())
|
||||
return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b))
|
||||
return this.queue.ordered((a, b) => this.pacing.order(a, b))
|
||||
}
|
||||
|
||||
private nextQueuedWakeDelay(excludedKey: string): number | null {
|
||||
@@ -237,7 +235,7 @@ export class PRRefreshQueueDrainer {
|
||||
continue
|
||||
}
|
||||
if (next.bypassBackgroundBudget !== true && isBudgetedBackground(next.reason)) {
|
||||
this.pacing.noteBackgroundStart()
|
||||
this.pacing.noteBackgroundStart(next)
|
||||
this.watchJobs.noteBudgetedStart(next)
|
||||
}
|
||||
if (usesActiveRefreshPacing(next)) {
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
refreshKey,
|
||||
sameAliasRequestIdentity,
|
||||
refreshIntervalForCandidate,
|
||||
shouldSkipFresh
|
||||
shouldSkipFresh,
|
||||
visibleFollowUpPriority
|
||||
} from './pr-refresh-candidate-policy'
|
||||
|
||||
export type PRRefreshQueueEntry = {
|
||||
@@ -40,51 +41,11 @@ export type PRRefreshEnqueue = {
|
||||
|
||||
type PRRefreshRequestState = { until: number; requestSequence?: number }
|
||||
|
||||
/** Retire old-branch aliases when a linked-PR key survives worktree branch changes. */
|
||||
function setLiveAlias(
|
||||
aliases: Map<string, GitHubPRRefreshAlias>,
|
||||
alias: GitHubPRRefreshAlias
|
||||
): void {
|
||||
if (alias.worktreeId) {
|
||||
for (const [cacheKey, existing] of aliases) {
|
||||
if (cacheKey !== alias.cacheKey && existing.worktreeId === alias.worktreeId) {
|
||||
aliases.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
aliases.set(alias.cacheKey, alias)
|
||||
}
|
||||
|
||||
/** Queued aliases are newer than request-time follow-ups; preserve them with their matching candidate. */
|
||||
function mergeFollowUpAlias(
|
||||
aliases: Map<string, GitHubPRRefreshAlias>,
|
||||
alias: GitHubPRRefreshAlias
|
||||
): GitHubPRRefreshAlias | undefined {
|
||||
if (alias.worktreeId) {
|
||||
for (const existing of aliases.values()) {
|
||||
if (existing.worktreeId === alias.worktreeId) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
}
|
||||
setLiveAlias(aliases, alias)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Manual refresh writes copied maps back; keep the latest branch per worktree on re-entry too. */
|
||||
function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias>): void {
|
||||
const liveCacheKeyByWorktree = new Map<string, string>()
|
||||
for (const [cacheKey, alias] of aliases) {
|
||||
if (!alias.worktreeId) {
|
||||
continue
|
||||
}
|
||||
const superseded = liveCacheKeyByWorktree.get(alias.worktreeId)
|
||||
if (superseded !== undefined) {
|
||||
aliases.delete(superseded)
|
||||
}
|
||||
liveCacheKeyByWorktree.set(alias.worktreeId, cacheKey)
|
||||
}
|
||||
}
|
||||
import {
|
||||
setLiveAlias,
|
||||
mergeFollowUpAlias,
|
||||
dropSupersededWorktreeAliases
|
||||
} from './pr-refresh-queue-aliases'
|
||||
|
||||
export class PRRefreshQueue {
|
||||
private readonly entries = new Map<string, PRRefreshQueueEntry>()
|
||||
@@ -155,6 +116,7 @@ export class PRRefreshQueue {
|
||||
continue
|
||||
}
|
||||
entry.candidate = candidateFor(entry.key, entry.candidate)
|
||||
entry.priority = visibleFollowUpPriority(entry.candidate, entry.priority)
|
||||
entry.dueAt = Math.max(
|
||||
freshRetryAt(entry.candidate) ?? Date.now(),
|
||||
this.backgroundNotBefore.get(entry.key)?.until ?? 0
|
||||
@@ -313,14 +275,14 @@ export class PRRefreshQueue {
|
||||
}
|
||||
|
||||
ordered(
|
||||
activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number
|
||||
pacingOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number
|
||||
): PRRefreshQueueEntry[] {
|
||||
const now = Date.now()
|
||||
return Array.from(this.entries.values()).sort((a, b) => {
|
||||
const aReady = a.dueAt <= now
|
||||
const bReady = b.dueAt <= now
|
||||
if (aReady && bReady) {
|
||||
return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt
|
||||
return pacingOrder(a, b) || b.priority - a.priority || a.dueAt - b.dueAt
|
||||
}
|
||||
if (aReady !== bReady) {
|
||||
return aReady ? -1 : 1
|
||||
|
||||
@@ -103,7 +103,10 @@ export class PRWatchJobRunner {
|
||||
}
|
||||
|
||||
noteBudgetedStart(entry: PRRefreshQueueEntry): void {
|
||||
this.lastBudgetedStartWasWatch = entry.watch !== undefined
|
||||
// Selected list reads have their own reserved turn; preserve the watch/list alternation.
|
||||
if (entry.reason !== 'visible' || entry.candidate.isSelected !== true) {
|
||||
this.lastBudgetedStartWasWatch = entry.watch !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-ranks watch entries before each pick, so aging needs no change to the queue's order. */
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
|
||||
import { makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import { SourceControlMergedReviewNotice } from './merged-review-workspace-notice'
|
||||
|
||||
const review: HostedReviewInfo = {
|
||||
provider: 'github',
|
||||
number: 412,
|
||||
title: 'Add retry to sync',
|
||||
state: 'merged',
|
||||
url: 'https://github.com/acme/app/pull/412',
|
||||
status: 'success',
|
||||
updatedAt: '2026-11-10T00:00:00Z',
|
||||
mergeable: 'UNKNOWN',
|
||||
headSha: 'a1b2c3d'
|
||||
}
|
||||
|
||||
const worktree = makeWorktree('repo-1::/work/app-sync', 'app-sync', {
|
||||
branch: 'refs/heads/feature/sync-retry'
|
||||
})
|
||||
|
||||
function Frame({ children }: { children: React.ReactNode }) {
|
||||
return <div className="bg-sidebar p-2 text-foreground">{children}</div>
|
||||
}
|
||||
|
||||
export const safeToDelete = defineFixture({
|
||||
width: 300,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<SourceControlMergedReviewNotice
|
||||
review={review}
|
||||
worktree={worktree}
|
||||
statusHeadOid="a1b2c3d"
|
||||
uncommittedCount={0}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const uncommittedChanges = defineFixture({
|
||||
width: 300,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<SourceControlMergedReviewNotice
|
||||
review={review}
|
||||
worktree={worktree}
|
||||
statusHeadOid="a1b2c3d"
|
||||
uncommittedCount={2}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
|
||||
export const headMovedPastReview = defineFixture({
|
||||
width: 300,
|
||||
render: () => (
|
||||
<Frame>
|
||||
<SourceControlMergedReviewNotice
|
||||
review={review}
|
||||
worktree={worktree}
|
||||
statusHeadOid="e4f5a6b"
|
||||
uncommittedCount={0}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
})
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ runWorktreeDelete: vi.fn() }))
|
||||
vi.mock('../../../sidebar/workspace-actions/deletion/delete-worktree-flow', () => mocks)
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: (selector: (state: { deleteStateByWorktreeId: object }) => unknown) =>
|
||||
selector({ deleteStateByWorktreeId: {} })
|
||||
}))
|
||||
|
||||
import { SourceControlMergedReviewNotice } from './merged-review-workspace-notice'
|
||||
import { makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
|
||||
it('requires the ignored-file confirmation for an ignored-only merged checkout', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'merged-notice-'))
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
const git = async (args: string[]) => {
|
||||
const result = await runProcess({ program: 'git', args, cwd: directory })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
try {
|
||||
await git(['init', '--quiet'])
|
||||
await writeFile(path.join(directory, '.gitignore'), '.env\n')
|
||||
await git(['add', '.gitignore'])
|
||||
await git([
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'user.email=test@example.com',
|
||||
'commit',
|
||||
'-m',
|
||||
'initial'
|
||||
])
|
||||
await writeFile(path.join(directory, '.env'), 'LOCAL_ONLY=value\n')
|
||||
const head = await git(['rev-parse', 'HEAD'])
|
||||
const status = await git(['status', '--porcelain'])
|
||||
expect(status).toBe('')
|
||||
const worktree = makeWorktree('ignored-only', 'Ignored only', {
|
||||
path: directory,
|
||||
instanceId: 'original'
|
||||
})
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<SourceControlMergedReviewNotice
|
||||
review={{
|
||||
provider: 'github',
|
||||
number: 1,
|
||||
state: 'merged',
|
||||
headSha: head,
|
||||
title: '',
|
||||
url: '',
|
||||
status: 'success',
|
||||
updatedAt: '',
|
||||
mergeable: 'UNKNOWN'
|
||||
}}
|
||||
worktree={worktree}
|
||||
statusHeadOid={head}
|
||||
uncommittedCount={0}
|
||||
/>
|
||||
)
|
||||
)
|
||||
await act(async () => container.querySelector('button')?.click())
|
||||
expect(mocks.runWorktreeDelete).toHaveBeenCalledWith('ignored-only', {
|
||||
expectedHostId: undefined,
|
||||
expectedInstanceId: 'original',
|
||||
forceConfirm: true
|
||||
})
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { GitMerge, LoaderCircle, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import {
|
||||
localizedHostedReviewCopy,
|
||||
resolveSupportedHostedReviewCopyProvider
|
||||
} from '@/i18n/hosted-review-localized-copy'
|
||||
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { resolveMergedReviewWorkspaceReadiness } from '../../../../../../shared/source-control/decisions/merged-review-workspace-readiness'
|
||||
import { runWorktreeDelete } from '../../../sidebar/workspace-actions/deletion/delete-worktree-flow'
|
||||
import { getDeleteStateForWorktreeHost } from '../../../sidebar/workspace-actions/deletion/worktree-delete-state-host-match'
|
||||
|
||||
/** Says whether a merged workspace still holds work; the branch compare alone keeps squash-merged commits listed. */
|
||||
export function SourceControlMergedReviewNotice({
|
||||
review,
|
||||
worktree,
|
||||
statusHeadOid,
|
||||
uncommittedCount
|
||||
}: {
|
||||
review: HostedReviewInfo | null
|
||||
worktree: Worktree
|
||||
/** HEAD from the latest successful git status read; null until one completes. */
|
||||
statusHeadOid: string | null
|
||||
uncommittedCount: number
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const isDeleting = useAppStore(
|
||||
(s) => getDeleteStateForWorktreeHost(worktree, s.deleteStateByWorktreeId)?.isDeleting ?? false
|
||||
)
|
||||
const readiness = resolveMergedReviewWorkspaceReadiness({
|
||||
review,
|
||||
headOid: statusHeadOid,
|
||||
uncommittedCount
|
||||
})
|
||||
// Without a status read the change count is unknown, so say nothing rather than guess.
|
||||
if (!review || !readiness || !statusHeadOid) {
|
||||
return null
|
||||
}
|
||||
const label = `${
|
||||
localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(review.provider), t)
|
||||
.shortLabel
|
||||
} #${review.number}`
|
||||
const canDelete = readiness.kind === 'safe-to-delete' && !worktree.isMainWorktree
|
||||
|
||||
let message: string
|
||||
if (readiness.kind === 'uncommitted-changes') {
|
||||
message = t(
|
||||
'auto.components.right.sidebar.SourceControl.mergedReviewUncommitted',
|
||||
'{{review}} is merged, but this workspace has {{count}} uncommitted changes that are not in it.',
|
||||
{
|
||||
review: label,
|
||||
count: readiness.count,
|
||||
defaultValue_one:
|
||||
'{{review}} is merged, but this workspace has {{count}} uncommitted change that is not in it.'
|
||||
}
|
||||
)
|
||||
} else if (readiness.kind === 'head-unverified') {
|
||||
message = t(
|
||||
'auto.components.right.sidebar.SourceControl.mergedReviewHeadMoved',
|
||||
'{{review}} is merged, but Orca could not confirm that it includes this branch’s current commits.',
|
||||
{ review: label }
|
||||
)
|
||||
} else if (worktree.isMainWorktree) {
|
||||
message = t(
|
||||
'auto.components.right.sidebar.SourceControl.mergedReviewNothingLeft',
|
||||
'{{review}} is merged and includes this branch’s committed changes.',
|
||||
{ review: label }
|
||||
)
|
||||
} else {
|
||||
message = t(
|
||||
'auto.components.right.sidebar.SourceControl.mergedReviewSafeToDelete',
|
||||
'{{review}} is merged and includes this branch’s committed changes. Ignored files may still be lost when deleting this workspace.',
|
||||
{ review: label }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-[11px] text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<GitMerge className="mt-0.5 size-3.5 shrink-0 text-callout-important" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1.5">
|
||||
<span>{message}</span>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="cursor-pointer"
|
||||
disabled={isDeleting}
|
||||
onClick={() =>
|
||||
runWorktreeDelete(worktree.id, {
|
||||
expectedHostId: worktree.hostId,
|
||||
expectedInstanceId: worktree.instanceId,
|
||||
forceConfirm: true
|
||||
})
|
||||
}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
{isDeleting
|
||||
? t('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
|
||||
: t(
|
||||
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
|
||||
'Delete Workspace'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { SourceControlUncommittedSections } from '../listing/uncommitted-section
|
||||
import { CompareUnavailable } from '../sync/compare-summary'
|
||||
import { SourceControlCommitSurface } from './commit-surface'
|
||||
import { SourceControlForkPushNotice } from './fork-push-notice'
|
||||
import { SourceControlMergedReviewNotice } from './merged-review-workspace-notice'
|
||||
import type { SourceControlPanelReadyProps } from './panel-props'
|
||||
import { useSourceControlScope } from './worktree-scope'
|
||||
import { openGitGraph } from '@/lib/source-control/graph/git-graph-tab'
|
||||
@@ -135,6 +136,19 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
{/* Keep CommitArea through clean/mid-commit states (#1448); only active conflict operations replace the action surface. */}
|
||||
<SourceControlForkPushNotice pushTarget={activeWorktree.pushTarget ?? null} />
|
||||
|
||||
{model.isFolder ? null : (
|
||||
<SourceControlMergedReviewNotice
|
||||
review={model.hostedReview}
|
||||
worktree={activeWorktree}
|
||||
statusHeadOid={
|
||||
statusReadError === undefined && repositoryHuge === undefined
|
||||
? model.activeGitStatusHead
|
||||
: null
|
||||
}
|
||||
uncommittedCount={model.entries.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderCommitArea(
|
||||
unresolvedConflicts.length,
|
||||
conflictOperation,
|
||||
|
||||
+14
@@ -108,6 +108,8 @@ vi.mock('./delete-worktree-failure-toast', () => ({
|
||||
import { toast } from 'sonner'
|
||||
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
|
||||
|
||||
import { runWorktreeDelete } from './delete-worktree-flow'
|
||||
|
||||
import { runWorktreeDeletesInParallel } from './worktree-delete-execution'
|
||||
|
||||
function setWorktrees(
|
||||
@@ -159,6 +161,18 @@ describe('delete worktree flow', () => {
|
||||
setWorktrees([])
|
||||
})
|
||||
|
||||
it('keeps the ignored-file confirmation available when a caller requires it despite skip-confirm', () => {
|
||||
setWorktrees([{ id: 'ignored-only' }])
|
||||
mocks.state.settings.skipDeleteWorktreeConfirm = true
|
||||
runWorktreeDelete('ignored-only', { forceConfirm: true })
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
|
||||
worktreeId: 'ignored-only',
|
||||
worktreeDeleteIdentities: [{ id: 'ignored-only', instanceId: 'ignored-only-instance' }],
|
||||
allowSkipConfirm: false
|
||||
})
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates each queued instance immediately before execution', async () => {
|
||||
// Model a host relying on renderer serialization for same-repo branch cleanup.
|
||||
setWorktrees([
|
||||
|
||||
+2
-1
@@ -96,6 +96,7 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
const hasLineageChildren = deleteLineage.descendants.length > 0
|
||||
// Skip-confirm preferences never authorize discarding unsaved editor text.
|
||||
const skipConfirm =
|
||||
options.forceConfirm !== true &&
|
||||
(state.settings?.skipDeleteWorktreeConfirm ?? false) &&
|
||||
collectRemovalUnsavedFiles(useAppStore.getState, [target]).length === 0
|
||||
if (skipConfirm && !hasLineageChildren) {
|
||||
@@ -114,7 +115,7 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
])
|
||||
}
|
||||
: {}),
|
||||
...(hasLineageChildren ? { allowSkipConfirm: false } : {})
|
||||
...(hasLineageChildren || options.forceConfirm ? { allowSkipConfirm: false } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export type WorktreeBatchDeleteOptions = {
|
||||
export type WorktreeDeleteIdentity = Pick<Worktree, 'id' | 'instanceId' | 'hostId'>
|
||||
|
||||
export type WorktreeDeleteOptions = {
|
||||
forceConfirm?: boolean
|
||||
expectedInstanceId?: string
|
||||
/** Pin deletion to the confirmed host because workspace IDs repeat across hosts. */
|
||||
expectedHostId?: ExecutionHostId
|
||||
|
||||
+16
-6
@@ -114,6 +114,9 @@ function getReviewStatusLabel(review: WorktreeCardPrDisplay, t: Translate = tran
|
||||
if (outcome === 'success') {
|
||||
return t('workspace.reviewStatus.checksPassing', '{{review}} checks: Passing', values)
|
||||
}
|
||||
if (!review.state && !outcome) {
|
||||
return getReviewLabel(review)
|
||||
}
|
||||
return t('workspace.reviewStatus.open', '{{review}}: Open', values)
|
||||
}
|
||||
|
||||
@@ -148,14 +151,17 @@ export function WorktreeCardStatusSlot({
|
||||
const vmState = vmRuntime ? getEphemeralVmHostState(vmRuntime.status, vmWaking) : null
|
||||
const vmFailure = vmRuntime?.failure ?? null
|
||||
const statusLabel = getWorktreeStatusLabel(status) || status
|
||||
// Sleeping remains distinct from quiet done/branch/review states; attention keeps its own glyph.
|
||||
const canShowSleepingStatus =
|
||||
newCardStyle && showStatus && isSleeping && QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
|
||||
const canShowReviewStatus =
|
||||
newCardStyle &&
|
||||
showStatus &&
|
||||
(prDisplay !== null || Boolean(collectionChecks)) &&
|
||||
!canShowSleepingStatus &&
|
||||
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
|
||||
// Review outranks sleep: the dimmed row already shows sleep, only the glyph can show the PR.
|
||||
const canShowSleepingStatus =
|
||||
newCardStyle &&
|
||||
showStatus &&
|
||||
isSleeping &&
|
||||
!canShowReviewStatus &&
|
||||
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
|
||||
const canShowBranchStatus =
|
||||
newCardStyle &&
|
||||
@@ -178,10 +184,14 @@ export function WorktreeCardStatusSlot({
|
||||
isReviewStale && canShowReviewStatus && (collectionChecks || prDisplay)
|
||||
? `${passiveStatusLabel} · ${t('workspace.links.lastKnownStatus', 'Last known status')}`
|
||||
: passiveStatusLabel
|
||||
const laneLabel =
|
||||
canShowReviewStatus && isSleeping
|
||||
? `${reviewLabel} · ${getSleepingStatusLabel(t)}`
|
||||
: reviewLabel
|
||||
const passiveStatusAnnouncement =
|
||||
newCardStyle && isUnread
|
||||
? t('workspace.activity.unreadLabel', '{{status}} · Unread', { status: reviewLabel })
|
||||
: reviewLabel
|
||||
? t('workspace.activity.unreadLabel', '{{status}} · Unread', { status: laneLabel })
|
||||
: laneLabel
|
||||
// Working/permission own the lane; retain unread in accessible copy and restore its dot afterward.
|
||||
const showNewCardUnreadAlert =
|
||||
newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission'
|
||||
|
||||
+2
@@ -429,6 +429,8 @@
|
||||
"discardFailedSampleMore_other": "{{paths}}, +{{count}} more",
|
||||
"discardFailed_one": "Failed to discard {{count}} file",
|
||||
"discardFailed_other": "Failed to discard {{count}} files",
|
||||
"mergedReviewUncommitted_one": "{{review}} is merged, but this workspace has {{count}} uncommitted change that is not in it.",
|
||||
"mergedReviewUncommitted_other": "{{review}} is merged, but this workspace has {{count}} uncommitted changes that are not in it.",
|
||||
"noteCount_one": "{{count}} note",
|
||||
"noteCount_other": "{{count}} notes"
|
||||
},
|
||||
|
||||
@@ -14798,6 +14798,11 @@
|
||||
"locallyResolvedConflictOfKind": "Conflict resolved locally ({{kind}})",
|
||||
"locallyResolvedConflict": "Conflict resolved locally",
|
||||
"pushesToForkTarget": "Pushes to fork {{target}}",
|
||||
"mergedReviewUncommitted_one": "{{review}} is merged, but this workspace has {{count}} uncommitted change that is not in it.",
|
||||
"mergedReviewUncommitted_other": "{{review}} is merged, but this workspace has {{count}} uncommitted changes that are not in it.",
|
||||
"mergedReviewHeadMoved": "{{review}} is merged, but Orca could not confirm that it includes this branch’s current commits.",
|
||||
"mergedReviewNothingLeft": "{{review}} is merged and includes this branch’s committed changes.",
|
||||
"mergedReviewSafeToDelete": "{{review}} is merged and includes this branch’s committed changes. Ignored files may still be lost when deleting this workspace.",
|
||||
"abortRebaseConfirmTitle": "Abort rebase?",
|
||||
"abortMergeConfirmTitle": "Abort merge?",
|
||||
"abortRebaseConfirmDescription": "This cancels the rebase in progress and can discard conflict resolutions made during this rebase.",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMergedReviewWorkspaceReadiness } from './merged-review-workspace-readiness'
|
||||
|
||||
const merged = { state: 'merged' as const, headSha: 'abc123' }
|
||||
|
||||
describe('resolveMergedReviewWorkspaceReadiness', () => {
|
||||
it('is safe to delete when HEAD is the merged head and the tree is clean', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: merged,
|
||||
headOid: 'ABC123',
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toEqual({ kind: 'safe-to-delete' })
|
||||
})
|
||||
|
||||
it('is safe to delete when the provider confirmed HEAD is in the merged review', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: { ...merged, headSha: 'updated-on-github', confirmedContainedHeadOid: 'local' },
|
||||
headOid: 'local',
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toEqual({ kind: 'safe-to-delete' })
|
||||
})
|
||||
|
||||
it('reports uncommitted changes before HEAD identity', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: merged,
|
||||
headOid: 'abc123',
|
||||
uncommittedCount: 3
|
||||
})
|
||||
).toEqual({ kind: 'uncommitted-changes', count: 3 })
|
||||
})
|
||||
|
||||
it('leaves a different checkout head unverified without assuming its commits are absent', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: merged,
|
||||
headOid: 'def456',
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toEqual({ kind: 'head-unverified' })
|
||||
})
|
||||
|
||||
it('leaves missing checkout heads and missing review proof unverified', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: merged,
|
||||
headOid: null,
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toEqual({ kind: 'head-unverified' })
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: { state: 'merged' },
|
||||
headOid: 'abc123',
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toEqual({ kind: 'head-unverified' })
|
||||
})
|
||||
|
||||
it('says nothing for reviews that are not merged', () => {
|
||||
expect(
|
||||
resolveMergedReviewWorkspaceReadiness({
|
||||
review: { ...merged, state: 'open' },
|
||||
headOid: 'abc123',
|
||||
uncommittedCount: 0
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HostedReviewInfo } from '../hosted-review/hosted-review-contracts'
|
||||
|
||||
export type MergedReviewWorkspaceReadiness =
|
||||
/** HEAD is the merged review's head (or GitHub confirmed it is in the review) and nothing is uncommitted. */
|
||||
| { kind: 'safe-to-delete' }
|
||||
| { kind: 'uncommitted-changes'; count: number }
|
||||
/** Merged, but this checkout's HEAD cannot be matched to the review, so its commits may be elsewhere. */
|
||||
| { kind: 'head-unverified' }
|
||||
|
||||
/**
|
||||
* Whether a workspace whose review merged still holds work. A squash or rebase merge copies commits onto
|
||||
* the base with new IDs, so the branch compare keeps listing them; only HEAD identity proves they landed.
|
||||
*/
|
||||
export function resolveMergedReviewWorkspaceReadiness(input: {
|
||||
review: Pick<HostedReviewInfo, 'state' | 'headSha' | 'confirmedContainedHeadOid'> | null
|
||||
headOid: string | null | undefined
|
||||
uncommittedCount: number
|
||||
}): MergedReviewWorkspaceReadiness | null {
|
||||
const { review, uncommittedCount } = input
|
||||
if (review?.state !== 'merged') {
|
||||
return null
|
||||
}
|
||||
if (uncommittedCount > 0) {
|
||||
return { kind: 'uncommitted-changes', count: uncommittedCount }
|
||||
}
|
||||
const head = input.headOid?.trim().toLowerCase() ?? ''
|
||||
const reviewHeads = [review.headSha, review.confirmedContainedHeadOid].map(
|
||||
(oid) => oid?.trim().toLowerCase() ?? ''
|
||||
)
|
||||
return head.length > 0 && reviewHeads.includes(head)
|
||||
? { kind: 'safe-to-delete' }
|
||||
: { kind: 'head-unverified' }
|
||||
}
|
||||
Reference in New Issue
Block a user