Display total lines of code change in branch header (#12771)
* Add branch line total chip to source control header Display the total lines added and removed across a branch from its fork point, measured via `git diff <mergeBase>`. Only computed when the chip is visible (request gate on merge base OID), with 500ms soft deadline to protect status latency and 15s hard timeout. Deduplicated across concurrent pollers and cached alongside line stats. Omitted on failure — always shows exact or nothing, never a partial estimate. Updates throughout the stack: native git status, relay, renderer store/API, and UI components. * Pin branch line total to app locale Format line counts using the app's configured locale instead of the system locale, ensuring consistent cross-platform display and test reliability. * test: wait for coalescer joins instead of fixed sleep Hold the diff until the second status pass actually takes the branch-total coalescer lease instead of using a fixed 400ms sleep. Fixes timing-dependent flakiness on slow machines.
This commit is contained in:
parent
73cd4c3f46
commit
debf4affe7
|
|
@ -1,101 +1,3 @@
|
|||
type StatusReadEntry<T> = {
|
||||
controller: AbortController
|
||||
promise: Promise<T>
|
||||
liveLeases: number
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
function getAbortReason(signal: AbortSignal): unknown {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return new DOMException('This operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
export class GitStatusReadLeaseOwner<T> {
|
||||
private readonly entries = new Map<string, StatusReadEntry<T>>()
|
||||
|
||||
lease(
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
load: (sharedSignal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(getAbortReason(signal))
|
||||
}
|
||||
|
||||
let entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
const controller = new AbortController()
|
||||
const promise = load(controller.signal)
|
||||
const createdEntry = { controller, promise, liveLeases: 0, settled: false }
|
||||
entry = createdEntry
|
||||
this.entries.set(key, createdEntry)
|
||||
void promise.then(
|
||||
() => this.settle(key, createdEntry),
|
||||
() => this.settle(key, createdEntry)
|
||||
)
|
||||
}
|
||||
|
||||
entry.liveLeases += 1
|
||||
return this.createLease(key, entry, signal)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private createLease(
|
||||
key: string,
|
||||
entry: StatusReadEntry<T>,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = true
|
||||
const release = (abortReason?: unknown): boolean => {
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
active = false
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
entry.liveLeases -= 1
|
||||
if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
entry.controller.abort(abortReason)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
const reason = getAbortReason(signal!)
|
||||
if (release(reason)) {
|
||||
reject(reason)
|
||||
}
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
void entry.promise.then(
|
||||
(value) => {
|
||||
if (release()) {
|
||||
resolve(value)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (release()) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private settle(key: string, entry: StatusReadEntry<T>): void {
|
||||
entry.settled = true
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: the class moved to src/shared so the relay host can coalesce the same
|
||||
// reads; this re-export keeps main's import path (and its tests) stable.
|
||||
export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildGitBranchLineTotalDiffArgs,
|
||||
invalidateGitBranchLineTotalInFlight
|
||||
} from '../../shared/git-branch-line-total'
|
||||
|
||||
import type * as BranchLineTotal from '../../shared/git-branch-line-total'
|
||||
import type * as GitRunner from './runner'
|
||||
|
||||
// Why: real git, spied argv. Every exec is recorded so the performance contract
|
||||
// ("no ranged diff unless asked", "one exec for concurrent callers") can be
|
||||
// asserted on the actual command list rather than on a stubbed return value.
|
||||
const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({
|
||||
gitExecCalls: [] as string[][],
|
||||
execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise<void> | void) },
|
||||
coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) }
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/git-branch-line-total', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof BranchLineTotal>()
|
||||
return {
|
||||
...actual,
|
||||
computeGitBranchLineTotal: (
|
||||
input: Parameters<typeof BranchLineTotal.computeGitBranchLineTotal>[0]
|
||||
) => {
|
||||
// The lease is taken synchronously inside, so counting after the call
|
||||
// means a join is observable the instant it has happened.
|
||||
const total = actual.computeGitBranchLineTotal(input)
|
||||
coalescerJoins.count += 1
|
||||
coalescerJoins.onJoin?.()
|
||||
return total
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GitRunner>()
|
||||
return {
|
||||
...actual,
|
||||
gitExecFileAsync: async (...args: Parameters<typeof GitRunner.gitExecFileAsync>) => {
|
||||
gitExecCalls.push(args[0])
|
||||
await execHooks.beforeExec?.(args[0])
|
||||
return actual.gitExecFileAsync(...args)
|
||||
},
|
||||
gitExecFileAsyncBuffer: async (
|
||||
...args: Parameters<typeof GitRunner.gitExecFileAsyncBuffer>
|
||||
) => {
|
||||
gitExecCalls.push(args[0])
|
||||
return actual.gitExecFileAsyncBuffer(...args)
|
||||
},
|
||||
gitStreamStdout: async (...args: Parameters<typeof GitRunner.gitStreamStdout>) => {
|
||||
gitExecCalls.push(args[0])
|
||||
return actual.gitStreamStdout(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
clearEffectiveUpstreamStatusCacheForTests,
|
||||
clearSubmodulePathsCacheForTests,
|
||||
getStatus,
|
||||
invalidateGitReadCaches
|
||||
} from './status'
|
||||
|
||||
const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5)
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
execFileSync('git', ['init', '-q', repo])
|
||||
git(repo, ['config', 'user.email', 'test@example.com'])
|
||||
git(repo, ['config', 'user.name', 'Test User'])
|
||||
git(repo, ['config', 'commit.gpgSign', 'false'])
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', 'base'])
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
async function write(repo: string, relativePath: string, contents: string): Promise<void> {
|
||||
const target = path.join(repo, relativePath)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
|
||||
/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */
|
||||
function rangedDiffCalls(): string[][] {
|
||||
return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--')
|
||||
}
|
||||
|
||||
function numstatCalls(): string[][] {
|
||||
return gitExecCalls.filter((args) => args.includes('--numstat'))
|
||||
}
|
||||
|
||||
/** Resolves once `count` status passes have entered the branch-total coalescer. */
|
||||
function waitForCoalescerJoins(count: number): Promise<void> {
|
||||
if (coalescerJoins.count >= count) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
coalescerJoins.onJoin = () => {
|
||||
if (coalescerJoins.count >= count) {
|
||||
coalescerJoins.onJoin = undefined
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resetGitReadCaches(): void {
|
||||
clearEffectiveUpstreamStatusCacheForTests()
|
||||
clearSubmodulePathsCacheForTests()
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecCalls.length = 0
|
||||
execHooks.beforeExec = undefined
|
||||
coalescerJoins.count = 0
|
||||
coalescerJoins.onJoin = undefined
|
||||
resetGitReadCaches()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
execHooks.beforeExec = undefined
|
||||
coalescerJoins.onJoin = undefined
|
||||
resetGitReadCaches()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('branch line total completeness', () => {
|
||||
it('omits the total when the status listing hit its limit', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
await write(repo, 'u1.txt', 'a\n')
|
||||
await write(repo, 'u2.txt', 'b\n')
|
||||
await write(repo, 'u3.txt', 'c\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 })
|
||||
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('omits the total when the ranged numstat fails, rather than reporting zero', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE })
|
||||
|
||||
expect(result.entries.length).toBeGreaterThan(0)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
// The diff really was attempted, so this is a failure path and not a gate.
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('omits the total for a directory that is not a git worktree', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-'))
|
||||
tempRoots.push(root)
|
||||
|
||||
const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE })
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects with an AbortError when the pass is cancelled mid-diff', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
const controller = new AbortController()
|
||||
execHooks.beforeExec = (args) => {
|
||||
if (args.includes('--numstat') && args.at(-1) === '--') {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
// The pass had already streamed status and reached the ranged diff, so this
|
||||
// is a cancellation in flight rather than a pre-flight rejection.
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('branch line total merge-base gate', () => {
|
||||
it('runs no ranged diff and returns no total without a merge base', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo)
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])(
|
||||
'never spawns a ranged diff for a merge base of %j',
|
||||
async (branchLineTotalMergeBase) => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
expect(gitExecCalls).not.toContainEqual(
|
||||
buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('never leaks a flag-shaped merge base into any git argv', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' })
|
||||
|
||||
for (const args of gitExecCalls) {
|
||||
expect(args).not.toContain('--upload-pack=/tmp/evil')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('branch line total exec budget', () => {
|
||||
it('costs exactly one extra exec versus the same pass with the chip hidden', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
await getStatus(repo)
|
||||
const withoutChip = gitExecCalls.map((args) => args.join(' '))
|
||||
|
||||
gitExecCalls.length = 0
|
||||
resetGitReadCaches()
|
||||
await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
const withChip = gitExecCalls.map((args) => args.join(' '))
|
||||
|
||||
expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip)
|
||||
expect(withChip).toHaveLength(withoutChip.length + 1)
|
||||
})
|
||||
|
||||
it('shares one ranged diff between two concurrent identical status passes', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase }),
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
])
|
||||
|
||||
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shares one ranged diff across status passes the status lease cannot merge', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
// Why: `limit` is part of the status read key, so these two passes each run
|
||||
// their own `git status`; only the branch-total coalescer can merge the diff.
|
||||
// Hold the first diff exactly until the second pass has taken the lease: a
|
||||
// fixed sleep would either release too early or, on a slow machine, outrun
|
||||
// GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total.
|
||||
execHooks.beforeExec = async (args) => {
|
||||
if (args.includes('--numstat') && args.at(-1) === '--') {
|
||||
await waitForCoalescerJoins(2)
|
||||
}
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }),
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 })
|
||||
])
|
||||
|
||||
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
// Proves the saving came from the branch-total coalescer: both passes really
|
||||
// ran their own `git status`, so the status lease merged nothing.
|
||||
expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2)
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reuses the cached total when the pass reuses cached line stats', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
const first = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: mergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
expect(numstatCalls()).toHaveLength(2)
|
||||
|
||||
gitExecCalls.length = 0
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
const second = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: mergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(numstatCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('recomputes rather than reusing a total measured against another merge base', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', 'second'])
|
||||
const laterMergeBase = git(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true })
|
||||
gitExecCalls.length = 0
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
const result = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: laterMergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase })
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { getStatus, invalidateGitReadCaches } from './status'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createFixtureRepo(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
execFileSync('git', ['init', '-q', repo])
|
||||
git(repo, ['config', 'user.email', 'test@example.com'])
|
||||
git(repo, ['config', 'user.name', 'Test User'])
|
||||
git(repo, ['config', 'commit.gpgSign', 'false'])
|
||||
return repo
|
||||
}
|
||||
|
||||
async function write(repo: string, relativePath: string, contents: string | Buffer): Promise<void> {
|
||||
const target = path.join(repo, relativePath)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
|
||||
/** The forked-from commit the chip measures against, as branch compare would resolve it. */
|
||||
function commitAll(repo: string, message: string): string {
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', message])
|
||||
return git(repo, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
/** What a plain `git diff <mergeBase>` reports, i.e. the number the chip must not disagree with. */
|
||||
function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) {
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const [rawAdded, rawRemoved] = line.split('\t')
|
||||
added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10)
|
||||
removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10)
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('branch line total against a real repository', () => {
|
||||
it('measures a partially staged line once, not once per status area', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'line1\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
// Stage "+foo", then edit that same line to "bar" without staging it: the
|
||||
// per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1.
|
||||
await write(repo, 'f.txt', 'line1\nfoo\n')
|
||||
git(repo, ['add', 'f.txt'])
|
||||
await write(repo, 'f.txt', 'line1\nbar\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([
|
||||
['staged', 1, 0],
|
||||
['unstaged', 1, 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('nets a line added in a branch commit and deleted in the working tree to zero', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
commitAll(repo, 'add b')
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('keeps the total across a commit and updates it on the next status call after an edit', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
|
||||
const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
|
||||
|
||||
commitAll(repo, 'commit the edit')
|
||||
const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
|
||||
|
||||
await write(repo, 'f.txt', 'a\nb\nc\nd\n')
|
||||
const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('counts a rename across the commit boundary once, using the post-rename path', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['mv', 'old.txt', 'new.txt'])
|
||||
commitAll(repo, 'rename')
|
||||
await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('counts an untracked-only branch from the untracked file contents', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'kept.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'src/new.ts', 'one\ntwo\nthree\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.entries.map((entry) => entry.area)).toEqual(['untracked'])
|
||||
expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('excludes a binary-only change, matching numstat reporting it as "-"', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66]))
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70]))
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/)
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('contributes nothing for a pure rename', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\nb\nc\nd\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['mv', 'f.txt', 'g.txt'])
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('includes untracked additions alongside tracked range changes', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
commitAll(repo, 'append c')
|
||||
await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
// Documented deviation from a pure `git diff`: the untracked half is added on
|
||||
// top of the tracked range, so a deleted-then-recreated path is counted twice.
|
||||
// Locked in deliberately — changing it must be a deliberate spec change.
|
||||
it('counts both the deletion and the recreated untracked file for the same path', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'gone.txt', 'a\nb\nc\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['rm', '-q', 'gone.txt'])
|
||||
commitAll(repo, 'delete gone.txt')
|
||||
await write(repo, 'gone.txt', 'n1\nn2\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([
|
||||
['untracked', 'gone.txt']
|
||||
])
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Main and relay must publish the same `branchLineTotal` for the same fixture
|
||||
* repo. Both call sites
|
||||
* share `src/shared/git-branch-line-total.ts`; this is the test that catches one
|
||||
* of them wiring it up differently — different flags, a different untracked
|
||||
* source, a different completeness gate.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getStatus } from './status'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Fork point → working tree for the fixture below:
|
||||
// tracked.txt +2 (branch commit only)
|
||||
// partial.txt +1 (staged +2 then one of those lines removed unstaged)
|
||||
// flip.txt 0 (added in the branch commit, removed again in the worktree)
|
||||
// moved.txt 0 (pure rename)
|
||||
// fresh.txt +3 (untracked)
|
||||
const EXPECTED_TOTAL = { added: 6, removed: 0 }
|
||||
// Summing the per-area status rows instead would give this — the wrong answer
|
||||
// the shared module exists to avoid.
|
||||
const AREA_ROW_SUM = { added: 5, removed: 2 }
|
||||
|
||||
const relayGit: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => {
|
||||
const { stdout } = await relayGit(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** Returns the merge-base OID the chip is measured against. */
|
||||
async function seedParityFixture(repo: string): Promise<string> {
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n')
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n')
|
||||
await writeFile(path.join(repo, 'renamed.txt'), 'stable\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n')
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt'])
|
||||
// Staged and unstaged hunks land on the same added lines, so an area sum
|
||||
// double-counts them.
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n')
|
||||
runFixtureGit(repo, ['add', 'partial.txt'])
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n')
|
||||
// The branch commit's line, taken back out in the worktree: net zero.
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): {
|
||||
added: number
|
||||
removed: number
|
||||
} {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const entry of entries) {
|
||||
added += entry.added ?? 0
|
||||
removed += entry.removed ?? 0
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
describe('branch line total parity between main and relay', () => {
|
||||
let repo: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('produces identical totals for the same fixture repo', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase })
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
// Both sides model the same worktree, so a parity pass on a mismatched
|
||||
// entry list would be meaningless.
|
||||
expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual(
|
||||
mainStatus.entries.map((entry) => entry.path).sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('agrees on a number no per-area row sum could produce', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM)
|
||||
expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual(
|
||||
AREA_ROW_SUM
|
||||
)
|
||||
expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM)
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
})
|
||||
|
||||
it('omits the total on both sides when no merge base is requested', async () => {
|
||||
await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo)
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo })
|
||||
|
||||
expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false)
|
||||
expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options'
|
|||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
import {
|
||||
computeGitBranchLineTotal,
|
||||
invalidateGitBranchLineTotalInFlight,
|
||||
readGitBranchLineTotalMergeBaseParam,
|
||||
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
|
||||
type GitBranchLineTotal
|
||||
} from '../../shared/git-branch-line-total'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
|
|
@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
|
|||
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
|
||||
const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
|
||||
// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read.
|
||||
// Why: clear every in-flight git read cache; clearing only some would let a post-mutation
|
||||
// getStatus() join a pre-mutation read and publish it as current.
|
||||
export function invalidateGitReadCaches(): void {
|
||||
gitDiffReadDedupe.clear()
|
||||
statusReadLeaseOwner.invalidate()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache()
|
||||
resolvedUpstreamNameCache.clear()
|
||||
|
|
@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
|
|||
export type GetStatusOptions = GitRuntimeOptions & {
|
||||
includeIgnored?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID the caller wants the branch line total measured against;
|
||||
* omitted means the chip is hidden, so no ranged diff runs at all. */
|
||||
branchLineTotalMergeBase?: string
|
||||
/**
|
||||
* Max changed-file entries before git is stopped and the result is marked
|
||||
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
|
||||
|
|
@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri
|
|||
options.wslDistro ?? '',
|
||||
options.includeIgnored === true,
|
||||
options.reuseLineStats === true,
|
||||
// Why: the result carries a total only for callers who asked, and only for
|
||||
// this fork point, so a shared lease must never serve one to the other.
|
||||
options.branchLineTotalMergeBase ?? '',
|
||||
options.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit,
|
||||
// Why: this changes which entries survive, so it must not share a cache slot.
|
||||
|
|
@ -371,16 +386,25 @@ async function runGetStatus(
|
|||
}
|
||||
|
||||
// Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set.
|
||||
let branchLineTotal: GitBranchLineTotal | undefined
|
||||
if (!didHitLimit) {
|
||||
await reuseOrRecomputeGitStatusLineStats({
|
||||
const branchLineTotalInput = createBranchLineTotalInput(
|
||||
worktreePath,
|
||||
entries,
|
||||
options,
|
||||
statusSucceeded
|
||||
)
|
||||
const lineStats = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: lineStatsCacheKey,
|
||||
head,
|
||||
entries,
|
||||
writeToken: lineStatsWriteToken,
|
||||
reuse: options.reuseLineStats === true,
|
||||
isAborted: () => options.signal?.aborted === true,
|
||||
recompute: () => attachLineStats(worktreePath, entries, options)
|
||||
recompute: () => attachLineStats(worktreePath, entries, options),
|
||||
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
|
||||
})
|
||||
branchLineTotal = lineStats.branchLineTotal
|
||||
} else {
|
||||
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
||||
}
|
||||
|
|
@ -398,6 +422,7 @@ async function runGetStatus(
|
|||
head,
|
||||
branch,
|
||||
...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}),
|
||||
...(branchLineTotal ? { branchLineTotal } : {}),
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}),
|
||||
...(statusSucceeded
|
||||
? {
|
||||
|
|
@ -416,6 +441,43 @@ async function runGetStatus(
|
|||
}
|
||||
}
|
||||
|
||||
/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */
|
||||
function createBranchLineTotalInput(
|
||||
worktreePath: string,
|
||||
entries: GitStatusEntry[],
|
||||
options: GetStatusOptions,
|
||||
statusSucceeded: boolean
|
||||
): { mergeBase: string; compute: () => Promise<GitBranchLineTotal | undefined> } | undefined {
|
||||
const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase)
|
||||
// Why: a failed status scan leaves the untracked list untrustworthy, so the
|
||||
// total would silently under-count rather than be absent.
|
||||
if (mergeBase === undefined || !statusSucceeded) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
mergeBase,
|
||||
compute: () =>
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
// Why: the same path can be a different filesystem per WSL distro.
|
||||
hostKey: options.wslDistro ?? 'native',
|
||||
mergeBase,
|
||||
untrackedPaths: entries
|
||||
.filter((entry) => entry.area === 'untracked')
|
||||
.map((entry) => entry.path),
|
||||
runDiffNumstat: (args, signal) =>
|
||||
gitExecFileAsync(args, {
|
||||
...gitOptionsForWorktree(worktreePath, options),
|
||||
// Why: after the spread, so the shared lease signal wins over this caller's own.
|
||||
signal,
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
|
||||
}).then((result) => result.stdout),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {
|
||||
// Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host.
|
||||
return `${options.wslDistro ?? 'native'}\0${worktreePath}`
|
||||
|
|
|
|||
|
|
@ -1127,6 +1127,7 @@ export function registerFilesystemHandlers(
|
|||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}
|
||||
): Promise<GitStatusResult> => {
|
||||
|
|
@ -1134,6 +1135,9 @@ export function registerFilesystemHandlers(
|
|||
const options = {
|
||||
includeIgnored: args.includeIgnored ?? false,
|
||||
...(args.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(args.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: args.branchLineTotalMergeBase }),
|
||||
...(args.bypassEffectiveUpstreamNegativeCache === true
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -3,5 +3,7 @@ export type GitProviderStatusOptions = {
|
|||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID to measure the branch line total against; omit to skip the work. */
|
||||
branchLineTotalMergeBase?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider {
|
|||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
|
||||
const branchLineTotalArgs =
|
||||
options?.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: options.branchLineTotalMergeBase }
|
||||
const request = {
|
||||
worktreePath,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs,
|
||||
...lineStatsReuseArgs
|
||||
...lineStatsReuseArgs,
|
||||
...branchLineTotalArgs
|
||||
}
|
||||
return (await (options?.signal
|
||||
? this.mux.request('git.status', request, { signal: options.signal })
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({
|
|||
export const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional(),
|
||||
bypassEffectiveUpstreamNegativeCache: z.boolean().optional(),
|
||||
reuseLineStats: z.boolean().optional()
|
||||
reuseLineStats: z.boolean().optional(),
|
||||
// Shape is re-validated host-side before it reaches a git argv.
|
||||
branchLineTotalMergeBase: z.string().optional()
|
||||
})
|
||||
|
||||
export const GitCheckIgnored = WorktreeSelector.extend({
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
params.includeIgnored === undefined &&
|
||||
params.bypassEffectiveUpstreamNegativeCache === undefined &&
|
||||
params.reuseLineStats === undefined &&
|
||||
params.branchLineTotalMergeBase === undefined &&
|
||||
signal === undefined
|
||||
? undefined
|
||||
: {
|
||||
|
|
@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [
|
|||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
...(params.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(params.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: params.branchLineTotalMergeBase }),
|
||||
...(signal ? { signal } : {})
|
||||
}
|
||||
return options === undefined
|
||||
|
|
|
|||
|
|
@ -2898,6 +2898,8 @@ export type PreloadApi = {
|
|||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID to measure the branch line total against; omit to skip the work. */
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}) => Promise<GitStatusResult>
|
||||
cancelStatus: (args: { requestToken: string }) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -3230,6 +3230,7 @@ const api = {
|
|||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
|
||||
cancelStatus: (args: { requestToken: string }): Promise<void> =>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import {
|
|||
clearGitStatusLineStatsCacheKey,
|
||||
reuseOrRecomputeGitStatusLineStats
|
||||
} from '../shared/git-status-line-stats-cache'
|
||||
import {
|
||||
readGitBranchLineTotalMergeBaseParam,
|
||||
type GitBranchLineTotal
|
||||
} from '../shared/git-branch-line-total'
|
||||
import { buildBranchLineTotalInput } from './git-status-branch-line-total'
|
||||
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
const dotGitPath = path.join(worktreePath, '.git')
|
||||
|
|
@ -74,11 +79,16 @@ export async function getStatusOp(
|
|||
ignoredPaths?: string[]
|
||||
didHitLimit?: boolean
|
||||
statusLength?: number
|
||||
branchLineTotal?: GitBranchLineTotal
|
||||
}> {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const lineStatsCacheKey = `relay\0${worktreePath}`
|
||||
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
|
||||
const includeIgnored = params.includeIgnored === true
|
||||
// Why: untrusted RPC input spliced into a git argv — only an OID shape may pass.
|
||||
const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam(
|
||||
params.branchLineTotalMergeBase
|
||||
)
|
||||
// Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate.
|
||||
const limit = resolveGitStatusLimit(params.limit)
|
||||
const conflictOperation = await detectConflictOperation(worktreePath)
|
||||
|
|
@ -89,6 +99,8 @@ export async function getStatusOp(
|
|||
let ignoredPaths: string[] = []
|
||||
let didHitLimit = false
|
||||
let statusLength = 0
|
||||
let statusSucceeded = false
|
||||
let branchLineTotal: GitBranchLineTotal | undefined
|
||||
|
||||
try {
|
||||
// Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish.
|
||||
|
|
@ -118,6 +130,7 @@ export async function getStatusOp(
|
|||
ignoredPaths = parser.ignoredPaths
|
||||
statusLength = parser.statusLength
|
||||
didHitLimit = stoppedEarly
|
||||
statusSucceeded = true
|
||||
const { upstreamName, upstreamAheadBehind } = parser.branch
|
||||
upstreamStatus = upstreamName
|
||||
? {
|
||||
|
|
@ -173,15 +186,26 @@ export async function getStatusOp(
|
|||
|
||||
// Why: skip numstat after the limit to avoid reintroducing its cost.
|
||||
if (!didHitLimit) {
|
||||
await reuseOrRecomputeGitStatusLineStats({
|
||||
const branchLineTotalInput = buildBranchLineTotalInput(
|
||||
git,
|
||||
worktreePath,
|
||||
entries,
|
||||
// Why: a failed scan leaves the untracked list untrustworthy, so the total
|
||||
// would under-count — omit it rather than publish a confident wrong number.
|
||||
statusSucceeded ? branchLineTotalMergeBase : undefined,
|
||||
options.signal
|
||||
)
|
||||
// Why: passed in so the ranged diff runs alongside the per-area numstats, not after them.
|
||||
;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: lineStatsCacheKey,
|
||||
head,
|
||||
entries,
|
||||
writeToken: lineStatsWriteToken,
|
||||
reuse: params.reuseLineStats === true,
|
||||
isAborted: () => options.signal?.aborted === true,
|
||||
recompute: () => attachLineStats(git, worktreePath, entries, options.signal)
|
||||
})
|
||||
recompute: () => attachLineStats(git, worktreePath, entries, options.signal),
|
||||
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
|
||||
}))
|
||||
} else {
|
||||
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
||||
}
|
||||
|
|
@ -200,7 +224,8 @@ export async function getStatusOp(
|
|||
branch,
|
||||
upstreamStatus,
|
||||
...(includeIgnored ? { ignoredPaths } : {}),
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength } : {})
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength } : {}),
|
||||
...(branchLineTotal ? { branchLineTotal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream'
|
|||
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
|
||||
import { endSubprocessStdin } from '../shared/subprocess-stdin-write'
|
||||
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
|
||||
import { streamRelayGitStdout } from './git-stdout-stream'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
|
@ -298,6 +299,7 @@ export class GitHandler {
|
|||
|
||||
private clearGitMutationReadCaches(): void {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache(this.submodulePathsCache)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,475 @@
|
|||
/**
|
||||
* Relay-side contract for the status pass's branch line total: the chip is an
|
||||
* exact number or absent, and costs nothing when nobody asked for it.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import { getStatusOp } from './git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from './git-stdout-stream'
|
||||
import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache'
|
||||
|
||||
type GitCall = Parameters<GitExec>
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567'
|
||||
const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98'
|
||||
// Untracked additions ride on top of the ranged diff, so the fixture needs a real file.
|
||||
const UNTRACKED_FILE = 'notes.md'
|
||||
const UNTRACKED_LINES = 4
|
||||
const STATUS_OUTPUT = [
|
||||
'# branch.oid 1111111111111111111111111111111111111111',
|
||||
'# branch.head (detached)',
|
||||
'1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts',
|
||||
`? ${UNTRACKED_FILE}`
|
||||
].join('\n')
|
||||
|
||||
function isRangedNumstat(args: string[]): boolean {
|
||||
return args.includes('diff') && args.includes('--numstat') && args.includes('-z')
|
||||
}
|
||||
|
||||
function rangedDiffCalls(calls: readonly GitCall[]): string[][] {
|
||||
return calls.map(([args]) => args).filter((args) => isRangedNumstat(args))
|
||||
}
|
||||
|
||||
function streamGitFromCapture(git: GitExec): RelayGitStreamExec {
|
||||
return async (args, cwd, options) => {
|
||||
const { stdout } = await git(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock host; the fixture-repo cases below run real git instead. */
|
||||
function createMockGit(overrides: {
|
||||
status?: string
|
||||
ranged?: () => Promise<{ stdout: string; stderr: string }>
|
||||
areaNumstat?: string
|
||||
}) {
|
||||
return vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
const realGitExec: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the
|
||||
* file the branch deleted, +4 untracked. The pure rename contributes nothing.
|
||||
*/
|
||||
async function seedBranchFixture(repo: string): Promise<string> {
|
||||
await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true })
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n')
|
||||
await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n')
|
||||
await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n')
|
||||
await fs.rm(path.join(repo, 'doomed.txt'))
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt'])
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n')
|
||||
await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
describe('getStatusOp branch line total', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-'))
|
||||
await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('runs no ranged diff and returns no total when the merge base param is absent', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir })
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
// The per-area numstats the CHANGES rows need still ran, so this is a
|
||||
// zero-cost omission rather than a disabled status pass.
|
||||
expect(result.entries).toContainEqual(
|
||||
expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('sums the ranged diff and untracked additions against a real fixture repo', async () => {
|
||||
const mergeBase = await seedBranchFixture(tmpDir)
|
||||
const git = vi.fn<GitExec>(realGitExec)
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({
|
||||
added: 3 + UNTRACKED_LINES,
|
||||
removed: 2,
|
||||
mergeBase
|
||||
})
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([
|
||||
['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the total when the status listing hit its limit', async () => {
|
||||
const manyEntries = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt`
|
||||
).join('\n')
|
||||
const git = createMockGit({ status: manyEntries })
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
limit: 3,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
// The untracked list is truncated, so any total would silently under-count.
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('omits the total, never zero, when the ranged diff fails', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef'))
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
// A failed ranged diff must not take the per-file rows down with it.
|
||||
expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 }))
|
||||
})
|
||||
|
||||
it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => {
|
||||
await seedBranchFixture(tmpDir)
|
||||
const git = vi.fn<GitExec>(realGitExec)
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
})
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('omits the total when the ranged diff exceeds its time budget', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => {
|
||||
const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT')
|
||||
error.killed = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
// The budget is handed to the subprocess rather than raced on a wall clock.
|
||||
const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2]
|
||||
expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) })
|
||||
})
|
||||
|
||||
it('omits the total when the status scan itself failed', async () => {
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
throw new Error('fatal: not a git repository')
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.entries).toEqual([])
|
||||
// A failed scan yields an empty untracked list, which would read as "no
|
||||
// untracked additions" rather than "unknown".
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
})
|
||||
|
||||
// Relay params are an untyped bag, so the merge base must be proven to be an
|
||||
// object name before it can be spliced into an argv.
|
||||
it.each([
|
||||
['a flag-shaped value', '--upload-pack=x'],
|
||||
['a rev name', 'HEAD'],
|
||||
['a ref path', 'refs/heads/main'],
|
||||
['a range', `${MERGE_BASE}..HEAD`],
|
||||
['a number', 123],
|
||||
['null', null],
|
||||
['an empty string', '']
|
||||
])('rejects %s before it reaches a git argv', async (_label, value) => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: value
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
for (const [args] of git.mock.calls) {
|
||||
expect(args).not.toContain(String(value))
|
||||
}
|
||||
})
|
||||
|
||||
it('answers a rejected merge base with the byte-identical no-merge-base response', async () => {
|
||||
const gitWithout = createMockGit({})
|
||||
const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), {
|
||||
worktreePath: tmpDir
|
||||
})
|
||||
clearGitStatusLineStatsCache()
|
||||
const gitRejected = createMockGit({})
|
||||
const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: '--upload-pack=x'
|
||||
})
|
||||
|
||||
expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam))
|
||||
expect(gitRejected.mock.calls.map(([args]) => args)).toEqual(
|
||||
gitWithout.mock.calls.map(([args]) => args)
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an aborted scan instead of resolving a partial total', async () => {
|
||||
const controller = new AbortController()
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
controller.abort()
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
getStatusOp(
|
||||
git,
|
||||
streamGitFromCapture(git),
|
||||
{ worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
).rejects.toThrow(/abort/i)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reuses the cached total instead of re-running the ranged diff', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const first = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
const reused = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(first.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
expect(reused.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent status passes onto one ranged diff', async () => {
|
||||
// The renderer's own in-flight refs only dedupe one renderer; a second
|
||||
// window or an fs-watcher burst must not run the ranged diff twice.
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
}),
|
||||
getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
])
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
expect(first.branchLineTotal).toEqual(second.branchLineTotal)
|
||||
expect(first.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes rather than reusing a total measured against another fork point', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
const moved = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: OTHER_MERGE_BASE,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(moved.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: OTHER_MERGE_BASE
|
||||
})
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field
|
||||
// is safe only while every reader survives its absence.
|
||||
describe('wire compatibility', () => {
|
||||
it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => {
|
||||
const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) })
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
// An old server's payload and a new server's "not known exact" payload are
|
||||
// the same thing on the wire: no key at all.
|
||||
const overTheWire = JSON.parse(JSON.stringify(result)) as {
|
||||
branchLineTotal?: { added: number; removed: number }
|
||||
}
|
||||
|
||||
expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false)
|
||||
expect(overTheWire.branchLineTotal).toBeUndefined()
|
||||
expect(overTheWire.branchLineTotal?.added).toBeUndefined()
|
||||
expect(overTheWire.branchLineTotal?.removed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the new request param out of the status argv an old server would run', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
const statusArgs = git.mock.calls
|
||||
.map(([args]) => args)
|
||||
.filter((args) => args.includes('status'))
|
||||
expect(statusArgs).toHaveLength(1)
|
||||
expect(statusArgs[0]).not.toContain(MERGE_BASE)
|
||||
})
|
||||
|
||||
it('publishes finite counts when the ranged diff reports only binaries', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' })
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({
|
||||
added: UNTRACKED_LINES,
|
||||
removed: 0,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true)
|
||||
expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Relay-side wiring for the status pass's branch line total.
|
||||
* Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300).
|
||||
*/
|
||||
import {
|
||||
computeGitBranchLineTotal,
|
||||
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
|
||||
type GitBranchLineTotal
|
||||
} from '../shared/git-branch-line-total'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
|
||||
/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */
|
||||
export function buildBranchLineTotalInput(
|
||||
git: GitExec,
|
||||
worktreePath: string,
|
||||
entries: Record<string, unknown>[],
|
||||
mergeBase: string | undefined,
|
||||
signal?: AbortSignal
|
||||
): { mergeBase: string; compute: () => Promise<GitBranchLineTotal | undefined> } | undefined {
|
||||
if (!mergeBase) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
mergeBase,
|
||||
compute: () =>
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
// Matches the `relay\0<path>` line-stats cache-key convention.
|
||||
hostKey: 'relay',
|
||||
mergeBase,
|
||||
untrackedPaths: entries
|
||||
.filter((entry) => entry.area === 'untracked')
|
||||
.map((entry) => entry.path as string),
|
||||
runDiffNumstat: (args, diffSignal) =>
|
||||
git(args, worktreePath, {
|
||||
// Why: a working-tree diff must not take index.lock away from terminal Git.
|
||||
disableOptionalLocks: true,
|
||||
signal: diffSignal,
|
||||
timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
|
||||
}).then(({ stdout }) => stdout),
|
||||
...(signal ? { signal } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/types'
|
||||
import {
|
||||
clearBranchLineTotalRequestGateForTests,
|
||||
getBranchLineTotalMergeBase
|
||||
} from './branch-line-total-request-gate'
|
||||
import SourceControl from './SourceControl'
|
||||
|
||||
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const activeRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git' as string
|
||||
}
|
||||
const activeWorktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt',
|
||||
head: 'head-1',
|
||||
branch: 'refs/heads/feature/line-total',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature/line-total',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
return {
|
||||
activeRepo,
|
||||
activeWorktree,
|
||||
state: {} as Record<string, unknown>
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector?: (state: Record<string, unknown>) => unknown) =>
|
||||
selector ? selector(mocks.state) : mocks.state,
|
||||
{
|
||||
getState: () => mocks.state
|
||||
}
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useActiveWorktree: () => mocks.activeWorktree,
|
||||
useRepoById: (repoId: string | null) =>
|
||||
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
|
||||
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
|
||||
}))
|
||||
|
||||
vi.mock('@/components/confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('./git-status-refresh', () => ({
|
||||
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
|
||||
return vi.fn().mockResolvedValue(value)
|
||||
}
|
||||
|
||||
const readySummary: GitBranchCompareSummary = {
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
baseOid: 'base-oid',
|
||||
compareRef: 'feature/line-total',
|
||||
headOid: 'head-1',
|
||||
mergeBase: MERGE_BASE,
|
||||
changedFiles: 2,
|
||||
commitsAhead: 1,
|
||||
status: 'ready'
|
||||
}
|
||||
|
||||
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
vi.clearAllMocks()
|
||||
mocks.activeRepo.kind = 'git'
|
||||
mocks.state = {
|
||||
activeWorktreeId: mocks.activeWorktree.id,
|
||||
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
|
||||
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
|
||||
repos: [mocks.activeRepo],
|
||||
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
|
||||
rightSidebarOpen: true,
|
||||
rightSidebarTab: 'source-control',
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
inFlightRemoteOpKind: null,
|
||||
settings: null,
|
||||
hostedReviewCache: {},
|
||||
prCache: {},
|
||||
commitMessageGenerationRecords: {},
|
||||
pullRequestGenerationRecords: {},
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
getDiffComments: vi.fn(() => []),
|
||||
updateSettings: noopAsync(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
fetchHostedReviewForBranch: noopAsync(),
|
||||
getHostedReviewCreationEligibility: noopAsync(null),
|
||||
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
|
||||
updateWorktreeMeta: noopAsync(),
|
||||
fetchPRForBranch: noopAsync(),
|
||||
enqueueGitHubPRRefresh: vi.fn(),
|
||||
updateRepo: noopAsync(),
|
||||
setGitStatus: vi.fn(),
|
||||
updateWorktreeGitIdentity: vi.fn(),
|
||||
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
|
||||
setGitBranchCompareResult: vi.fn(),
|
||||
clearGitBranchCompare: vi.fn(),
|
||||
fetchUpstreamStatus: noopAsync(),
|
||||
setUpstreamStatus: vi.fn(),
|
||||
pushBranch: noopAsync(),
|
||||
pullBranch: noopAsync(),
|
||||
fastForwardBranch: noopAsync(),
|
||||
syncBranch: noopAsync(),
|
||||
rebaseFromBase: noopAsync(),
|
||||
fetchBranch: noopAsync(),
|
||||
revealInExplorer: vi.fn(),
|
||||
trackConflictPath: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
setEditorViewMode: vi.fn(),
|
||||
setMarkdownViewMode: vi.fn(),
|
||||
setPendingEditorReveal: vi.fn(),
|
||||
openConflictFile: vi.fn(),
|
||||
openConflictReview: vi.fn(),
|
||||
openBranchDiff: vi.fn(),
|
||||
createEmptySplitGroup: vi.fn(() => 'group-2'),
|
||||
openAllDiffs: vi.fn(),
|
||||
openBranchAllDiffs: vi.fn(),
|
||||
openCommitAllDiffs: vi.fn(),
|
||||
deleteDiffComment: noopAsync(true),
|
||||
clearDiffComments: noopAsync(true),
|
||||
clearDiffCommentsForFile: noopAsync(true),
|
||||
setScrollToDiffCommentId: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
setRightSidebarTab: vi.fn(),
|
||||
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
|
||||
setCommitMessageGenerationRecord: vi.fn(),
|
||||
updateCommitMessageGenerationRecord: vi.fn(),
|
||||
pruneCommitMessageGenerationRecords: vi.fn(),
|
||||
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
|
||||
setPullRequestGenerationRecord: vi.fn(),
|
||||
updatePullRequestGenerationRecord: vi.fn(),
|
||||
prunePullRequestGenerationRecords: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
resetState()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
})
|
||||
|
||||
function renderSourceControl(): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<SourceControl />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function chip(): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>('[data-testid="source-control-branch-line-total"]')
|
||||
}
|
||||
|
||||
describe('SourceControl branch line total request gate', () => {
|
||||
it('asks for a total when the panel is visible and compare is ready', () => {
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
|
||||
})
|
||||
|
||||
it('asks for nothing while the sidebar is closed', () => {
|
||||
resetState({ rightSidebarOpen: false })
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing while another right-sidebar tab is showing', () => {
|
||||
resetState({ rightSidebarTab: 'checks' })
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing until branch compare is ready', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing when compare has no merge base', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing in a folder workspace', () => {
|
||||
resetState()
|
||||
mocks.activeRepo.kind = 'folder'
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releases the gate when the panel unmounts', () => {
|
||||
renderSourceControl()
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
|
||||
|
||||
act(() => root.render(<TooltipProvider>{null}</TooltipProvider>))
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SourceControl branch line total chip', () => {
|
||||
it('renders a total measured against the current fork point', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions')
|
||||
// Grouping is pinned to the app locale (`en`), not the runner's host locale.
|
||||
expect(chip()?.textContent).toBe('+8,259-670')
|
||||
})
|
||||
|
||||
it('drops a total whose fork point has since moved', () => {
|
||||
// Why: status and branch compare refresh on different cadences, so a total
|
||||
// can outlive the merge base it measured. Hidden beats a stale number.
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a published total while branch compare has no ready summary', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' }
|
||||
},
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when no total was published', () => {
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for an exact zero total', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the zero half of a one-sided total', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()?.textContent).toBe('+42')
|
||||
expect(chip()?.getAttribute('aria-label')).toBe('42 additions')
|
||||
})
|
||||
})
|
||||
|
|
@ -104,6 +104,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
|||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
|||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ import {
|
|||
} from './source-control-hosted-review-push-target'
|
||||
import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url'
|
||||
import { parseRemoteRepo } from './source-control-remote-repo'
|
||||
import { setBranchLineTotalMergeBase } from './branch-line-total-request-gate'
|
||||
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
import {
|
||||
createRunningCommitMessageGenerationRecord,
|
||||
|
|
@ -835,6 +836,15 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const branchSummary = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null
|
||||
)
|
||||
const publishedBranchLineTotal = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null
|
||||
)
|
||||
// Why: status and branch compare refresh on different cadences, so a total can
|
||||
// outlive the fork point it measured. Drop it rather than render a stale number.
|
||||
const branchLineTotal =
|
||||
publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase
|
||||
? publishedBranchLineTotal
|
||||
: null
|
||||
const conflictOperation = useAppStore((s) =>
|
||||
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
|
||||
)
|
||||
|
|
@ -1252,6 +1262,22 @@ function SourceControlInner(): React.JSX.Element {
|
|||
// Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer.
|
||||
const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen
|
||||
|
||||
// Why: the merge base IS the request gate — no OID on the status request means
|
||||
// the host runs no ranged diff, so a hidden chip costs a background worktree nothing.
|
||||
const requestedBranchLineTotalMergeBase =
|
||||
isBranchVisible && !isFolder && branchSummary?.status === 'ready'
|
||||
? branchSummary.mergeBase
|
||||
: null
|
||||
useEffect(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
setBranchLineTotalMergeBase(activeWorktreeId, requestedBranchLineTotalMergeBase)
|
||||
return () => {
|
||||
setBranchLineTotalMergeBase(activeWorktreeId, null)
|
||||
}
|
||||
}, [activeWorktreeId, requestedBranchLineTotalMergeBase])
|
||||
|
||||
const refreshActiveGitStatus = useCallback(
|
||||
async (signal?: AbortSignal): Promise<void> => {
|
||||
if (!activeWorktreeId || !worktreePath || isFolder) {
|
||||
|
|
@ -5549,6 +5575,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
diffCommentCount={diffCommentCount}
|
||||
onExpandNotes={() => setDiffCommentsExpanded(true)}
|
||||
branchSummary={branchSummary}
|
||||
branchLineTotal={branchLineTotal}
|
||||
compareBaseRef={compareBaseRef}
|
||||
headDisplay={gitIdentityDisplay}
|
||||
upstreamStatus={remoteStatus}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
|||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearBranchLineTotalRequestGateForTests,
|
||||
getBranchLineTotalMergeBase,
|
||||
setBranchLineTotalMergeBase
|
||||
} from './branch-line-total-request-gate'
|
||||
|
||||
describe('branch line total request gate', () => {
|
||||
beforeEach(() => {
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
})
|
||||
|
||||
it('reports no merge base for a worktree that never opened the chip', () => {
|
||||
expect(getBranchLineTotalMergeBase('wt-never-gated')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores the merge base a visible chip asked for', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-1')
|
||||
|
||||
expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-1')
|
||||
})
|
||||
|
||||
it('replaces the merge base when the fork point moves', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-1')
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-2')
|
||||
|
||||
expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-2')
|
||||
})
|
||||
|
||||
it('deletes the entry when the chip is hidden', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-1')
|
||||
setBranchLineTotalMergeBase('wt-1', null)
|
||||
|
||||
expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats an empty merge base as no gate rather than an empty request param', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-1')
|
||||
setBranchLineTotalMergeBase('wt-1', '')
|
||||
|
||||
expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps entries independent per worktree', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'merge-base-1')
|
||||
setBranchLineTotalMergeBase('wt-2', 'merge-base-2')
|
||||
setBranchLineTotalMergeBase('wt-1', null)
|
||||
|
||||
expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined()
|
||||
expect(getBranchLineTotalMergeBase('wt-2')).toBe('merge-base-2')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Why: several independent code paths call `git.status`; if only some carried
|
||||
// the merge base the branch line-total chip would blank out between polls. One
|
||||
// registry keeps every path asking for the same thing.
|
||||
// Only the visible worktree's SourceControl is mounted and its effect cleanup
|
||||
// deletes on every change, so this holds ~1 entry and needs no eviction.
|
||||
const branchLineTotalMergeBaseByWorktree = new Map<string, string>()
|
||||
|
||||
/** `null` deletes the entry — that absence is the visibility gate (no merge base ⇒ no host cost). */
|
||||
export function setBranchLineTotalMergeBase(worktreeId: string, mergeBase: string | null): void {
|
||||
if (!mergeBase) {
|
||||
branchLineTotalMergeBaseByWorktree.delete(worktreeId)
|
||||
return
|
||||
}
|
||||
branchLineTotalMergeBaseByWorktree.set(worktreeId, mergeBase)
|
||||
}
|
||||
|
||||
export function getBranchLineTotalMergeBase(worktreeId: string): string | undefined {
|
||||
return branchLineTotalMergeBaseByWorktree.get(worktreeId)
|
||||
}
|
||||
|
||||
export function clearBranchLineTotalRequestGateForTests(): void {
|
||||
branchLineTotalMergeBaseByWorktree.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearBranchLineTotalRequestGateForTests,
|
||||
setBranchLineTotalMergeBase
|
||||
} from './branch-line-total-request-gate'
|
||||
import {
|
||||
clearGitStatusRefreshOrderingForTests,
|
||||
refreshGitStatusForWorktree,
|
||||
refreshGitStatusForWorktreeStrict,
|
||||
type GitStatusRefreshDeps
|
||||
} from './git-status-refresh'
|
||||
import type { GitStatusResult } from '../../../../shared/types'
|
||||
|
||||
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
|
||||
|
||||
function makeDeps(): GitStatusRefreshDeps {
|
||||
return {
|
||||
setGitStatus: vi.fn(),
|
||||
updateWorktreeGitIdentity: vi.fn(),
|
||||
setUpstreamStatus: vi.fn(),
|
||||
fetchUpstreamStatus: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
function stubGitStatus(): ReturnType<typeof vi.fn> {
|
||||
const status: GitStatusResult = {
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
}
|
||||
const gitStatus = vi.fn().mockResolvedValue(status)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: {
|
||||
status: gitStatus,
|
||||
cancelStatus: vi.fn().mockResolvedValue(undefined),
|
||||
upstreamStatus: vi.fn().mockResolvedValue({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
}
|
||||
}
|
||||
})
|
||||
return gitStatus
|
||||
}
|
||||
|
||||
describe('branch line total request gate on git status refreshes', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
clearGitStatusRefreshOrderingForTests()
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
})
|
||||
|
||||
it('omits the merge base when no chip is asking for a total', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-hidden',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
deps: makeDeps()
|
||||
})
|
||||
|
||||
// Why: no OID on the request is the whole performance contract — the host
|
||||
// runs no ranged diff, so a background worktree costs nothing.
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the merge base for a worktree whose chip was hidden again', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-1', MERGE_BASE)
|
||||
setBranchLineTotalMergeBase('wt-1', null)
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send one worktree gate on another worktree status pass', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-visible', MERGE_BASE)
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-background',
|
||||
worktreePath: '/other-repo',
|
||||
deps: makeDeps()
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/other-repo',
|
||||
connectionId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the merge base on a plain refresh that passes no request options', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-1', MERGE_BASE)
|
||||
|
||||
// Why: this path used to build no options object at all, so the gate was
|
||||
// silently dropped on the most common (fs-watcher) refresh.
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
deps: makeDeps()
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the merge base alongside line-stat reuse', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-1', MERGE_BASE)
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps(),
|
||||
request: { reuseLineStats: true }
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
reuseLineStats: true,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the merge base on an abortable refresh', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-1', MERGE_BASE)
|
||||
const controller = new AbortController()
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps(),
|
||||
request: { signal: controller.signal }
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreePath: '/repo',
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('re-reads the gate on every pass so a moved fork point is not sent twice', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-1', MERGE_BASE)
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
})
|
||||
setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base')
|
||||
await refreshGitStatusForWorktree({
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenNthCalledWith(1, {
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
expect(gitStatus).toHaveBeenNthCalledWith(2, {
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
branchLineTotalMergeBase: 'rebased-merge-base'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the merge base from a strict refresh when no chip is visible', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
worktreeId: 'wt-strict',
|
||||
worktreePath: '/repo',
|
||||
deps: { ...makeDeps(), fetchUpstreamStatus: undefined }
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a strict refresh is exactly the commit/push/sync that moves the number;
|
||||
// skipping the gate there would blank the chip until the next automatic poll.
|
||||
it('sends the merge base on a strict refresh', async () => {
|
||||
const gitStatus = stubGitStatus()
|
||||
setBranchLineTotalMergeBase('wt-strict', MERGE_BASE)
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
worktreeId: 'wt-strict',
|
||||
worktreePath: '/repo',
|
||||
deps: { ...makeDeps(), fetchUpstreamStatus: undefined }
|
||||
})
|
||||
|
||||
expect(gitStatus).toHaveBeenCalledWith({
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
bypassEffectiveUpstreamNegativeCache: true,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
|
||||
import { getBranchLineTotalMergeBase } from './branch-line-total-request-gate'
|
||||
import {
|
||||
clearAutomaticPushTargetUpstreamStatusCache,
|
||||
getCachedAutomaticPushTargetUpstreamStatus,
|
||||
|
|
@ -121,6 +122,9 @@ export async function refreshGitStatusForWorktree({
|
|||
}
|
||||
}): Promise<void> {
|
||||
const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId)
|
||||
// Why: every status path must carry the merge base, or the chip blanks out on
|
||||
// whichever poll happened to omit it.
|
||||
const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId)
|
||||
try {
|
||||
const status = (await getRuntimeGitStatus(
|
||||
{
|
||||
|
|
@ -129,12 +133,11 @@ export async function refreshGitStatusForWorktree({
|
|||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
request
|
||||
? {
|
||||
...(request.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(request.signal ? { signal: request.signal } : {})
|
||||
}
|
||||
: undefined
|
||||
{
|
||||
...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(request?.signal ? { signal: request.signal } : {}),
|
||||
...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {})
|
||||
}
|
||||
)) as GitStatusResult
|
||||
|
||||
if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) {
|
||||
|
|
@ -247,6 +250,7 @@ export async function refreshGitStatusForWorktreeStrict({
|
|||
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
|
||||
beginStrictUpstreamRefresh(worktreeId)
|
||||
clearAutomaticPushTargetUpstreamStatusCache()
|
||||
const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId)
|
||||
const status = (await getRuntimeGitStatus(
|
||||
{
|
||||
settings,
|
||||
|
|
@ -257,7 +261,12 @@ export async function refreshGitStatusForWorktreeStrict({
|
|||
{
|
||||
// Why: strict refreshes are user-triggered reconciliation and must not reuse
|
||||
// automatic polling's no-upstream backoff window.
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
bypassEffectiveUpstreamNegativeCache: true,
|
||||
// Why: an absent total clears the chip, so a path that skipped the gate
|
||||
// would blank it right after the commit or push that moved the number.
|
||||
...(strictBranchLineTotalMergeBase
|
||||
? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase }
|
||||
: {})
|
||||
}
|
||||
)) as GitStatusResult
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SourceControlBranchContextRow } from './source-control-branch-context-row'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/types'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
|
|
@ -215,3 +216,144 @@ describe('SourceControlBranchContextRow', () => {
|
|||
expect(markup).toContain('aria-label="Open review page in browser"')
|
||||
})
|
||||
})
|
||||
|
||||
function renderWithLineTotal(
|
||||
branchLineTotal: GitBranchLineTotal | null | undefined,
|
||||
summary: GitBranchCompareSummary | null = readySummary
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={summary}
|
||||
compareBaseRef={null}
|
||||
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
|
||||
manualReviewUrl="https://example.test/review"
|
||||
branchLineTotal={branchLineTotal}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SourceControlBranchContextRow branch line total', () => {
|
||||
it('renders both halves with grouped digits and a spoken label', () => {
|
||||
const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' })
|
||||
|
||||
expect(markup).toContain('+8,259')
|
||||
expect(markup).toContain('-670')
|
||||
// Label reads raw digits; the grouped spans are decoration.
|
||||
expect(markup).toContain('aria-label="8259 additions, 670 deletions"')
|
||||
expect(markup).toContain('tabular-nums')
|
||||
expect(markup).toContain('text-[color:var(--git-decoration-added)]')
|
||||
expect(markup).toContain('text-[color:var(--git-decoration-deleted)]')
|
||||
// Not clickable in v1 — the chip's scope differs from openBranchAllDiffs.
|
||||
expect(markup).not.toContain('<button type="button" data-testid="source-control-branch')
|
||||
})
|
||||
|
||||
it('keeps full precision instead of a compact 8.3k form', () => {
|
||||
const markup = renderWithLineTotal({ added: 123456, removed: 0, mergeBase: 'base' })
|
||||
|
||||
expect(markup).toContain(`+${(123456).toLocaleString()}`)
|
||||
expect(markup).not.toContain('123k')
|
||||
expect(markup).not.toContain('123.5')
|
||||
})
|
||||
|
||||
it('omits the zero half rather than printing +42 -0', () => {
|
||||
const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' })
|
||||
expect(addedOnly).toContain('>+42<')
|
||||
expect(addedOnly).not.toContain('>-0<')
|
||||
expect(addedOnly).toContain('aria-label="42 additions"')
|
||||
|
||||
const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' })
|
||||
expect(removedOnly).toContain('>-7<')
|
||||
expect(removedOnly).not.toContain('>+0<')
|
||||
expect(removedOnly).toContain('aria-label="7 deletions"')
|
||||
})
|
||||
|
||||
it('hides the chip when both counts are zero', () => {
|
||||
const markup = renderWithLineTotal({ added: 0, removed: 0, mergeBase: 'base' })
|
||||
|
||||
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
|
||||
expect(markup).not.toContain('>+0<')
|
||||
expect(markup).not.toContain('>-0<')
|
||||
})
|
||||
|
||||
it('hides the chip when the total is absent', () => {
|
||||
for (const total of [null, undefined]) {
|
||||
const markup = renderWithLineTotal(total)
|
||||
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
|
||||
expect(markup).not.toContain('NaN')
|
||||
}
|
||||
})
|
||||
|
||||
// Lines measure the branch's work, commits measure the comparison, so each sits
|
||||
// on the line that names its subject. Adjacency is what made them read as one
|
||||
// number in the first place.
|
||||
it('puts the chip on the head line, ahead of the base line and its commit count', () => {
|
||||
const markup = renderWithLineTotal(
|
||||
{ added: 8259, removed: 670, mergeBase: 'base' },
|
||||
{
|
||||
...readySummary,
|
||||
commitsAhead: 2
|
||||
}
|
||||
)
|
||||
const headIndex = markup.indexOf('data-testid="source-control-head-identity"')
|
||||
const chipIndex = markup.indexOf('data-testid="source-control-branch-line-total"')
|
||||
const aheadIndex = markup.indexOf('↑2')
|
||||
const reviewIndex = markup.indexOf('aria-label="Open review page in browser"')
|
||||
|
||||
expect(headIndex).toBeGreaterThan(-1)
|
||||
expect(chipIndex).toBeGreaterThan(headIndex)
|
||||
expect(aheadIndex).toBeGreaterThan(chipIndex)
|
||||
expect(reviewIndex).toBeGreaterThan(aheadIndex)
|
||||
})
|
||||
|
||||
it('keeps the ahead count out of the line-total colors', () => {
|
||||
const markup = renderWithLineTotal(
|
||||
{ added: 8259, removed: 670, mergeBase: 'base' },
|
||||
{ ...readySummary, commitsAhead: 2 }
|
||||
)
|
||||
// The `↑2` span must carry the muted class, not added-green — two adjacent
|
||||
// green numbers counting different units is the bug this guards.
|
||||
const aheadSpan = markup.slice(
|
||||
markup.lastIndexOf('<span', markup.indexOf('↑2')),
|
||||
markup.indexOf('↑2')
|
||||
)
|
||||
|
||||
expect(aheadSpan).toContain('text-muted-foreground/70')
|
||||
expect(aheadSpan).not.toContain('--git-decoration-added')
|
||||
})
|
||||
|
||||
it('folds the chip onto the base line when there is no head identity', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={readySummary}
|
||||
compareBaseRef={null}
|
||||
headDisplay={null}
|
||||
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-testid="source-control-branch-line-total"')
|
||||
expect(markup).toContain('+8,259')
|
||||
})
|
||||
|
||||
it('stays hidden while compare is loading or failed', () => {
|
||||
const total: GitBranchLineTotal = { added: 8259, removed: 670, mergeBase: 'base' }
|
||||
|
||||
for (const summary of [
|
||||
null,
|
||||
{ ...readySummary, status: 'loading' } as GitBranchCompareSummary,
|
||||
{
|
||||
...readySummary,
|
||||
status: 'error',
|
||||
errorMessage: 'Could not compare against base'
|
||||
} as GitBranchCompareSummary
|
||||
]) {
|
||||
const markup = renderWithLineTotal(total, summary)
|
||||
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
|
||||
expect(markup).not.toContain('+8,259')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React from 'react'
|
||||
import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { SourceControlHeaderIconButton } from './source-control-header-icon-button'
|
||||
import { SourceControlBranchLineTotalChip } from './source-control-branch-line-total-chip'
|
||||
import {
|
||||
buildSourceControlBranchContextStats,
|
||||
formatSourceControlRefLabel,
|
||||
|
|
@ -49,9 +51,7 @@ function ContextStat({
|
|||
}): React.JSX.Element {
|
||||
const className = cn(
|
||||
'shrink-0 tabular-nums text-muted-foreground',
|
||||
stat.tone === 'muted' && 'text-muted-foreground/70',
|
||||
stat.tone === 'ahead' && 'text-[color:var(--git-decoration-added)]',
|
||||
stat.tone === 'behind' && 'text-[color:var(--git-decoration-deleted)]'
|
||||
stat.tone === 'muted' && 'text-muted-foreground/70'
|
||||
)
|
||||
|
||||
if (!stat.title) {
|
||||
|
|
@ -218,7 +218,8 @@ function StackedCompareFlow({
|
|||
onChangeBaseRef,
|
||||
changeBaseTitle,
|
||||
leading,
|
||||
trailing
|
||||
trailing,
|
||||
headTrailing
|
||||
}: {
|
||||
headDisplay: WorktreeGitIdentityDisplay | null
|
||||
baseRef: string
|
||||
|
|
@ -227,6 +228,7 @@ function StackedCompareFlow({
|
|||
changeBaseTitle: string
|
||||
leading?: React.ReactNode
|
||||
trailing?: React.ReactNode
|
||||
headTrailing?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
if (!headDisplay) {
|
||||
return (
|
||||
|
|
@ -237,15 +239,27 @@ function StackedCompareFlow({
|
|||
changeBaseTitle={changeBaseTitle}
|
||||
showArrow={false}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
// Why: no head line exists to host it, so fold it onto the base line
|
||||
// rather than dropping it.
|
||||
trailing={
|
||||
<>
|
||||
{headTrailing}
|
||||
{trailing}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="min-w-0">
|
||||
<HeadIdentity display={headDisplay} />
|
||||
{/* Why: the line total belongs beside HEAD — it measures this branch's work.
|
||||
The base line keeps the commit count, which measures the comparison. */}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1">
|
||||
<HeadIdentity display={headDisplay} />
|
||||
</span>
|
||||
{headTrailing}
|
||||
</div>
|
||||
{/* Why: spinner/actions sit on the base line — they describe compare state, not HEAD. */}
|
||||
<BaseLine
|
||||
|
|
@ -267,6 +281,7 @@ export function SourceControlBranchContextRow({
|
|||
headDisplay = null,
|
||||
upstreamStatus,
|
||||
manualReviewUrl,
|
||||
branchLineTotal,
|
||||
onChangeBaseRef,
|
||||
onRetry
|
||||
}: {
|
||||
|
|
@ -275,6 +290,7 @@ export function SourceControlBranchContextRow({
|
|||
headDisplay?: WorktreeGitIdentityDisplay | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
branchLineTotal?: GitBranchLineTotal | null
|
||||
onChangeBaseRef: () => void
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element | null {
|
||||
|
|
@ -397,6 +413,7 @@ export function SourceControlBranchContextRow({
|
|||
onChangeBaseRef={onChangeBaseRef}
|
||||
changeBaseTitle={changeBaseTitle}
|
||||
trailing={trailing}
|
||||
headTrailing={<SourceControlBranchLineTotalChip branchLineTotal={branchLineTotal} />}
|
||||
/>
|
||||
</CompareFlowGroup>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,11 +30,15 @@ function formatBehindBaseTitle(count: number, baseRef: string): string {
|
|||
)
|
||||
}
|
||||
|
||||
// Why: ahead/behind carry no color of their own. Green and red are reserved for
|
||||
// the line-total chip that sits beside them — an `↑1` in added-green next to
|
||||
// `+1,114` reads as one quantity when they count different things (commits vs
|
||||
// lines). The ↑/↓ glyph already carries direction.
|
||||
export type SourceControlBranchContextStat = {
|
||||
key: string
|
||||
label: string
|
||||
title?: string
|
||||
tone: 'default' | 'ahead' | 'behind' | 'muted'
|
||||
tone: 'default' | 'muted'
|
||||
}
|
||||
|
||||
export function resolveSourceControlDisplayedBaseRef(
|
||||
|
|
@ -111,7 +115,7 @@ export function buildSourceControlBranchContextStats({
|
|||
key: 'upstream-ahead',
|
||||
label: `↑${upstreamStatus.ahead}`,
|
||||
title: formatAheadOfBaseTitle(upstreamStatus.ahead, upstreamLabel),
|
||||
tone: 'ahead'
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
if (upstreamStatus.behind > 0) {
|
||||
|
|
@ -119,7 +123,7 @@ export function buildSourceControlBranchContextStats({
|
|||
key: 'upstream-behind',
|
||||
label: `↓${upstreamStatus.behind}`,
|
||||
title: formatBehindBaseTitle(upstreamStatus.behind, upstreamLabel),
|
||||
tone: 'behind'
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +143,7 @@ export function buildSourceControlBranchContextStats({
|
|||
key: 'compare-ahead',
|
||||
label: `↑${commitsAhead}`,
|
||||
title: formatAheadOfBaseTitle(commitsAhead, baseLabel),
|
||||
tone: 'ahead'
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
import { getIntlLocale, translate } from '@/i18n/i18n'
|
||||
|
||||
// Why: raw digits, not the grouped display string — screen readers announce
|
||||
// "8,259" as two numbers in several locales.
|
||||
function buildAccessibleLabel(added: number, removed: number): string {
|
||||
if (added > 0 && removed > 0) {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b',
|
||||
'{{value0}} additions, {{value1}} deletions',
|
||||
{ value0: added, value1: removed }
|
||||
)
|
||||
}
|
||||
if (added > 0) {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666',
|
||||
'{{value0}} additions',
|
||||
{ value0: added }
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d',
|
||||
'{{value0}} deletions',
|
||||
{ value0: removed }
|
||||
)
|
||||
}
|
||||
|
||||
// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no
|
||||
// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower.
|
||||
export const SourceControlBranchLineTotalChip = React.memo(
|
||||
function SourceControlBranchLineTotalChip({
|
||||
branchLineTotal
|
||||
}: {
|
||||
branchLineTotal: GitBranchLineTotal | null | undefined
|
||||
}): React.JSX.Element | null {
|
||||
const added = branchLineTotal?.added ?? 0
|
||||
const removed = branchLineTotal?.removed ?? 0
|
||||
const hasAdded = added > 0
|
||||
const hasRemoved = removed > 0
|
||||
// Full precision and app-locale-aware; a status tick that leaves the counts
|
||||
// alone must not rebuild these strings.
|
||||
const locale = getIntlLocale()
|
||||
const addedText = useMemo(() => added.toLocaleString(locale), [added, locale])
|
||||
const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale])
|
||||
const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed])
|
||||
|
||||
if (!hasAdded && !hasRemoved) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone
|
||||
// keeps digits from jittering between refreshes.
|
||||
return (
|
||||
<span
|
||||
role="group"
|
||||
aria-label={accessibleLabel}
|
||||
data-testid="source-control-branch-line-total"
|
||||
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{hasAdded ? (
|
||||
<span aria-hidden="true" className="text-[color:var(--git-decoration-added)]">
|
||||
+{addedText}
|
||||
</span>
|
||||
) : null}
|
||||
{hasRemoved ? (
|
||||
<span aria-hidden="true" className="text-[color:var(--git-decoration-deleted)]">
|
||||
-{removedText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SourceControlHeaderToolbar } from './source-control-header-toolbar'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/types'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import type { PrimaryAction } from './source-control-primary-action'
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ function renderToolbar(options?: {
|
|||
headDisplay?: WorktreeGitIdentityDisplay | null
|
||||
branchSummary?: GitBranchCompareSummary | null
|
||||
compareBaseRef?: string | null
|
||||
branchLineTotal?: GitBranchLineTotal | null
|
||||
}): string {
|
||||
return renderToStaticMarkup(
|
||||
<SourceControlHeaderToolbar
|
||||
|
|
@ -66,6 +68,7 @@ function renderToolbar(options?: {
|
|||
? { kind: 'branch', branchName: 'brennanb2025/source-control-branch-name' }
|
||||
: options.headDisplay
|
||||
}
|
||||
branchLineTotal={options?.branchLineTotal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -125,4 +128,17 @@ describe('SourceControlHeaderToolbar branch identity', () => {
|
|||
expect(markup).not.toContain('→')
|
||||
expect(markup).toContain('Create PR')
|
||||
})
|
||||
|
||||
it('threads the branch line total down to the base-ref line', () => {
|
||||
const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } })
|
||||
|
||||
expect(markup).toContain('aria-label="24 additions, 3 deletions"')
|
||||
expect(markup).toContain('+24')
|
||||
expect(markup).toContain('-3')
|
||||
})
|
||||
|
||||
it('renders exactly as before when no branch line total is supplied', () => {
|
||||
expect(renderToolbar({ branchLineTotal: undefined })).toBe(renderToolbar())
|
||||
expect(renderToolbar()).not.toContain('data-testid="source-control-branch-line-total"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
GitUpstreamStatus,
|
||||
SourceControlViewMode
|
||||
} from '../../../../shared/types'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { PrimaryAction } from './source-control-primary-action'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -41,6 +42,7 @@ type SourceControlHeaderToolbarProps = {
|
|||
headDisplay?: WorktreeGitIdentityDisplay | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
branchLineTotal?: GitBranchLineTotal | null
|
||||
}
|
||||
|
||||
function HostedReviewToolbarLink({
|
||||
|
|
@ -146,7 +148,8 @@ export function SourceControlHeaderToolbar({
|
|||
compareBaseRef,
|
||||
headDisplay = null,
|
||||
upstreamStatus,
|
||||
manualReviewUrl
|
||||
manualReviewUrl,
|
||||
branchLineTotal
|
||||
}: SourceControlHeaderToolbarProps): React.JSX.Element {
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const normalizedFilter = filterQuery.trim()
|
||||
|
|
@ -293,6 +296,7 @@ export function SourceControlHeaderToolbar({
|
|||
headDisplay={headDisplay}
|
||||
upstreamStatus={upstreamStatus}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
branchLineTotal={branchLineTotal}
|
||||
onChangeBaseRef={onChangeBaseRef}
|
||||
onRetry={onRefreshBranchCompare}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11299,6 +11299,17 @@
|
|||
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
|
||||
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
|
||||
}
|
||||
},
|
||||
"branch": {
|
||||
"line": {
|
||||
"total": {
|
||||
"chip": {
|
||||
"daa8e8e59b": "{{value0}} additions, {{value1}} deletions",
|
||||
"8a9b97b666": "{{value0}} additions",
|
||||
"52c366d88d": "{{value0}} deletions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRuntimeGitStatus } from './runtime-git-client'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from './runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
|
||||
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
|
||||
|
||||
const gitStatus = vi.fn()
|
||||
const gitCancelStatus = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
gitStatus.mockReset()
|
||||
gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
|
||||
gitCancelStatus.mockReset()
|
||||
gitCancelStatus.mockResolvedValue(undefined)
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { entries: [], conflictOperation: 'unknown' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { status: gitStatus, cancelStatus: gitCancelStatus },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('branch line total merge base on git status requests', () => {
|
||||
it('forwards the merge base to local git status only when the chip asked for it', async () => {
|
||||
const context = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
}
|
||||
|
||||
await getRuntimeGitStatus(context, { branchLineTotalMergeBase: MERGE_BASE })
|
||||
await getRuntimeGitStatus(context, {})
|
||||
await getRuntimeGitStatus(context)
|
||||
|
||||
expect(gitStatus).toHaveBeenNthCalledWith(1, {
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
expect(gitStatus).toHaveBeenNthCalledWith(2, {
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined
|
||||
})
|
||||
expect(gitStatus).toHaveBeenNthCalledWith(3, {
|
||||
worktreePath: '/repo',
|
||||
connectionId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the merge base through the active runtime environment', async () => {
|
||||
await getRuntimeGitStatus(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
{ branchLineTotalMergeBase: MERGE_BASE }
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.status',
|
||||
params: { worktree: 'id:wt-1', branchLineTotalMergeBase: MERGE_BASE },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the merge base param entirely for a remote status with no visible chip', async () => {
|
||||
// Why: Rule-1 wire safety — an old server must see the exact params it
|
||||
// already understands, and a hidden chip must not cost a remote diff.
|
||||
await getRuntimeGitStatus({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.status',
|
||||
params: { worktree: 'id:wt-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the merge base alongside reuse and cache-bypass flags', async () => {
|
||||
await getRuntimeGitStatus(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
{
|
||||
reuseLineStats: true,
|
||||
bypassEffectiveUpstreamNegativeCache: true,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
}
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'git.status',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
bypassEffectiveUpstreamNegativeCache: true,
|
||||
reuseLineStats: true,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -139,6 +139,7 @@ export async function getRuntimeGitStatus(
|
|||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
branchLineTotalMergeBase?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<GitStatusResult> {
|
||||
|
|
@ -148,6 +149,9 @@ export async function getRuntimeGitStatus(
|
|||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
|
||||
const branchLineTotalArgs = options?.branchLineTotalMergeBase
|
||||
? { branchLineTotalMergeBase: options.branchLineTotalMergeBase }
|
||||
: {}
|
||||
if (target.kind === 'local' || !context.worktreeId) {
|
||||
return callLocalGitStatus(
|
||||
{
|
||||
|
|
@ -155,7 +159,8 @@ export async function getRuntimeGitStatus(
|
|||
connectionId: context.connectionId,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs,
|
||||
...lineStatsReuseArgs
|
||||
...lineStatsReuseArgs,
|
||||
...branchLineTotalArgs
|
||||
},
|
||||
options?.signal
|
||||
)
|
||||
|
|
@ -167,7 +172,8 @@ export async function getRuntimeGitStatus(
|
|||
worktree: toRuntimeWorktreeSelector(context.worktreeId),
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs,
|
||||
...lineStatsReuseArgs
|
||||
...lineStatsReuseArgs,
|
||||
...branchLineTotalArgs
|
||||
},
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createEditorSlice } from './editor'
|
||||
import type { AppState } from '../types'
|
||||
import type { GitStatusResult } from '../../../../shared/types'
|
||||
|
||||
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
|
||||
|
||||
function createEditorStore(): StoreApi<AppState> {
|
||||
// Only the editor slice + activeWorktreeId are needed for these tests.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
tabsByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
}
|
||||
|
||||
function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
|
||||
return {
|
||||
conflictOperation: 'unknown',
|
||||
entries: [{ path: 'src/index.ts', status: 'modified', area: 'unstaged' }],
|
||||
head: 'head-1',
|
||||
ignoredPaths: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('createEditorSlice branch line total', () => {
|
||||
it('stores a total published with the status result', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 8259, removed: 670, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
|
||||
added: 8259,
|
||||
removed: 670,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('stores an exact zero total rather than treating it as absent', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 0, removed: 0, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stored total when a later status omits the field', () => {
|
||||
const store = createEditorStore()
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
// Why: an omitted field means "not known exact" — an old server, a failed
|
||||
// numstat, or a timeout. Keeping the old number renders a confident lie.
|
||||
store.getState().setGitStatus('wt-1', status())
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a stored total when the listing hits the entry cap', () => {
|
||||
const store = createEditorStore()
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
store.getState().setGitStatus(
|
||||
'wt-1',
|
||||
status({
|
||||
entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }],
|
||||
didHitLimit: true,
|
||||
statusLength: 2
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().gitStatusHugeByWorktree['wt-1']).toEqual({ limit: 1 })
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never substitutes zero or NaN for a total an old server never sent', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store.getState().setGitStatus('wt-1', status())
|
||||
|
||||
const stored = store.getState().gitBranchLineTotalByWorktree['wt-1']
|
||||
expect(stored).toBeUndefined()
|
||||
expect(stored ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps totals independent per worktree', () => {
|
||||
const store = createEditorStore()
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-2',
|
||||
status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } })
|
||||
)
|
||||
|
||||
store.getState().setGitStatus('wt-1', status())
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined()
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({
|
||||
added: 1,
|
||||
removed: 1,
|
||||
mergeBase: 'other'
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces the total when the fork point moves', () => {
|
||||
const store = createEditorStore()
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'rebased' } })
|
||||
)
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
|
||||
added: 24,
|
||||
removed: 3,
|
||||
mergeBase: 'rebased'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not produce a new state object for an unchanged status tick', () => {
|
||||
const store = createEditorStore()
|
||||
const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
store.getState().setGitStatus('wt-1', tick)
|
||||
const before = store.getState()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = store.subscribe(listener)
|
||||
|
||||
store.getState().setGitStatus('wt-1', {
|
||||
...tick,
|
||||
entries: [...tick.entries],
|
||||
branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE }
|
||||
})
|
||||
unsubscribe()
|
||||
|
||||
expect(store.getState()).toBe(before)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('produces a new state object when only the total changed', () => {
|
||||
const store = createEditorStore()
|
||||
const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
store.getState().setGitStatus('wt-1', tick)
|
||||
const before = store.getState()
|
||||
|
||||
store.getState().setGitStatus('wt-1', {
|
||||
...tick,
|
||||
branchLineTotal: { added: 25, removed: 3, mergeBase: MERGE_BASE }
|
||||
})
|
||||
|
||||
expect(store.getState()).not.toBe(before)
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({
|
||||
added: 25,
|
||||
removed: 3,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves other worktree entries referentially stable when one total changes', () => {
|
||||
const store = createEditorStore()
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-2',
|
||||
status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } })
|
||||
)
|
||||
const otherTotal = store.getState().gitBranchLineTotalByWorktree['wt-2']
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setGitStatus(
|
||||
'wt-1',
|
||||
status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } })
|
||||
)
|
||||
|
||||
expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toBe(otherTotal)
|
||||
})
|
||||
})
|
||||
|
|
@ -45,6 +45,7 @@ import type {
|
|||
WorkspaceSessionState,
|
||||
WorkspaceVisibleTabType
|
||||
} from '../../../../shared/types'
|
||||
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width'
|
||||
import {
|
||||
|
|
@ -629,6 +630,8 @@ export type EditorSlice = {
|
|||
gitStatusHeadByWorktree: Record<string, string>
|
||||
// Why: set when status hit the entry limit; SCM shows "too many changes" and pauses polling. `{ limit }` when huge, else absent.
|
||||
gitStatusHugeByWorktree: Record<string, { limit: number }>
|
||||
// Why: absent means "not known exact" (stale fork point, old server, capped listing); never fall back to a previous total.
|
||||
gitBranchLineTotalByWorktree: Record<string, GitBranchLineTotal | null>
|
||||
gitIgnoredPathsByWorktree: Record<string, string[]>
|
||||
gitConflictOperationByWorktree: Record<string, GitConflictOperation>
|
||||
trackedConflictPathsByWorktree: Record<string, Record<string, GitConflictKind>>
|
||||
|
|
@ -4086,6 +4089,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
gitStatusByWorktree: {},
|
||||
gitStatusHeadByWorktree: {},
|
||||
gitStatusHugeByWorktree: {},
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitIgnoredPathsByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
trackedConflictPathsByWorktree: {},
|
||||
|
|
@ -4187,6 +4191,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
const prevHuge = s.gitStatusHugeByWorktree[worktreeId]
|
||||
const nextHuge = status.didHitLimit ? { limit: nextEntries.length } : undefined
|
||||
const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null)
|
||||
|
||||
const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null
|
||||
// Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip.
|
||||
const nextBranchLineTotal = status.branchLineTotal ?? null
|
||||
const branchLineTotalUnchanged =
|
||||
prevBranchLineTotal === nextBranchLineTotal ||
|
||||
(prevBranchLineTotal !== null &&
|
||||
nextBranchLineTotal !== null &&
|
||||
prevBranchLineTotal.added === nextBranchLineTotal.added &&
|
||||
prevBranchLineTotal.removed === nextBranchLineTotal.removed &&
|
||||
prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase)
|
||||
|
||||
const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId]
|
||||
const nextStatusHead = getKnownGitHead(status.head)
|
||||
const statusHeadUnchanged = prevStatusHead === nextStatusHead
|
||||
|
|
@ -4206,12 +4222,23 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
operationUnchanged &&
|
||||
ignoredUnchanged &&
|
||||
hugeUnchanged &&
|
||||
branchLineTotalUnchanged &&
|
||||
statusHeadUnchanged &&
|
||||
!shouldInvalidateBranchCompare
|
||||
) {
|
||||
return s
|
||||
}
|
||||
|
||||
const nextBranchLineTotalMap = branchLineTotalUnchanged
|
||||
? s.gitBranchLineTotalByWorktree
|
||||
: nextBranchLineTotal
|
||||
? { ...s.gitBranchLineTotalByWorktree, [worktreeId]: nextBranchLineTotal }
|
||||
: (() => {
|
||||
const copy = { ...s.gitBranchLineTotalByWorktree }
|
||||
delete copy[worktreeId]
|
||||
return copy
|
||||
})()
|
||||
|
||||
const nextHugeMap = hugeUnchanged
|
||||
? s.gitStatusHugeByWorktree
|
||||
: nextHuge
|
||||
|
|
@ -4244,6 +4271,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
return {
|
||||
openFiles: nextOpenFiles,
|
||||
gitStatusHugeByWorktree: nextHugeMap,
|
||||
gitBranchLineTotalByWorktree: nextBranchLineTotalMap,
|
||||
gitStatusHeadByWorktree: nextStatusHeadMap,
|
||||
gitStatusByWorktree: statusUnchanged
|
||||
? s.gitStatusByWorktree
|
||||
|
|
|
|||
|
|
@ -5023,6 +5023,10 @@ describe('removeWorktree state cleanup', () => {
|
|||
'repo1::/path/wt1': 'head-1',
|
||||
'repo1::/path/wt2': 'head-2'
|
||||
},
|
||||
gitBranchLineTotalByWorktree: {
|
||||
'repo1::/path/wt1': { added: 24, removed: 3, mergeBase: 'base-1' },
|
||||
'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
|
||||
},
|
||||
gitIgnoredPathsByWorktree: {
|
||||
'repo1::/path/wt1': ['dist/'],
|
||||
'repo1::/path/wt2': ['coverage/']
|
||||
|
|
@ -5061,6 +5065,9 @@ describe('removeWorktree state cleanup', () => {
|
|||
expect(store.getState().gitStatusHeadByWorktree).toEqual({
|
||||
'repo1::/path/wt2': 'head-2'
|
||||
})
|
||||
expect(store.getState().gitBranchLineTotalByWorktree).toEqual({
|
||||
'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
|
||||
})
|
||||
expect(store.getState().gitIgnoredPathsByWorktree).toEqual({
|
||||
'repo1::/path/wt2': ['coverage/']
|
||||
})
|
||||
|
|
@ -8257,6 +8264,10 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => {
|
|||
'repoA::/a/wt1': 'head-1',
|
||||
'repoA::/a/wt2': 'head-2'
|
||||
},
|
||||
gitBranchLineTotalByWorktree: {
|
||||
'repoA::/a/wt1': { added: 24, removed: 3, mergeBase: 'base-1' },
|
||||
'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
|
||||
},
|
||||
gitBranchCompareRequestStatusHeadByWorktree: {
|
||||
'repoA::/a/wt1': 'head-1',
|
||||
'repoA::/a/wt2': 'head-2'
|
||||
|
|
@ -8300,6 +8311,9 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => {
|
|||
expect(s.editorDrafts).toEqual({ 'file-99': 'other' })
|
||||
expect(s.markdownFrontmatterVisible).toEqual({ 'file-99': true })
|
||||
expect(s.gitStatusHeadByWorktree).toEqual({ 'repoA::/a/wt2': 'head-2' })
|
||||
expect(s.gitBranchLineTotalByWorktree).toEqual({
|
||||
'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' }
|
||||
})
|
||||
expect(s.gitBranchCompareRequestStatusHeadByWorktree).toEqual({
|
||||
'repoA::/a/wt2': 'head-2'
|
||||
})
|
||||
|
|
@ -8808,6 +8822,7 @@ describe('migrateWorktreeIdentity', () => {
|
|||
groupsByWorktree: { [OLD]: [{ id: 'group1', worktreeId: OLD }] },
|
||||
gitStatusByWorktree: { [OLD]: [{ path: 'a.ts' }] },
|
||||
gitStatusHeadByWorktree: { [OLD]: 'head-old' },
|
||||
gitBranchLineTotalByWorktree: { [OLD]: { added: 24, removed: 3, mergeBase: 'base-old' } },
|
||||
gitBranchCompareRequestStatusHeadByWorktree: { [OLD]: 'head-old' },
|
||||
lastVisitedAtByWorktreeId: { [OLD]: 123 },
|
||||
defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true },
|
||||
|
|
@ -8855,6 +8870,12 @@ describe('migrateWorktreeIdentity', () => {
|
|||
expect(s.groupsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW)
|
||||
expect(s.gitStatusByWorktree[NEW]).toEqual([{ path: 'a.ts' }])
|
||||
expect(s.gitStatusHeadByWorktree[NEW]).toBe('head-old')
|
||||
expect(s.gitBranchLineTotalByWorktree[OLD]).toBeUndefined()
|
||||
expect(s.gitBranchLineTotalByWorktree[NEW]).toEqual({
|
||||
added: 24,
|
||||
removed: 3,
|
||||
mergeBase: 'base-old'
|
||||
})
|
||||
expect(s.gitBranchCompareRequestStatusHeadByWorktree[NEW]).toBe('head-old')
|
||||
expect(s.rightSidebarExplorerViewByWorktree[OLD]).toBeUndefined()
|
||||
expect(s.rightSidebarExplorerViewByWorktree[NEW]).toBe('search')
|
||||
|
|
|
|||
|
|
@ -2228,6 +2228,7 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
|
|||
'activeGroupIdByWorktree',
|
||||
'gitStatusByWorktree',
|
||||
'gitStatusHeadByWorktree',
|
||||
'gitBranchLineTotalByWorktree',
|
||||
'gitIgnoredPathsByWorktree',
|
||||
'gitConflictOperationByWorktree',
|
||||
'trackedConflictPathsByWorktree',
|
||||
|
|
@ -2695,6 +2696,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
|
|||
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry).
|
||||
remoteStatusesByWorktree: omitByWorktree(s.remoteStatusesByWorktree),
|
||||
gitStatusHeadByWorktree: omitByWorktree(s.gitStatusHeadByWorktree),
|
||||
gitBranchLineTotalByWorktree: omitByWorktree(s.gitBranchLineTotalByWorktree),
|
||||
gitIgnoredPathsByWorktree: omitByWorktree(s.gitIgnoredPathsByWorktree),
|
||||
gitConflictOperationByWorktree: omitByWorktree(s.gitConflictOperationByWorktree),
|
||||
trackedConflictPathsByWorktree: omitByWorktree(s.trackedConflictPathsByWorktree),
|
||||
|
|
@ -4191,6 +4193,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
delete nextGitStatusByWorktree[worktreeId]
|
||||
const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree }
|
||||
delete nextGitStatusHeadByWorktree[worktreeId]
|
||||
const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree }
|
||||
delete nextGitBranchLineTotalByWorktree[worktreeId]
|
||||
const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree }
|
||||
delete nextGitIgnoredPathsByWorktree[worktreeId]
|
||||
const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree }
|
||||
|
|
@ -4354,6 +4358,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
gitStatusHugeByWorktree: nextGitStatusHugeByWorktree,
|
||||
gitStatusByWorktree: nextGitStatusByWorktree,
|
||||
gitStatusHeadByWorktree: nextGitStatusHeadByWorktree,
|
||||
gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree,
|
||||
gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree,
|
||||
gitConflictOperationByWorktree: nextGitConflictOperationByWorktree,
|
||||
trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree,
|
||||
|
|
|
|||
|
|
@ -3616,6 +3616,99 @@ describe('web git preload API', () => {
|
|||
{ method: 'git.remoteCommitUrl', params: { worktree: 'id:wt-1', sha: TEST_COMMIT_OID } }
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the branch line total merge base only when the chip asked for one', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
const worktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
displayName: 'repo',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
workspaceStatus: 'todo'
|
||||
}
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
runtimeCalls.push({ method, params })
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'repo-1' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
if (method === 'worktree.detectedList') {
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: { repoId: 'repo-1', authoritative: true, worktrees: [worktree] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: { entries: [], conflictOperation: 'unknown' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await globals.window.api.git.status({
|
||||
worktreePath: '/workspace/repo',
|
||||
branchLineTotalMergeBase: TEST_COMMIT_OID
|
||||
})
|
||||
await globals.window.api.git.status({ worktreePath: '/workspace/repo' })
|
||||
|
||||
const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status')
|
||||
// Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`,
|
||||
// which is exactly what the conditional spread must avoid sending.
|
||||
expect(statusCalls).toStrictEqual([
|
||||
{
|
||||
method: 'git.status',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
includeIgnored: undefined,
|
||||
bypassEffectiveUpstreamNegativeCache: undefined,
|
||||
reuseLineStats: undefined,
|
||||
branchLineTotalMergeBase: TEST_COMMIT_OID
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'git.status',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
includeIgnored: undefined,
|
||||
bypassEffectiveUpstreamNegativeCache: undefined,
|
||||
reuseLineStats: undefined
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('web GitHub preload API', () => {
|
||||
|
|
|
|||
|
|
@ -1979,6 +1979,7 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
|||
includeIgnored,
|
||||
bypassEffectiveUpstreamNegativeCache,
|
||||
reuseLineStats,
|
||||
branchLineTotalMergeBase,
|
||||
requestToken
|
||||
}) => {
|
||||
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
||||
|
|
@ -1986,7 +1987,8 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
|||
worktree: toRuntimeWorktreeSelector(worktree.id),
|
||||
includeIgnored,
|
||||
bypassEffectiveUpstreamNegativeCache,
|
||||
reuseLineStats
|
||||
reuseLineStats,
|
||||
...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {})
|
||||
}
|
||||
// Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort.
|
||||
if (!requestToken) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
readCachedGitBranchLineTotal,
|
||||
reuseOrRecomputeGitStatusLineStats
|
||||
} from './git-status-line-stats-cache'
|
||||
|
||||
const MERGE_BASE = 'a'.repeat(40)
|
||||
const CACHE_KEY = 'native\0/repo'
|
||||
const entries = [{ path: 'a.txt', area: 'unstaged', status: 'M', added: 1, removed: 0 }]
|
||||
|
||||
async function runPass(diffDelayMs: number): Promise<{ elapsed: number; total: unknown }> {
|
||||
const cacheKey = CACHE_KEY
|
||||
const started = Date.now()
|
||||
const result = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey,
|
||||
head: 'head-1',
|
||||
entries,
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(cacheKey),
|
||||
reuse: false,
|
||||
isAborted: () => false,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: {
|
||||
mergeBase: MERGE_BASE,
|
||||
compute: () =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ added: 5, removed: 5, mergeBase: MERGE_BASE }), diffDelayMs)
|
||||
)
|
||||
}
|
||||
})
|
||||
return { elapsed: Date.now() - started, total: result.branchLineTotal }
|
||||
}
|
||||
|
||||
describe('branch line total never blocks the status response', () => {
|
||||
it('returns fast when the ranged diff is slow, then publishes it on the next pass', async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
const slow = await runPass(3000)
|
||||
expect(slow.total).toBeUndefined()
|
||||
expect(slow.elapsed).toBeLessThan(1200)
|
||||
|
||||
// Why: poll the cache rather than run another pass — a second recompute
|
||||
// stores, which retires the first pass's still-pending late-arrival token.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(
|
||||
readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })
|
||||
).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
|
||||
},
|
||||
{ timeout: 15000, interval: 50 }
|
||||
)
|
||||
const next = await runPass(3000)
|
||||
expect(next.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
|
||||
}, 20000)
|
||||
|
||||
it('still returns the total inline when the diff is fast', async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
const fast = await runPass(10)
|
||||
expect(fast.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE })
|
||||
expect(fast.elapsed).toBeLessThan(400)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildGitBranchLineTotalDiffArgs,
|
||||
invalidateGitBranchLineTotalInFlight,
|
||||
computeGitBranchLineTotal,
|
||||
GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS,
|
||||
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
|
||||
isGitBranchLineTotalMergeBase,
|
||||
readGitBranchLineTotalMergeBaseParam,
|
||||
sumGitBranchLineTotal
|
||||
} from './git-branch-line-total'
|
||||
import type { GitLineStats } from './git-uncommitted-line-stats'
|
||||
|
||||
const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4'
|
||||
const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c'
|
||||
|
||||
function statsMap(entries: Record<string, GitLineStats>): ReadonlyMap<string, GitLineStats> {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
async function createWorktreeDir(files: Record<string, string> = {}): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-'))
|
||||
tempRoots.push(root)
|
||||
for (const [relativePath, contents] of Object.entries(files)) {
|
||||
await writeFile(path.join(root, relativePath), contents)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('sumGitBranchLineTotal', () => {
|
||||
it('counts binary entries as zero rather than NaN', () => {
|
||||
expect(
|
||||
sumGitBranchLineTotal({
|
||||
mergeBase: MERGE_BASE,
|
||||
tracked: statsMap({
|
||||
'blob.bin': { added: undefined, removed: undefined },
|
||||
'src/a.ts': { added: 4, removed: 1 }
|
||||
}),
|
||||
untracked: statsMap({})
|
||||
})
|
||||
).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE })
|
||||
})
|
||||
|
||||
it('counts a half-binary entry on the side git did report', () => {
|
||||
expect(
|
||||
sumGitBranchLineTotal({
|
||||
mergeBase: MERGE_BASE,
|
||||
tracked: statsMap({ 'odd.txt': { added: 3 } }),
|
||||
untracked: statsMap({ 'new.bin': {} })
|
||||
})
|
||||
).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE })
|
||||
})
|
||||
|
||||
it('adds untracked additions on top of the tracked range and echoes the merge base', () => {
|
||||
expect(
|
||||
sumGitBranchLineTotal({
|
||||
mergeBase: MERGE_BASE,
|
||||
tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }),
|
||||
untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } })
|
||||
})
|
||||
).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE })
|
||||
})
|
||||
|
||||
it('returns an all-zero total for a pure rename rather than omitting it', () => {
|
||||
expect(
|
||||
sumGitBranchLineTotal({
|
||||
mergeBase: MERGE_BASE,
|
||||
tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }),
|
||||
untracked: statsMap({})
|
||||
})
|
||||
).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE })
|
||||
})
|
||||
})
|
||||
|
||||
describe('merge base param validation', () => {
|
||||
it('accepts abbreviated and full object names', () => {
|
||||
for (const value of ['abc1234', MERGE_BASE, 'f'.repeat(64)]) {
|
||||
expect(isGitBranchLineTotalMergeBase(value)).toBe(true)
|
||||
expect(readGitBranchLineTotalMergeBaseParam(value)).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects anything that is not an object name, notably flag-shaped input', () => {
|
||||
const rejected: unknown[] = [
|
||||
'--upload-pack=x',
|
||||
'-M',
|
||||
'HEAD',
|
||||
'',
|
||||
'origin/main',
|
||||
'A1B2C3D',
|
||||
'abc123',
|
||||
'f'.repeat(65),
|
||||
'abc1234 --output=/tmp/x',
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
{ toString: () => MERGE_BASE }
|
||||
]
|
||||
for (const value of rejected) {
|
||||
expect(isGitBranchLineTotalMergeBase(value)).toBe(false)
|
||||
expect(readGitBranchLineTotalMergeBaseParam(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGitBranchLineTotalDiffArgs', () => {
|
||||
it('diffs the merge base against the working tree with -M only', () => {
|
||||
expect(buildGitBranchLineTotalDiffArgs(MERGE_BASE)).toEqual([
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'-z',
|
||||
'--numstat',
|
||||
'-M',
|
||||
MERGE_BASE,
|
||||
'--'
|
||||
])
|
||||
})
|
||||
|
||||
it('never asks for --cached or -C, which would change the measured range', () => {
|
||||
const args = buildGitBranchLineTotalDiffArgs(MERGE_BASE)
|
||||
expect(args).not.toContain('--cached')
|
||||
expect(args).not.toContain('-C')
|
||||
expect(args.at(-1)).toBe('--')
|
||||
})
|
||||
|
||||
it('exposes a finite timeout budget so a huge branch cannot block the status response', () => {
|
||||
expect(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeGitBranchLineTotal', () => {
|
||||
it('never invokes git for a merge base that is not an object name', async () => {
|
||||
const runDiffNumstat = vi.fn()
|
||||
for (const mergeBase of ['--upload-pack=x', 'HEAD', '', 'origin/main']) {
|
||||
await expect(
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
}
|
||||
expect(runDiffNumstat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes untracked additions the ranged diff cannot see', async () => {
|
||||
const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\nthree\n' })
|
||||
|
||||
await expect(
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: ['new.txt'],
|
||||
runDiffNumstat: async () => '4\t1\tsrc/a.ts\0'
|
||||
})
|
||||
).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE })
|
||||
})
|
||||
|
||||
it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => {
|
||||
const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\n' })
|
||||
|
||||
await expect(
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: ['new.txt'],
|
||||
runDiffNumstat: async () => {
|
||||
throw new Error('fatal: bad object')
|
||||
}
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('coalesces concurrent callers sharing a host, worktree and merge base into one exec', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const runDiffNumstat = vi.fn(async () => {
|
||||
await gate
|
||||
return '4\t1\tsrc/a.ts\0'
|
||||
})
|
||||
const input = {
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
}
|
||||
|
||||
const both = Promise.all([computeGitBranchLineTotal(input), computeGitBranchLineTotal(input)])
|
||||
release()
|
||||
|
||||
expect(await both).toEqual([
|
||||
{ added: 4, removed: 1, mergeBase: MERGE_BASE },
|
||||
{ added: 4, removed: 1, mergeBase: MERGE_BASE }
|
||||
])
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps separate execs for a different merge base, worktree or host', async () => {
|
||||
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
|
||||
const base = {
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
computeGitBranchLineTotal(base),
|
||||
computeGitBranchLineTotal({ ...base, mergeBase: OTHER_MERGE_BASE }),
|
||||
computeGitBranchLineTotal({ ...base, worktreePath: '/other-repo' }),
|
||||
computeGitBranchLineTotal({ ...base, hostKey: 'Ubuntu' })
|
||||
])
|
||||
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('re-execs once the shared lease has settled', async () => {
|
||||
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
|
||||
const input = {
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
}
|
||||
|
||||
await computeGitBranchLineTotal(input)
|
||||
await computeGitBranchLineTotal(input)
|
||||
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects when the shared diff is aborted instead of resolving a partial total', async () => {
|
||||
const controller = new AbortController()
|
||||
const runDiffNumstat = vi.fn(
|
||||
(_args: string[], signal: AbortSignal) =>
|
||||
new Promise<string>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(createAbortError()), { once: true })
|
||||
})
|
||||
)
|
||||
|
||||
const pending = computeGitBranchLineTotal({
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat,
|
||||
signal: controller.signal
|
||||
})
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
controller.abort()
|
||||
await assertion
|
||||
})
|
||||
|
||||
it('rejects immediately for a signal that is already aborted', async () => {
|
||||
const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0')
|
||||
|
||||
await expect(
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat,
|
||||
signal: AbortSignal.abort()
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(runDiffNumstat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the built argv straight through to the runner', async () => {
|
||||
const runDiffNumstat = vi.fn(async () => '')
|
||||
|
||||
await computeGitBranchLineTotal({
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
})
|
||||
|
||||
expect(runDiffNumstat).toHaveBeenCalledWith(
|
||||
buildGitBranchLineTotalDiffArgs(MERGE_BASE),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidateGitBranchLineTotalInFlight stops a later pass joining a pre-mutation diff', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const runDiffNumstat = vi.fn(async () => {
|
||||
await gate
|
||||
return '1\t0\tsrc/a.ts\0'
|
||||
})
|
||||
const input = {
|
||||
worktreePath: '/repo',
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
}
|
||||
|
||||
const first = computeGitBranchLineTotal(input)
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
const second = computeGitBranchLineTotal(input)
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeGitBranchLineTotal ranged-diff cooldown', () => {
|
||||
let nowMs = 0
|
||||
|
||||
function diffTaking(durationMs: number): () => Promise<string> {
|
||||
return vi.fn(async () => {
|
||||
nowMs += durationMs
|
||||
return '10\t2\tsrc/a.ts\0'
|
||||
})
|
||||
}
|
||||
|
||||
function inputFor(
|
||||
runDiffNumstat: () => Promise<string>,
|
||||
worktreePath = '/repo'
|
||||
): Parameters<typeof computeGitBranchLineTotal>[0] {
|
||||
return {
|
||||
worktreePath,
|
||||
hostKey: 'native',
|
||||
mergeBase: MERGE_BASE,
|
||||
untrackedPaths: [],
|
||||
runDiffNumstat
|
||||
}
|
||||
}
|
||||
|
||||
const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE }
|
||||
|
||||
beforeEach(() => {
|
||||
nowMs = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('skips a rerun for as long as the overrunning diff itself took', async () => {
|
||||
const runDiffNumstat = diffTaking(900)
|
||||
const input = inputFor(runDiffNumstat)
|
||||
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
|
||||
|
||||
nowMs += 900
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves a diff inside the soft deadline uncooled, so ordinary repos are untouched', async () => {
|
||||
const runDiffNumstat = diffTaking(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS - 1)
|
||||
const input = inputFor(runDiffNumstat)
|
||||
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('arms the cooldown on a failing diff too, since a timeout is the costliest outcome', async () => {
|
||||
const runDiffNumstat = vi.fn(async () => {
|
||||
nowMs += GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
|
||||
throw new Error('timed out')
|
||||
})
|
||||
const input = inputFor(runDiffNumstat)
|
||||
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined()
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the cooldown on a git mutation, when a fresh total matters most', async () => {
|
||||
const runDiffNumstat = diffTaking(900)
|
||||
const input = inputFor(runDiffNumstat)
|
||||
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL)
|
||||
expect(runDiffNumstat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cools down per worktree, so one slow branch cannot mute another', async () => {
|
||||
const slow = diffTaking(900)
|
||||
const other = diffTaking(900)
|
||||
|
||||
await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toEqual(TOTAL)
|
||||
await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toBeUndefined()
|
||||
await expect(computeGitBranchLineTotal(inputFor(other, '/other'))).resolves.toEqual(TOTAL)
|
||||
|
||||
expect(slow).toHaveBeenCalledTimes(1)
|
||||
expect(other).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import type { GitBranchLineTotal } from './git-status-types'
|
||||
import {
|
||||
collectUntrackedAdditions,
|
||||
parseNumstat,
|
||||
type GitLineStats
|
||||
} from './git-uncommitted-line-stats'
|
||||
|
||||
export type { GitBranchLineTotal }
|
||||
|
||||
// Why: a mergeBase→worktree diff on a very large branch over SSH is unbounded
|
||||
// work. Past this budget the chip is dropped rather than the status response
|
||||
// blocked — an absent total is the documented "not known exact" rendering.
|
||||
export const GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS = 15_000
|
||||
|
||||
// Why: the hard timeout bounds the git process, not the wait. The status
|
||||
// response carries the file list and staging state, so it must never sit behind
|
||||
// a ranged diff — past this budget the pass publishes without the total and the
|
||||
// diff keeps running to backfill the cache for the next pass.
|
||||
export const GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS = 500
|
||||
|
||||
const BRANCH_LINE_TOTAL_PENDING = Symbol('branch-line-total-pending')
|
||||
|
||||
/**
|
||||
* Resolves with the total only if it lands inside the soft budget; otherwise
|
||||
* resolves undefined and hands the still-running diff to `onLateArrival`.
|
||||
*/
|
||||
export async function settleGitBranchLineTotalWithinSoftDeadline(input: {
|
||||
total: Promise<GitBranchLineTotal | undefined>
|
||||
onLateArrival: (total: GitBranchLineTotal) => void
|
||||
softDeadlineMs?: number
|
||||
}): Promise<GitBranchLineTotal | undefined> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<typeof BRANCH_LINE_TOTAL_PENDING>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve(BRANCH_LINE_TOTAL_PENDING),
|
||||
input.softDeadlineMs ?? GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS
|
||||
)
|
||||
// Why: a pending status timer must not hold the process open at shutdown.
|
||||
timer.unref?.()
|
||||
})
|
||||
const settled = await Promise.race([input.total, deadline])
|
||||
clearTimeout(timer)
|
||||
if (settled !== BRANCH_LINE_TOTAL_PENDING) {
|
||||
return settled
|
||||
}
|
||||
void input.total.then((late) => {
|
||||
if (late) {
|
||||
input.onLateArrival(late)
|
||||
}
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `git diff <mergeBase>` with no second rev and no `--cached` compares that
|
||||
* commit to the working tree, so committed + staged + unstaged collapse into one
|
||||
* correctly-deduplicated result. Summing the per-area status rows instead would
|
||||
* double-count any line touched in two areas.
|
||||
*
|
||||
* `-M` only (not `-M -C`): matches the working-tree numstat the CHANGES rows are
|
||||
* built from, and avoids `-C`'s cost on wide diffs. The trailing `--` keeps the
|
||||
* OID parsed as a rev even if a path of the same name exists.
|
||||
*/
|
||||
export function buildGitBranchLineTotalDiffArgs(mergeBase: string): string[] {
|
||||
return ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
|
||||
}
|
||||
|
||||
/**
|
||||
* The merge base reaches the host as untrusted RPC input and is spliced into a
|
||||
* git argv. Only an object name shape is ever legitimate here, so anything else
|
||||
* (notably a leading `-`) is rejected before it can act as a flag.
|
||||
*/
|
||||
export function isGitBranchLineTotalMergeBase(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f]{7,64}$/.test(value)
|
||||
}
|
||||
|
||||
/** Reads the request param without trusting its type; undefined disables the work entirely. */
|
||||
export function readGitBranchLineTotalMergeBaseParam(value: unknown): string | undefined {
|
||||
return isGitBranchLineTotalMergeBase(value) ? value : undefined
|
||||
}
|
||||
|
||||
export function sumGitBranchLineTotal(input: {
|
||||
mergeBase: string
|
||||
tracked: ReadonlyMap<string, GitLineStats>
|
||||
untracked: ReadonlyMap<string, GitLineStats>
|
||||
}): GitBranchLineTotal {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const stats of input.tracked.values()) {
|
||||
// Binary files parse to undefined in numstat and contribute nothing, matching
|
||||
// the per-file rows.
|
||||
added += stats.added ?? 0
|
||||
removed += stats.removed ?? 0
|
||||
}
|
||||
for (const stats of input.untracked.values()) {
|
||||
added += stats.added ?? 0
|
||||
removed += stats.removed ?? 0
|
||||
}
|
||||
return { added, removed, mergeBase: input.mergeBase }
|
||||
}
|
||||
|
||||
// Why: one shared exec per (host, worktree, mergeBase). The renderer's own
|
||||
// in-flight refs only dedupe a single renderer's calls; a second window or an
|
||||
// fs-watcher burst would otherwise run the ranged diff twice concurrently.
|
||||
const rangedNumstatLeaseOwner = new GitStatusReadLeaseOwner<Map<string, GitLineStats> | null>()
|
||||
|
||||
// Why: the soft deadline hides this diff's cost from the poller's duration-aware
|
||||
// backoff, so an overrunning one would restart the moment it finished. Make it
|
||||
// wait out its own measured cost; the cache carry-forward keeps the chip up.
|
||||
const GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS = 30_000
|
||||
const GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS = 256
|
||||
const rangedNumstatCooldownUntilMs = new Map<string, number>()
|
||||
|
||||
const monotonicNowMs = (): number => performance.now()
|
||||
|
||||
function isRangedNumstatCoolingDown(leaseKey: string, nowMs: number): boolean {
|
||||
const readyAtMs = rangedNumstatCooldownUntilMs.get(leaseKey)
|
||||
if (readyAtMs === undefined) {
|
||||
return false
|
||||
}
|
||||
if (nowMs >= readyAtMs) {
|
||||
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function recordRangedNumstatDuration(leaseKey: string, durationMs: number, nowMs: number): void {
|
||||
if (durationMs <= GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) {
|
||||
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
||||
return
|
||||
}
|
||||
rangedNumstatCooldownUntilMs.delete(leaseKey)
|
||||
rangedNumstatCooldownUntilMs.set(
|
||||
leaseKey,
|
||||
nowMs + Math.min(durationMs, GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS)
|
||||
)
|
||||
while (rangedNumstatCooldownUntilMs.size > GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS) {
|
||||
const oldestKey = rangedNumstatCooldownUntilMs.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
return
|
||||
}
|
||||
rangedNumstatCooldownUntilMs.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from every path that clears the other git read caches: without it a pass
|
||||
* beginning after a discard/stash/checkout joins the pre-mutation lease.
|
||||
*/
|
||||
export function invalidateGitBranchLineTotalInFlight(): void {
|
||||
rangedNumstatLeaseOwner.invalidate()
|
||||
rangedNumstatCooldownUntilMs.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the branch total, or undefined when it cannot be known exact.
|
||||
* Rejects only on abort — every other failure hides the chip instead of
|
||||
* publishing a partial number.
|
||||
*/
|
||||
export async function computeGitBranchLineTotal(input: {
|
||||
worktreePath: string
|
||||
/** Distinguishes hosts that can map the same path to different filesystems (WSL distro, relay). */
|
||||
hostKey: string
|
||||
mergeBase: string
|
||||
untrackedPaths: readonly string[]
|
||||
runDiffNumstat: (args: string[], signal: AbortSignal) => Promise<string>
|
||||
signal?: AbortSignal
|
||||
}): Promise<GitBranchLineTotal | undefined> {
|
||||
if (!isGitBranchLineTotalMergeBase(input.mergeBase)) {
|
||||
return undefined
|
||||
}
|
||||
const leaseKey = `${input.hostKey}\0${input.worktreePath}\0${input.mergeBase}`
|
||||
// Safe before the lease: a cooldown is only armed once a diff settled, so there
|
||||
// is never an in-flight one to join while it is active.
|
||||
if (isRangedNumstatCoolingDown(leaseKey, monotonicNowMs())) {
|
||||
return undefined
|
||||
}
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
rangedNumstatLeaseOwner.lease(leaseKey, input.signal, async (sharedSignal) => {
|
||||
const startedAtMs = monotonicNowMs()
|
||||
try {
|
||||
const stdout = await input.runDiffNumstat(
|
||||
buildGitBranchLineTotalDiffArgs(input.mergeBase),
|
||||
sharedSignal
|
||||
)
|
||||
return parseNumstat(stdout)
|
||||
} catch (error) {
|
||||
// Why: an aborted pass must reject so a cancelled scan is never treated
|
||||
// as completed. Everything else — a bad merge base, a timeout, a
|
||||
// detached worktree — yields null so the field is omitted, not zeroed.
|
||||
if (sharedSignal.aborted) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
// `finally`, not the success path: a timeout is the costliest outcome.
|
||||
if (!sharedSignal.aborted) {
|
||||
const settledAtMs = monotonicNowMs()
|
||||
recordRangedNumstatDuration(leaseKey, settledAtMs - startedAtMs, settledAtMs)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Untracked files are invisible to a ranged diff but already render as
|
||||
// CHANGES rows, so they are added on top. Stat-keyed caching inside makes
|
||||
// this near-free when attachLineStats just read the same paths.
|
||||
collectUntrackedAdditions(input.worktreePath, input.untrackedPaths, input.signal)
|
||||
])
|
||||
if (tracked === null) {
|
||||
return undefined
|
||||
}
|
||||
return sumGitBranchLineTotal({ mergeBase: input.mergeBase, tracked, untracked })
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyCachedGitStatusLineStats,
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
readCachedGitBranchLineTotal,
|
||||
reuseOrRecomputeGitStatusLineStats,
|
||||
storeGitStatusLineStats
|
||||
} from './git-status-line-stats-cache'
|
||||
import type { GitBranchLineTotal } from './git-status-types'
|
||||
|
||||
const CACHE_KEY = 'native\0/repo'
|
||||
const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4'
|
||||
const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c'
|
||||
const TOTAL: GitBranchLineTotal = { added: 12, removed: 3, mergeBase: MERGE_BASE }
|
||||
|
||||
function entries(added?: number): { path: string; status: string; area: string; added?: number }[] {
|
||||
return [{ path: 'src/a.ts', status: 'modified', area: 'unstaged', ...(added ? { added } : {}) }]
|
||||
}
|
||||
|
||||
function seedSnapshot(branchLineTotal?: GitBranchLineTotal): void {
|
||||
storeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(3),
|
||||
...(branchLineTotal ? { branchLineTotal } : {})
|
||||
})
|
||||
}
|
||||
|
||||
describe('branch line total inside the status line-stats cache', () => {
|
||||
beforeEach(() => {
|
||||
clearGitStatusLineStatsCache()
|
||||
})
|
||||
|
||||
it('costs nothing when the caller did not ask for a total', async () => {
|
||||
const recompute = vi.fn(async () => true)
|
||||
|
||||
const result = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: false,
|
||||
isAborted: () => false,
|
||||
recompute
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(recompute).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the cached total on a line-stats reuse hit without re-running the diff', async () => {
|
||||
seedSnapshot(TOTAL)
|
||||
const compute = vi.fn(async () => TOTAL)
|
||||
const recompute = vi.fn(async () => true)
|
||||
|
||||
const result = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: true,
|
||||
isAborted: () => false,
|
||||
recompute,
|
||||
branchLineTotal: { mergeBase: MERGE_BASE, compute }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ branchLineTotal: TOTAL })
|
||||
expect(compute).not.toHaveBeenCalled()
|
||||
expect(recompute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recomputes and backfills when the snapshot has no total for this fork point', async () => {
|
||||
seedSnapshot({ added: 99, removed: 99, mergeBase: OTHER_MERGE_BASE })
|
||||
const compute = vi.fn(async () => TOTAL)
|
||||
|
||||
const first = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: true,
|
||||
isAborted: () => false,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: { mergeBase: MERGE_BASE, compute }
|
||||
})
|
||||
const second = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: true,
|
||||
isAborted: () => false,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: { mergeBase: MERGE_BASE, compute }
|
||||
})
|
||||
|
||||
expect(first).toEqual({ branchLineTotal: TOTAL })
|
||||
expect(second).toEqual({ branchLineTotal: TOTAL })
|
||||
expect(compute).toHaveBeenCalledTimes(1)
|
||||
expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toEqual(
|
||||
TOTAL
|
||||
)
|
||||
})
|
||||
|
||||
it('never serves a cached total that was measured against another fork point', () => {
|
||||
seedSnapshot(TOTAL)
|
||||
|
||||
expect(
|
||||
readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: OTHER_MERGE_BASE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts the ranged diff before waiting on the per-area numstat', async () => {
|
||||
const order: string[] = []
|
||||
let releaseRecompute = (): void => {}
|
||||
const recomputeGate = new Promise<void>((resolve) => {
|
||||
releaseRecompute = resolve
|
||||
})
|
||||
|
||||
const pending = reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: false,
|
||||
isAborted: () => false,
|
||||
recompute: async () => {
|
||||
order.push('recompute')
|
||||
await recomputeGate
|
||||
return true
|
||||
},
|
||||
branchLineTotal: {
|
||||
mergeBase: MERGE_BASE,
|
||||
compute: async () => {
|
||||
order.push('compute')
|
||||
return TOTAL
|
||||
}
|
||||
}
|
||||
})
|
||||
releaseRecompute()
|
||||
|
||||
expect(await pending).toEqual({ branchLineTotal: TOTAL })
|
||||
expect(order).toEqual(['compute', 'recompute'])
|
||||
})
|
||||
|
||||
it('keeps the exact total when the per-area numstat pass failed', async () => {
|
||||
const result = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: false,
|
||||
isAborted: () => false,
|
||||
recompute: async () => false,
|
||||
branchLineTotal: { mergeBase: MERGE_BASE, compute: async () => TOTAL }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ branchLineTotal: TOTAL })
|
||||
// The incomplete pass stays uncacheable, so nothing was pinned for reuse.
|
||||
expect(
|
||||
applyCachedGitStatusLineStats({ cacheKey: CACHE_KEY, head: 'head-1', entries: entries() })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('omits the field entirely when the total could not be known exact', async () => {
|
||||
const result = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(4),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: false,
|
||||
isAborted: () => false,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: { mergeBase: MERGE_BASE, compute: async () => undefined }
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect('branchLineTotal' in result).toBe(false)
|
||||
expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects instead of returning a partial total when the pass aborts mid-diff', async () => {
|
||||
let aborted = false
|
||||
|
||||
await expect(
|
||||
reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: false,
|
||||
isAborted: () => aborted,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: {
|
||||
mergeBase: MERGE_BASE,
|
||||
compute: async () => {
|
||||
aborted = true
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a reuse hit whose backfill compute is aborted', async () => {
|
||||
seedSnapshot()
|
||||
let aborted = false
|
||||
|
||||
await expect(
|
||||
reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: CACHE_KEY,
|
||||
head: 'head-1',
|
||||
entries: entries(),
|
||||
writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY),
|
||||
reuse: true,
|
||||
isAborted: () => aborted,
|
||||
recompute: async () => true,
|
||||
branchLineTotal: {
|
||||
mergeBase: MERGE_BASE,
|
||||
compute: async () => {
|
||||
aborted = true
|
||||
return TOTAL
|
||||
}
|
||||
}
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,19 @@
|
|||
import { settleGitBranchLineTotalWithinSoftDeadline } from './git-branch-line-total'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
bumpGitStatusLineStatsKeyGeneration,
|
||||
isWriteTokenCurrent,
|
||||
markGitStatusLineStatsStored,
|
||||
resetGitStatusLineStatsWriteGenerations,
|
||||
type GitStatusLineStatsWriteToken
|
||||
} from './git-status-line-stats-write-token'
|
||||
import type { GitBranchLineTotal } from './git-status-types'
|
||||
|
||||
export {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
type GitStatusLineStatsWriteToken
|
||||
} from './git-status-line-stats-write-token'
|
||||
|
||||
type GitStatusLineStatsEntry = {
|
||||
path?: unknown
|
||||
status?: unknown
|
||||
|
|
@ -19,13 +35,10 @@ type CachedLineStats = {
|
|||
identity: string
|
||||
storedAt: number
|
||||
stats: { added?: number; removed?: number }[]
|
||||
}
|
||||
|
||||
export type GitStatusLineStatsWriteToken = {
|
||||
cacheKey: string
|
||||
globalGeneration: number
|
||||
keyGeneration: number
|
||||
beginSeq: number
|
||||
// Why: the branch total is derived from the same tree snapshot as the entry
|
||||
// stats, so it must share their reuse lifecycle — a poll that reuses line
|
||||
// stats must reuse the total rather than re-running the ranged diff.
|
||||
branchLineTotal?: GitBranchLineTotal
|
||||
}
|
||||
|
||||
// Why: the TTL is the sole staleness backstop when file contents change while
|
||||
|
|
@ -33,50 +46,11 @@ export type GitStatusLineStatsWriteToken = {
|
|||
// reuse identity), so a missed watcher signal pins counts for at most this long.
|
||||
export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000
|
||||
const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128
|
||||
const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024
|
||||
const lineStatsByWorktree = new Map<string, CachedLineStats>()
|
||||
// Why: mutation invalidation must retire scans that began before it. A scan
|
||||
// captures these generations at begin; a mismatch at store/clear time means an
|
||||
// invalidation happened mid-scan and the derived stats may be pre-mutation.
|
||||
let globalInvalidationGeneration = 0
|
||||
const keyInvalidationGenerationByWorktree = new Map<string, number>()
|
||||
// Why: overlapping recomputes must resolve latest-begun-wins without letting a
|
||||
// reuse-only read (which never stores) starve an older recompute's store.
|
||||
const lastStoredBeginSeqByWorktree = new Map<string, number>()
|
||||
let nextBeginSeq = 0
|
||||
|
||||
// Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL.
|
||||
const monotonicNowMs = (): number => performance.now()
|
||||
|
||||
function bumpBoundedKeyMap(map: Map<string, number>, cacheKey: string, value: number): void {
|
||||
map.delete(cacheKey)
|
||||
map.set(cacheKey, value)
|
||||
while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) {
|
||||
const oldestKey = map.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
return
|
||||
}
|
||||
map.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken {
|
||||
return {
|
||||
cacheKey,
|
||||
globalGeneration: globalInvalidationGeneration,
|
||||
keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0,
|
||||
beginSeq: ++nextBeginSeq
|
||||
}
|
||||
}
|
||||
|
||||
function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean {
|
||||
return (
|
||||
token.globalGeneration === globalInvalidationGeneration &&
|
||||
token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) &&
|
||||
token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string {
|
||||
return JSON.stringify([
|
||||
head ?? null,
|
||||
|
|
@ -143,27 +117,72 @@ export function applyCachedGitStatusLineStats(input: {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Only valid immediately after `applyCachedGitStatusLineStats` returned true —
|
||||
* that call is what proves the snapshot is fresh and matches this scan's
|
||||
* entries. The merge base must match too, or the total describes a fork point
|
||||
* this scan is no longer comparing against.
|
||||
*/
|
||||
export function readCachedGitBranchLineTotal(input: {
|
||||
cacheKey: string
|
||||
mergeBase: string
|
||||
}): GitBranchLineTotal | undefined {
|
||||
const total = lineStatsByWorktree.get(input.cacheKey)?.branchLineTotal
|
||||
return total?.mergeBase === input.mergeBase ? total : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills a total onto a snapshot that was reused for its entry stats. Purely
|
||||
* additive, so unlike a full store it must not retire older in-flight scans.
|
||||
*/
|
||||
export function updateCachedGitBranchLineTotal(input: {
|
||||
cacheKey: string
|
||||
head?: string
|
||||
entries: GitStatusLineStatsEntry[]
|
||||
branchLineTotal: GitBranchLineTotal
|
||||
writeToken: GitStatusLineStatsWriteToken
|
||||
}): void {
|
||||
if (!isWriteTokenCurrent(input.writeToken)) {
|
||||
return
|
||||
}
|
||||
const cached = lineStatsByWorktree.get(input.cacheKey)
|
||||
if (!cached || cached.identity !== createInputIdentity(input.head, input.entries)) {
|
||||
return
|
||||
}
|
||||
cached.branchLineTotal = input.branchLineTotal
|
||||
}
|
||||
|
||||
export function storeGitStatusLineStats(input: {
|
||||
cacheKey: string
|
||||
head?: string
|
||||
entries: GitStatusLineStatsEntry[]
|
||||
now?: number
|
||||
writeToken?: GitStatusLineStatsWriteToken
|
||||
branchLineTotal?: GitBranchLineTotal
|
||||
}): void {
|
||||
const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey)
|
||||
if (!isWriteTokenCurrent(writeToken)) {
|
||||
return
|
||||
}
|
||||
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq)
|
||||
markGitStatusLineStatsStored(input.cacheKey, writeToken.beginSeq)
|
||||
const now = input.now ?? monotonicNowMs()
|
||||
const identity = createInputIdentity(input.head, input.entries)
|
||||
// Why: a total that missed its soft deadline lands here later; an unchanged
|
||||
// snapshot must carry it forward or the next recompute would drop it and the
|
||||
// chip could never appear on a repo where the diff is always slow.
|
||||
const previous = lineStatsByWorktree.get(input.cacheKey)
|
||||
const branchLineTotal =
|
||||
input.branchLineTotal ??
|
||||
(previous?.identity === identity ? previous.branchLineTotal : undefined)
|
||||
lineStatsByWorktree.delete(input.cacheKey)
|
||||
lineStatsByWorktree.set(input.cacheKey, {
|
||||
identity: createInputIdentity(input.head, input.entries),
|
||||
identity,
|
||||
storedAt: now,
|
||||
stats: input.entries.map((entry) => ({
|
||||
...(entry.added === undefined ? {} : { added: entry.added }),
|
||||
...(entry.removed === undefined ? {} : { removed: entry.removed })
|
||||
}))
|
||||
})),
|
||||
...(branchLineTotal === undefined ? {} : { branchLineTotal })
|
||||
})
|
||||
trimLineStatsCache(now)
|
||||
}
|
||||
|
|
@ -189,7 +208,12 @@ export async function reuseOrRecomputeGitStatusLineStats(input: {
|
|||
reuse: boolean
|
||||
isAborted: () => boolean
|
||||
recompute: () => Promise<boolean>
|
||||
}): Promise<void> {
|
||||
/** Omitted when the caller did not ask for a branch total, which costs nothing. */
|
||||
branchLineTotal?: {
|
||||
mergeBase: string
|
||||
compute: () => Promise<GitBranchLineTotal | undefined>
|
||||
}
|
||||
}): Promise<{ branchLineTotal?: GitBranchLineTotal }> {
|
||||
if (input.isAborted()) {
|
||||
// Why: reject rather than resolve — a cancelled scan must not look like a
|
||||
// completed status result (including a cache-hit reuse path).
|
||||
|
|
@ -206,9 +230,26 @@ export async function reuseOrRecomputeGitStatusLineStats(input: {
|
|||
if (input.isAborted()) {
|
||||
throw createGitStatusLineStatsAbortError()
|
||||
}
|
||||
return
|
||||
return reuseCachedBranchLineTotal(input)
|
||||
}
|
||||
// Why: started before the await below so both diffs run concurrently, and
|
||||
// pre-caught so an aborted total can never surface as an unhandled rejection
|
||||
// when recompute rejects first.
|
||||
const totalPromise = input.branchLineTotal?.compute().catch(() => undefined)
|
||||
const complete = await input.recompute()
|
||||
const branchLineTotal = totalPromise
|
||||
? await settleGitBranchLineTotalWithinSoftDeadline({
|
||||
total: totalPromise,
|
||||
onLateArrival: (late) =>
|
||||
updateCachedGitBranchLineTotal({
|
||||
cacheKey: input.cacheKey,
|
||||
head: input.head,
|
||||
entries: input.entries,
|
||||
branchLineTotal: late,
|
||||
writeToken: input.writeToken
|
||||
})
|
||||
})
|
||||
: undefined
|
||||
if (input.isAborted()) {
|
||||
// Why: an aborted pass never reached storeGitStatusLineStats, so there is
|
||||
// nothing partial to undo; clearing here would instead evict a concurrent
|
||||
|
|
@ -217,21 +258,80 @@ export async function reuseOrRecomputeGitStatusLineStats(input: {
|
|||
throw createGitStatusLineStatsAbortError()
|
||||
}
|
||||
if (!complete) {
|
||||
return
|
||||
// Why: the total comes from its own ranged diff, so a failed per-area
|
||||
// numstat leaves it exact even though the entry stats are uncacheable.
|
||||
return branchLineTotal === undefined ? {} : { branchLineTotal }
|
||||
}
|
||||
storeGitStatusLineStats({
|
||||
cacheKey: input.cacheKey,
|
||||
head: input.head,
|
||||
entries: input.entries,
|
||||
writeToken: input.writeToken
|
||||
writeToken: input.writeToken,
|
||||
...(branchLineTotal === undefined ? {} : { branchLineTotal })
|
||||
})
|
||||
if (!input.branchLineTotal) {
|
||||
return {}
|
||||
}
|
||||
// Why: read back rather than return the local — the store carries forward a
|
||||
// total that arrived late on an unchanged snapshot, which is the only way the
|
||||
// chip ever appears where the diff always outruns the soft deadline.
|
||||
const published = readCachedGitBranchLineTotal({
|
||||
cacheKey: input.cacheKey,
|
||||
mergeBase: input.branchLineTotal.mergeBase
|
||||
})
|
||||
return published === undefined ? {} : { branchLineTotal: published }
|
||||
}
|
||||
|
||||
async function reuseCachedBranchLineTotal(input: {
|
||||
cacheKey: string
|
||||
head?: string
|
||||
entries: GitStatusLineStatsEntry[]
|
||||
writeToken: GitStatusLineStatsWriteToken
|
||||
isAborted: () => boolean
|
||||
branchLineTotal?: {
|
||||
mergeBase: string
|
||||
compute: () => Promise<GitBranchLineTotal | undefined>
|
||||
}
|
||||
}): Promise<{ branchLineTotal?: GitBranchLineTotal }> {
|
||||
if (!input.branchLineTotal) {
|
||||
return {}
|
||||
}
|
||||
const cached = readCachedGitBranchLineTotal({
|
||||
cacheKey: input.cacheKey,
|
||||
mergeBase: input.branchLineTotal.mergeBase
|
||||
})
|
||||
if (cached) {
|
||||
return { branchLineTotal: cached }
|
||||
}
|
||||
// The snapshot predates this feature or was computed against another fork
|
||||
// point; compute once and backfill so the next reuse hit is free. A reuse pass
|
||||
// exists to be cheap, so it waits no longer than any other for the diff.
|
||||
const backfill = (late: GitBranchLineTotal): void => {
|
||||
updateCachedGitBranchLineTotal({
|
||||
cacheKey: input.cacheKey,
|
||||
head: input.head,
|
||||
entries: input.entries,
|
||||
branchLineTotal: late,
|
||||
writeToken: input.writeToken
|
||||
})
|
||||
}
|
||||
const branchLineTotal = await settleGitBranchLineTotalWithinSoftDeadline({
|
||||
total: input.branchLineTotal.compute().catch(() => undefined),
|
||||
onLateArrival: backfill
|
||||
})
|
||||
if (input.isAborted()) {
|
||||
throw createGitStatusLineStatsAbortError()
|
||||
}
|
||||
if (branchLineTotal === undefined) {
|
||||
return {}
|
||||
}
|
||||
backfill(branchLineTotal)
|
||||
return { branchLineTotal }
|
||||
}
|
||||
|
||||
export function clearGitStatusLineStatsCache(): void {
|
||||
globalInvalidationGeneration += 1
|
||||
lineStatsByWorktree.clear()
|
||||
keyInvalidationGenerationByWorktree.clear()
|
||||
lastStoredBeginSeqByWorktree.clear()
|
||||
resetGitStatusLineStatsWriteGenerations()
|
||||
}
|
||||
|
||||
export function clearGitStatusLineStatsCacheKey(
|
||||
|
|
@ -242,15 +342,11 @@ export function clearGitStatusLineStatsCacheKey(
|
|||
return
|
||||
}
|
||||
if (writeToken === undefined) {
|
||||
bumpBoundedKeyMap(
|
||||
keyInvalidationGenerationByWorktree,
|
||||
cacheKey,
|
||||
(keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1
|
||||
)
|
||||
bumpGitStatusLineStatsKeyGeneration(cacheKey)
|
||||
} else {
|
||||
// Why: a token-scoped purge must retire scans that began before it, so an
|
||||
// older in-flight scan can't store pre-purge counts and repopulate this key.
|
||||
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq)
|
||||
markGitStatusLineStatsStored(cacheKey, writeToken.beginSeq)
|
||||
}
|
||||
lineStatsByWorktree.delete(cacheKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Generation bookkeeping that decides whether a git-status line-stats scan may
|
||||
* still write. Split from the cache itself so the snapshot store stays readable.
|
||||
*/
|
||||
export type GitStatusLineStatsWriteToken = {
|
||||
cacheKey: string
|
||||
globalGeneration: number
|
||||
keyGeneration: number
|
||||
beginSeq: number
|
||||
}
|
||||
|
||||
const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024
|
||||
|
||||
// Why: mutation invalidation must retire scans that began before it. A scan
|
||||
// captures these generations at begin; a mismatch at store/clear time means an
|
||||
// invalidation happened mid-scan and the derived stats may be pre-mutation.
|
||||
let globalInvalidationGeneration = 0
|
||||
const keyInvalidationGenerationByWorktree = new Map<string, number>()
|
||||
// Why: overlapping recomputes must resolve latest-begun-wins without letting a
|
||||
// reuse-only read (which never stores) starve an older recompute's store.
|
||||
const lastStoredBeginSeqByWorktree = new Map<string, number>()
|
||||
let nextBeginSeq = 0
|
||||
|
||||
function bumpBoundedKeyMap(map: Map<string, number>, cacheKey: string, value: number): void {
|
||||
map.delete(cacheKey)
|
||||
map.set(cacheKey, value)
|
||||
while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) {
|
||||
const oldestKey = map.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
return
|
||||
}
|
||||
map.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken {
|
||||
return {
|
||||
cacheKey,
|
||||
globalGeneration: globalInvalidationGeneration,
|
||||
keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0,
|
||||
beginSeq: ++nextBeginSeq
|
||||
}
|
||||
}
|
||||
|
||||
export function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean {
|
||||
return (
|
||||
token.globalGeneration === globalInvalidationGeneration &&
|
||||
token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) &&
|
||||
token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function markGitStatusLineStatsStored(cacheKey: string, beginSeq: number): void {
|
||||
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, beginSeq)
|
||||
}
|
||||
|
||||
export function bumpGitStatusLineStatsKeyGeneration(cacheKey: string): void {
|
||||
bumpBoundedKeyMap(
|
||||
keyInvalidationGenerationByWorktree,
|
||||
cacheKey,
|
||||
(keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
export function resetGitStatusLineStatsWriteGenerations(): void {
|
||||
globalInvalidationGeneration += 1
|
||||
keyInvalidationGenerationByWorktree.clear()
|
||||
lastStoredBeginSeqByWorktree.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
type StatusReadEntry<T> = {
|
||||
controller: AbortController
|
||||
promise: Promise<T>
|
||||
liveLeases: number
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
function getAbortReason(signal: AbortSignal): unknown {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return new DOMException('This operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
export class GitStatusReadLeaseOwner<T> {
|
||||
private readonly entries = new Map<string, StatusReadEntry<T>>()
|
||||
|
||||
lease(
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
load: (sharedSignal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(getAbortReason(signal))
|
||||
}
|
||||
|
||||
let entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
const controller = new AbortController()
|
||||
const promise = load(controller.signal)
|
||||
const createdEntry = { controller, promise, liveLeases: 0, settled: false }
|
||||
entry = createdEntry
|
||||
this.entries.set(key, createdEntry)
|
||||
void promise.then(
|
||||
() => this.settle(key, createdEntry),
|
||||
() => this.settle(key, createdEntry)
|
||||
)
|
||||
}
|
||||
|
||||
entry.liveLeases += 1
|
||||
return this.createLease(key, entry, signal)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private createLease(
|
||||
key: string,
|
||||
entry: StatusReadEntry<T>,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = true
|
||||
const release = (abortReason?: unknown): boolean => {
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
active = false
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
entry.liveLeases -= 1
|
||||
if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
entry.controller.abort(abortReason)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
const reason = getAbortReason(signal!)
|
||||
if (release(reason)) {
|
||||
reject(reason)
|
||||
}
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
void entry.promise.then(
|
||||
(value) => {
|
||||
if (release()) {
|
||||
resolve(value)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (release()) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private settle(key: string, entry: StatusReadEntry<T>): void {
|
||||
entry.settled = true
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,15 @@ export type GitUncommittedEntry = {
|
|||
|
||||
export type GitStatusEntry = GitUncommittedEntry
|
||||
|
||||
// `mergeBase(base, HEAD) → working tree`, deduplicated, so committing doesn't move it.
|
||||
// Matches the per-file rows rather than git: binary and >2MB untracked count zero.
|
||||
// `mergeBase` is echoed so a renderer can drop a moved fork point.
|
||||
export type GitBranchLineTotal = {
|
||||
added: number
|
||||
removed: number
|
||||
mergeBase: string
|
||||
}
|
||||
|
||||
export type GitStatusResult = {
|
||||
entries: GitStatusEntry[]
|
||||
conflictOperation: GitConflictOperation
|
||||
|
|
@ -69,6 +78,9 @@ export type GitStatusResult = {
|
|||
// "too many changes" state.
|
||||
didHitLimit?: boolean
|
||||
statusLength?: number
|
||||
// Only computed when the request carried a merge-base OID (the renderer's
|
||||
// visibility gate), and omitted — never zeroed — whenever it cannot be trusted.
|
||||
branchLineTotal?: GitBranchLineTotal
|
||||
}
|
||||
|
||||
// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a
|
||||
|
|
|
|||
Loading…
Reference in New Issue