mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
perf(worktree): fix the prepared-checkout hit rate and make misses visible (#17863)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
measureRetargetDivergence,
|
||||
RETARGET_MAX_COMMIT_DIVERGENCE
|
||||
} from './worktree-base-divergence'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createRepo(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-'))
|
||||
tempRoots.push(root)
|
||||
const repoPath = join(root, 'repo')
|
||||
execFileSync('git', ['init', '--quiet', repoPath])
|
||||
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||
git(repoPath, ['config', 'user.email', 'test@example.com'])
|
||||
git(repoPath, ['config', 'user.name', 'Test User'])
|
||||
await writeFile(join(repoPath, 'version.txt'), 'one\n')
|
||||
git(repoPath, ['add', 'version.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
||||
return repoPath
|
||||
}
|
||||
|
||||
function commitEmpty(repoPath: string, count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('measureRetargetDivergence with real Git', () => {
|
||||
it('allows the drift between a local branch and its remote-tracking copy', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
commitEmpty(repoPath, 5)
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3'])
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('within')
|
||||
})
|
||||
|
||||
it('counts drift in both directions', async () => {
|
||||
const repoPath = await createRepo()
|
||||
const forkPoint = git(repoPath, ['rev-parse', 'HEAD'])
|
||||
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE)
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['reset', '--hard', '--quiet', forkPoint])
|
||||
commitEmpty(repoPath, 1)
|
||||
|
||||
// 100 ahead + 1 behind is over the cap even though neither side alone exceeds it.
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('refuses a base that has drifted past the cap', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1)
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('refuses unrelated histories, which share no commits at all', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated'])
|
||||
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root'])
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('reports an unreadable ref as unverifiable, not as excess drift', async () => {
|
||||
const repoPath = await createRepo()
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing')
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync }))
|
||||
|
||||
import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout'
|
||||
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
|
||||
import {
|
||||
measureRetargetDivergence,
|
||||
RETARGET_DIVERGENCE_BUDGET_MS
|
||||
} from './worktree-base-divergence'
|
||||
|
||||
type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal }
|
||||
|
||||
function callOptions(): ExecOptions[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions)
|
||||
}
|
||||
|
||||
function subcommands(): string[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
|
||||
}
|
||||
|
||||
function answerProbes(count: string, mergeBase = 'abc123\n') {
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) =>
|
||||
args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count }
|
||||
)
|
||||
}
|
||||
|
||||
function exitCodeError(code: number): Error & { code: number } {
|
||||
return Object.assign(new Error('git exited'), { code })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.gitExecFileAsync.mockReset()
|
||||
})
|
||||
|
||||
describe('measureRetargetDivergence deadlines', () => {
|
||||
it('puts every probe under one shared budget, not a budget each', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('within')
|
||||
|
||||
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
|
||||
const signals = callOptions().map((options) => options.signal)
|
||||
// One signal object across all three: the counts and merge-base are staged, so per-probe
|
||||
// budgets would let the check cost the sum of them.
|
||||
expect(new Set(signals).size).toBe(1)
|
||||
expect(signals[0]).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('also gives each probe a command timeout well below git default read deadline', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
|
||||
// The signal covers admission queueing and the WSL environment wait, which start before a
|
||||
// command timeout exists; the timeout still covers a hung spawn.
|
||||
for (const options of callOptions()) {
|
||||
expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS)
|
||||
}
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('really aborts the in-flight probes when the shared budget expires', async () => {
|
||||
// A probe that behaves like a slow walk: it produces nothing on its own and only settles when
|
||||
// its signal fires. If the budget never fired, or never reached the probe, this hangs and the
|
||||
// test fails on its own timeout rather than passing on a signal that does nothing.
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
|
||||
budgetMsForTest: 25
|
||||
})
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('clears the WSL read-environment wait, which starts before any command timeout', () => {
|
||||
// Equal to it would make the first WSL-routed create of a session `unknown` by construction,
|
||||
// and the WSL numbers meaningless.
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS)
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const pending = measureRetargetDivergence(
|
||||
'/repo',
|
||||
'refs/heads/main',
|
||||
'refs/remotes/origin/main',
|
||||
// A budget long enough that only the caller's cancellation can end this in time.
|
||||
{ signal: controller.signal, budgetMsForTest: 60_000 }
|
||||
)
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports a blown deadline as unverifiable rather than as excess drift', async () => {
|
||||
mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.'))
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('unknown')
|
||||
// A count that never answered must not go on to spend a merge-base walk.
|
||||
expect(subcommands()).not.toContain('merge-base')
|
||||
})
|
||||
|
||||
it('separates merge-base saying no from merge-base failing', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'merge-base') {
|
||||
// Exit 1 is Git's answer for unrelated histories.
|
||||
throw exitCodeError(1)
|
||||
}
|
||||
return { stdout: '2\n' }
|
||||
})
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
|
||||
mocks.gitExecFileAsync.mockReset()
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'merge-base') {
|
||||
// A timeout carries no exit code and must not be read as "no common ancestor".
|
||||
throw new Error('git timed out.')
|
||||
}
|
||||
return { stdout: '2\n' }
|
||||
})
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports drift past the cap without spending a merge-base walk', async () => {
|
||||
answerProbes('101\n')
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
expect(subcommands()).not.toContain('merge-base')
|
||||
})
|
||||
|
||||
it('routes every probe to the caller-named WSL distro', async () => {
|
||||
answerProbes('1\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
for (const options of callOptions()) {
|
||||
expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
export type RetargetDivergenceOptions = {
|
||||
wslDistro?: string
|
||||
/** The create's own cancellation signal. Without it a cancelled create leaves these probes
|
||||
* running until the budget expires. */
|
||||
signal?: AbortSignal
|
||||
/** Shortens only the end-to-end budget so a test can observe a real abort; production always
|
||||
* uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */
|
||||
budgetMsForTest?: number
|
||||
}
|
||||
|
||||
/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could
|
||||
* not be evaluated" have different causes and different fixes, and only the second one means a
|
||||
* retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a
|
||||
* cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */
|
||||
export type RetargetDivergence = 'within' | 'exceeded' | 'unknown'
|
||||
|
||||
/**
|
||||
* How far two bases may drift and still be worth retargeting a prepared checkout between.
|
||||
*
|
||||
* Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74
|
||||
* files apart, while an abandoned fork's `main` — same branch name, so the same base family —
|
||||
* was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit
|
||||
* count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff
|
||||
* the retarget reset would have to write.
|
||||
*/
|
||||
export const RETARGET_MAX_COMMIT_DIVERGENCE = 100
|
||||
|
||||
/**
|
||||
* Headroom for the walk itself, on top of the worst pre-spawn wait.
|
||||
*
|
||||
* ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a
|
||||
* cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by
|
||||
* construction.
|
||||
*/
|
||||
const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500
|
||||
|
||||
/**
|
||||
* End-to-end deadline for the whole check, not per probe.
|
||||
*
|
||||
* Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe
|
||||
* before a command timeout even exists, so a budget merely equal to it would guarantee `unknown`
|
||||
* on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it
|
||||
* keeps that relationship explicit instead of coincidental.
|
||||
*
|
||||
* Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold
|
||||
* `worktree add`, so when it expires the create pays the budget and then does that add anyway. The
|
||||
* total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`.
|
||||
*
|
||||
* It must be a signal, not just a per-command timeout, because a per-command timeout starts only
|
||||
* after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the
|
||||
* counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number
|
||||
* written here.
|
||||
*/
|
||||
export const RETARGET_DIVERGENCE_BUDGET_MS =
|
||||
WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS
|
||||
|
||||
function probeOptions(
|
||||
repoPath: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } {
|
||||
// Built field by field rather than spread: the caller's bag carries a test-only key that must
|
||||
// never reach git's exec options.
|
||||
// Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a
|
||||
// command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn.
|
||||
return {
|
||||
cwd: repoPath,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
signal,
|
||||
timeout: RETARGET_DIVERGENCE_BUDGET_MS
|
||||
}
|
||||
}
|
||||
|
||||
/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */
|
||||
async function countCommitsAhead(
|
||||
repoPath: string,
|
||||
fromRef: string,
|
||||
toRef: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
// `--max-count` stops the walk, so an unrelated history costs a bounded number of commits
|
||||
// rather than a full traversal. Both flags predate the Git 2.25 baseline.
|
||||
// `--end-of-options` (Git 2.24) because a range whose left side began with `-` would
|
||||
// otherwise parse as an option; callers only pass `refs/`-qualified names today, and this
|
||||
// keeps that from being load-bearing.
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
[
|
||||
'rev-list',
|
||||
'--count',
|
||||
`--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`,
|
||||
'--end-of-options',
|
||||
`${fromRef}..${toRef}`
|
||||
],
|
||||
probeOptions(repoPath, options, signal)
|
||||
)
|
||||
const count = Number.parseInt(stdout.trim(), 10)
|
||||
return Number.isNaN(count) ? null : count
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True/false when Git decided, null when the probe was unusable. */
|
||||
async function hasCommonHistory(
|
||||
repoPath: string,
|
||||
leftRef: string,
|
||||
rightRef: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['merge-base', '--end-of-options', leftRef, rightRef],
|
||||
probeOptions(repoPath, options, signal)
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch (error) {
|
||||
// Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort
|
||||
// carries no exit code and must not be read as one.
|
||||
return (error as { code?: unknown }).code === 1 ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap.
|
||||
*
|
||||
* Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes
|
||||
* reusing the checkout, so every other outcome lands on the cold create path.
|
||||
*/
|
||||
export async function measureRetargetDivergence(
|
||||
repoPath: string,
|
||||
preparedBase: string,
|
||||
targetBase: string,
|
||||
options: RetargetDivergenceOptions = {}
|
||||
): Promise<RetargetDivergence> {
|
||||
const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS)
|
||||
// Combined so cancelling the create stops the probes immediately rather than at the deadline.
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget
|
||||
// Both directions: commits the target adds decide what the reset writes, commits only the
|
||||
// preparation has decide what it must delete.
|
||||
const [ahead, behind] = await Promise.all([
|
||||
countCommitsAhead(repoPath, preparedBase, targetBase, options, signal),
|
||||
countCommitsAhead(repoPath, targetBase, preparedBase, options, signal)
|
||||
])
|
||||
if (ahead === null || behind === null) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) {
|
||||
return 'exceeded'
|
||||
}
|
||||
// Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of
|
||||
// them in full. Reaching here already proved neither side is more than the cap ahead of the
|
||||
// other, which bounds that walk — and unrelated histories of any size fail the counts first.
|
||||
// Required because unrelated histories replace the whole tree however few commits they carry.
|
||||
const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal)
|
||||
if (shareHistory === null) {
|
||||
return 'unknown'
|
||||
}
|
||||
return shareHistory ? 'within' : 'exceeded'
|
||||
}
|
||||
@@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef(
|
||||
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The qualified ref a worktree base names in this repo, or the base unchanged when nothing
|
||||
* matches. Callers that key on a base must compare this, not the raw string, or `main` and
|
||||
* `refs/heads/main` look like different bases.
|
||||
*/
|
||||
export function resolveLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<string> {
|
||||
return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
|
||||
* full commit id — already resolves in this repo's own object/ref store.
|
||||
|
||||
@@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => {
|
||||
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lands a cross-base retarget on exactly the requested commit', async () => {
|
||||
const { repoPath, root } = await createRepo()
|
||||
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
|
||||
const preparedPath = join(preparationRoot, `${process.pid}-retarget`)
|
||||
const finalPath = join(root, 'retargeted-worktree')
|
||||
await mkdir(preparationRoot, { recursive: true })
|
||||
|
||||
await writeFile(join(repoPath, 'shared.txt'), 'kept\n')
|
||||
git(repoPath, ['add', 'shared.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'local main'])
|
||||
const localMainHead = git(repoPath, ['rev-parse', 'HEAD'])
|
||||
|
||||
// A remote-tracking `main` that diverged: different content, an extra file, and one deletion.
|
||||
git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main'])
|
||||
await writeFile(join(repoPath, 'version.txt'), 'two\n')
|
||||
await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n')
|
||||
git(repoPath, ['rm', '--quiet', 'shared.txt'])
|
||||
git(repoPath, ['add', 'version.txt', 'only-upstream.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'upstream main'])
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['checkout', '--quiet', 'main'])
|
||||
git(repoPath, ['branch', '--quiet', '-D', 'upstream-main'])
|
||||
|
||||
await prepareWorktreeCreateCheckout(
|
||||
repoPath,
|
||||
preparedPath,
|
||||
'refs/remotes/origin/main',
|
||||
createWorktreePreparationLockReason('retarget-test')
|
||||
)
|
||||
expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead)
|
||||
|
||||
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main')
|
||||
|
||||
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead)
|
||||
// A retarget that left stale files behind would be a wrong checkout, not just a slow one.
|
||||
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
|
||||
expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
|
||||
'one\n'
|
||||
)
|
||||
expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
|
||||
'kept\n'
|
||||
)
|
||||
await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow()
|
||||
expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted')
|
||||
expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe(
|
||||
'refs/heads/main'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => {
|
||||
const { repoPath, root } = await createRepo()
|
||||
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
|
||||
|
||||
Reference in New Issue
Block a user