mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
Refresh optimistic worktrees after base fetch
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# Worktree Post-Create Base Refresh
|
||||
|
||||
## Problem
|
||||
|
||||
Before this change, only one local-create path was intentionally stale-on-create:
|
||||
|
||||
- `baseBranch` resolves to a remote-tracking ref.
|
||||
- That ref exists locally.
|
||||
- Fetch freshness is stale.
|
||||
|
||||
In that path, Orca starts `getOrStartRemoteFetch(...)` and does not await it before `git worktree add` ([src/main/ipc/worktree-remote.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/main/ipc/worktree-remote.ts:531), [src/main/ipc/worktree-remote.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/main/ipc/worktree-remote.ts:724)). The new worktree may be created from stale `refs/remotes/<remote>/<branch>`.
|
||||
|
||||
Later, `reconcileWorktreeBaseStatus` classifies the result (`current` / `drift` / `base_changed` / `unknown`) but did not repair the worktree ([src/main/runtime/orca-runtime.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/main/runtime/orca-runtime.ts:6068)).
|
||||
|
||||
Important current behavior constraints:
|
||||
|
||||
- If the remote-tracking ref does not exist locally, create blocks on fetch before `addWorktree`; this path is not stale-on-create.
|
||||
- If fetch freshness is hit, create previously did not schedule reconcile: no `checking`, no post-create classification ([src/main/ipc/worktree-remote.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/main/ipc/worktree-remote.ts:551)).
|
||||
- `refreshLocalBaseRefOnWorktreeCreate` is separate and default-off ([src/shared/constants.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/shared/constants.ts:145)). It may fast-forward a local branch pointer, but does not make the remote-tracking base fresh ([src/main/git/worktree.ts](/Users/jinjingliang/Documents/projects/orca/https-github.com-stablyai-orca-issues-2307/src/main/git/worktree.ts:164)).
|
||||
|
||||
## Root Cause
|
||||
|
||||
Latency optimization decouples create from fetch completion, but reconcile is read-only. There is no guarded post-fetch fast-forward of the just-created worktree.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not block the optimistic local-create path on network fetch.
|
||||
- Do not mutate worktrees after any user/setup/agent filesystem or commit activity.
|
||||
- Do not change base-ref selection semantics.
|
||||
- Do not add extra fetches beyond existing `getOrStartRemoteFetch` reuse.
|
||||
- Do not broaden to SSH in this change.
|
||||
|
||||
## Design
|
||||
|
||||
1. Keep current non-blocking create behavior in the optimistic path.
|
||||
|
||||
2. Extend reconcile with guarded auto-refresh before emitting `drift`.
|
||||
|
||||
Trigger only when:
|
||||
- `postFetchSha !== createdBaseSha`.
|
||||
- `createdBaseSha` is an ancestor of `postFetchSha`.
|
||||
|
||||
Action:
|
||||
- `git reset --hard <postFetchSha>` in the created worktree.
|
||||
|
||||
3. Required guards before reset.
|
||||
|
||||
- Reconcile token still current.
|
||||
- Worktree `instanceId` still matches create-time value.
|
||||
- Worktree path still exists and is still a git worktree.
|
||||
- `HEAD` still equals `createdBaseSha`.
|
||||
- Current branch still equals created branch (skip detached/switch).
|
||||
- Worktree is clean via `git status --porcelain` (include untracked).
|
||||
|
||||
4. Status behavior.
|
||||
|
||||
- Reset succeeds: emit `current`.
|
||||
- Any guard fails in fast-forward case: emit existing `drift` payload.
|
||||
- Non-ancestor: emit `base_changed`.
|
||||
- Keep existing publish-remote conflict behavior unchanged: only run on `current` / `drift` / `base_changed` (not on `unknown`).
|
||||
|
||||
5. Close the fresh-cache classification gap.
|
||||
|
||||
Freshness-hit creates now still schedule reconcile by passing `Promise.resolve({ ok: true })` as `fetchPromise`. This adds `checking` -> `current` consistency without starting another fetch.
|
||||
|
||||
## Required Plumbing Changes
|
||||
|
||||
- Thread `createdWorktreePath` and `createdInstanceId` from create into reconcile args.
|
||||
- Reconcile currently only gets `worktreeId`; that is insufficient for safe mutation without extra lookups and path-reuse protection.
|
||||
- Add a runtime helper for “validate untouched + reset hard to postFetchSha”.
|
||||
- Re-validate token immediately before mutation and before emit.
|
||||
|
||||
## Consistency and Concurrency
|
||||
|
||||
- Token check handles stale async completions in one runtime instance.
|
||||
- `instanceId` check is mandatory for same-path delete/recreate races.
|
||||
- Fetch cache key canonicalizes by git common-dir + remote, so parallel creates across linked worktrees share one in-flight fetch/freshness window.
|
||||
- External git mutations between create and reconcile are expected; guards must downgrade to non-mutating classification.
|
||||
- Multi-window renderers are unaffected; event type remains `WorktreeBaseStatusEvent`.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Fetch fails/offline: emit `unknown`, no mutation.
|
||||
- Base ref missing after fetch (`rev-parse` fails): emit `unknown`.
|
||||
- Force-push/rewrite (non-ancestor): emit `base_changed`, no mutation.
|
||||
- User/setup/agent file writes (including untracked): skip reset, emit `drift`.
|
||||
- User commit before reconcile: `HEAD` mismatch, skip reset.
|
||||
- Branch switched/detached: skip reset.
|
||||
- Worktree removed/recreated at same path: token + instance check prevent wrong-target mutation.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add reconcile-time untouched-state/reset helper in runtime.
|
||||
2. Pass `createdWorktreePath` + `createdInstanceId` into reconcile.
|
||||
3. Invoke helper after ancestry check and before drift emit.
|
||||
4. Always schedule reconcile in the optimistic-base path, including freshness-hit creates.
|
||||
5. Add tests for:
|
||||
- successful guarded auto-refresh to `current`;
|
||||
- dirty/untracked skip;
|
||||
- HEAD-changed skip;
|
||||
- branch-switched/detached skip;
|
||||
- non-ancestor `base_changed`;
|
||||
- stale-token and instance-id mismatch skip;
|
||||
- deleted/recreated path skip;
|
||||
- freshness-hit path schedules reconcile (if adopted).
|
||||
6. Keep non-blocking cold-fetch create test intent intact (`src/main/ipc/worktrees.test.ts`).
|
||||
@@ -551,9 +551,9 @@ export async function createLocalWorktree(
|
||||
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase)
|
||||
if (hasLocalBaseRef) {
|
||||
const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote)
|
||||
if (!isFresh) {
|
||||
optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
|
||||
}
|
||||
optimisticFetchPromise = isFresh
|
||||
? Promise.resolve({ ok: true })
|
||||
: runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
|
||||
} else {
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching')
|
||||
const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
|
||||
@@ -770,11 +770,12 @@ export async function createLocalWorktree(
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const createdInstanceId = randomUUID()
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
// Why: path-derived worktree IDs can be reused after external deletion.
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
// attach to the new occupant of the same path.
|
||||
instanceId: randomUUID(),
|
||||
instanceId: createdInstanceId,
|
||||
// Stamp activity so the worktree sorts into its final position
|
||||
// immediately — prevents scroll-to-reveal racing with a later
|
||||
// bumpWorktreeActivity that would re-sort the list.
|
||||
@@ -882,6 +883,8 @@ export async function createLocalWorktree(
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
worktreeId,
|
||||
createdWorktreePath: created.path,
|
||||
createdInstanceId,
|
||||
base: optimisticBase,
|
||||
branchName,
|
||||
createdBaseSha,
|
||||
|
||||
@@ -956,6 +956,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdWorktreePath: '/workspace/improve-dashboard',
|
||||
createdInstanceId: expect.any(String),
|
||||
createdBaseSha: 'created-sha',
|
||||
fetchPromise: pendingFetch
|
||||
})
|
||||
@@ -968,6 +970,58 @@ describe('registerWorktreeHandlers', () => {
|
||||
resolveFetch()
|
||||
})
|
||||
|
||||
it('schedules optimistic reconcile when remote-tracking base freshness is cached', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
runtimeStub.isRemoteFetchFresh.mockResolvedValue(true)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'created-sha',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expect(runtimeStub.getOrStartRemoteFetch).not.toHaveBeenCalled()
|
||||
expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||||
status: 'checking',
|
||||
base: 'origin/main',
|
||||
remote: 'origin'
|
||||
})
|
||||
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdWorktreePath: '/workspace/improve-dashboard',
|
||||
createdInstanceId: expect.any(String),
|
||||
createdBaseSha: 'created-sha',
|
||||
fetchPromise: expect.any(Promise)
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
runtimeStub.reconcileWorktreeBaseStatus.mock.calls[0][0].fetchPromise
|
||||
).resolves.toEqual({ ok: true })
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws a clear error when no default base ref can be resolved', async () => {
|
||||
// Why: guard against regressing to a silent 'origin/main' fallback. When
|
||||
// getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD,
|
||||
|
||||
@@ -5470,6 +5470,8 @@ describe('OrcaRuntimeService', () => {
|
||||
repoId: TEST_REPO_ID,
|
||||
repoPath: TEST_REPO_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
createdWorktreePath: TEST_WORKTREE_PATH,
|
||||
createdInstanceId: 'instance-1',
|
||||
base: {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
@@ -5485,6 +5487,306 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(worktreeBaseStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
|
||||
function createReconcileRuntime(instanceId: string | null = 'instance-1'): {
|
||||
runtime: OrcaRuntimeService
|
||||
worktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const worktreeBaseStatus = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
worktreeId === TEST_WORKTREE_ID && instanceId
|
||||
? { ...store.getWorktreeMeta(worktreeId), instanceId }
|
||||
: store.getWorktreeMeta(worktreeId)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setNotifier({
|
||||
worktreeBaseStatus,
|
||||
worktreeRemoteBranchConflict: vi.fn()
|
||||
} as never)
|
||||
return { runtime, worktreeBaseStatus }
|
||||
}
|
||||
|
||||
function mockReconcileGit(options: {
|
||||
postFetchSha?: string
|
||||
ancestor?: boolean
|
||||
worktreeExists?: boolean
|
||||
headSha?: string
|
||||
branchName?: string
|
||||
branchDetached?: boolean
|
||||
status?: string
|
||||
baseRefMissing?: boolean
|
||||
}) {
|
||||
const {
|
||||
postFetchSha = 'new-base-sha',
|
||||
ancestor = true,
|
||||
worktreeExists = true,
|
||||
headSha = 'created-base-sha',
|
||||
branchName = 'feature',
|
||||
branchDetached = false,
|
||||
status = '',
|
||||
baseRefMissing = false
|
||||
} = options
|
||||
|
||||
return vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
|
||||
const command = args as string[]
|
||||
const cwd = (options as { cwd?: string } | undefined)?.cwd
|
||||
if (
|
||||
cwd === TEST_REPO_PATH &&
|
||||
command[0] === 'rev-parse' &&
|
||||
command[1] === '--verify' &&
|
||||
command[2] === `${remoteTrackingBase.ref}^{commit}`
|
||||
) {
|
||||
if (baseRefMissing) {
|
||||
throw new Error('missing base ref')
|
||||
}
|
||||
return { stdout: `${postFetchSha}\n`, stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'merge-base') {
|
||||
if (!ancestor) {
|
||||
throw new Error('not ancestor')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_WORKTREE_PATH && command[0] === 'rev-parse') {
|
||||
if (command[1] === '--is-inside-work-tree') {
|
||||
if (!worktreeExists) {
|
||||
throw new Error('not a worktree')
|
||||
}
|
||||
return { stdout: 'true\n', stderr: '' }
|
||||
}
|
||||
if (command[1] === '--verify' && command[2] === 'HEAD^{commit}') {
|
||||
return { stdout: `${headSha}\n`, stderr: '' }
|
||||
}
|
||||
}
|
||||
if (cwd === TEST_WORKTREE_PATH && command[0] === 'symbolic-ref') {
|
||||
if (branchDetached) {
|
||||
throw new Error('detached')
|
||||
}
|
||||
return { stdout: `${branchName}\n`, stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_WORKTREE_PATH && command[0] === 'status') {
|
||||
return { stdout: status, stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_WORKTREE_PATH && command[0] === 'reset') {
|
||||
return { stdout: `HEAD is now at ${postFetchSha}\n`, stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'rev-list') {
|
||||
return { stdout: '3\n', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'log') {
|
||||
return { stdout: 'base commit 3\nbase commit 2\n', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'config') {
|
||||
throw new Error('config missing')
|
||||
}
|
||||
if (
|
||||
cwd === TEST_REPO_PATH &&
|
||||
command[0] === 'rev-parse' &&
|
||||
command[1] === '--verify' &&
|
||||
command[2] === 'refs/remotes/origin/feature^{commit}'
|
||||
) {
|
||||
throw new Error('no publish branch conflict')
|
||||
}
|
||||
throw new Error(`unexpected git command: ${command.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function reconcileWithToken(runtime: OrcaRuntimeService, token: string): Promise<void> {
|
||||
await runtime.reconcileWorktreeBaseStatus({
|
||||
repoId: TEST_REPO_ID,
|
||||
repoPath: TEST_REPO_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
createdWorktreePath: TEST_WORKTREE_PATH,
|
||||
createdInstanceId: 'instance-1',
|
||||
base: remoteTrackingBase,
|
||||
branchName: 'feature',
|
||||
createdBaseSha: 'created-base-sha',
|
||||
token,
|
||||
fetchPromise: Promise.resolve({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
it('auto-refreshes an untouched created worktree to current after fetch fast-forwards base', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({})
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith({
|
||||
repoId: TEST_REPO_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
base: 'origin/main',
|
||||
remote: 'origin',
|
||||
status: 'current'
|
||||
})
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auto-refresh and emits drift when the created worktree has untracked files', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ status: '?? scratch.txt\n' })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'drift',
|
||||
behind: 3,
|
||||
recentSubjects: ['base commit 3', 'base commit 2']
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auto-refresh and emits drift when HEAD changed before reconcile', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ headSha: 'user-commit-sha' })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(expect.objectContaining({ status: 'drift' }))
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auto-refresh and emits drift when the created branch was switched or detached', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ branchDetached: true })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(expect.objectContaining({ status: 'drift' }))
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits base_changed without mutation when the fetched base rewrote history', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ ancestor: false })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'base_changed' })
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips stale-token and instance-mismatch reconciles without mutating or emitting stale status', async () => {
|
||||
const stale = createReconcileRuntime()
|
||||
const staleToken = stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const staleGitSpy = mockReconcileGit({})
|
||||
try {
|
||||
await reconcileWithToken(stale.runtime, staleToken)
|
||||
expect(stale.worktreeBaseStatus).not.toHaveBeenCalled()
|
||||
expect(staleGitSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
staleGitSpy.mockRestore()
|
||||
}
|
||||
|
||||
const mismatch = createReconcileRuntime('new-instance')
|
||||
const mismatchToken = mismatch.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const mismatchGitSpy = mockReconcileGit({})
|
||||
try {
|
||||
await reconcileWithToken(mismatch.runtime, mismatchToken)
|
||||
|
||||
expect(mismatchGitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(mismatch.worktreeBaseStatus).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
mismatchGitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auto-refresh and emits drift when the created path is gone', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ worktreeExists: false })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(expect.objectContaining({ status: 'drift' }))
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits unknown without mutation when fetch fails or the base ref is missing', async () => {
|
||||
const fetchFailure = createReconcileRuntime()
|
||||
const fetchFailureToken = fetchFailure.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
await fetchFailure.runtime.reconcileWorktreeBaseStatus({
|
||||
repoId: TEST_REPO_ID,
|
||||
repoPath: TEST_REPO_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
createdWorktreePath: TEST_WORKTREE_PATH,
|
||||
createdInstanceId: 'instance-1',
|
||||
base: remoteTrackingBase,
|
||||
branchName: 'feature',
|
||||
createdBaseSha: 'created-base-sha',
|
||||
token: fetchFailureToken,
|
||||
fetchPromise: Promise.resolve({ ok: false, errorKind: 'git_error' })
|
||||
})
|
||||
expect(fetchFailure.worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'unknown' })
|
||||
)
|
||||
|
||||
const missingBase = createReconcileRuntime()
|
||||
const missingBaseToken = missingBase.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ baseRefMissing: true })
|
||||
try {
|
||||
await reconcileWithToken(missingBase.runtime, missingBaseToken)
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(missingBase.worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'unknown' })
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
|
||||
// Reproduces: CLI-created worktrees fail with "Access denied: unknown
|
||||
// repository or worktree path" because the filesystem-auth cache was
|
||||
|
||||
@@ -6065,10 +6065,77 @@ export class OrcaRuntimeService {
|
||||
this.notifier?.worktreeBaseStatus?.(event)
|
||||
}
|
||||
|
||||
private async refreshUntouchedCreatedWorktree(args: {
|
||||
worktreeId: string
|
||||
createdWorktreePath: string
|
||||
createdInstanceId: string
|
||||
branchName: string
|
||||
createdBaseSha: string
|
||||
postFetchSha: string
|
||||
stillCurrent: () => boolean
|
||||
}): Promise<'refreshed' | 'skipped' | 'stale'> {
|
||||
const instanceMatches = (): boolean =>
|
||||
this.store?.getWorktreeMeta(args.worktreeId)?.instanceId === args.createdInstanceId
|
||||
if (!args.stillCurrent()) {
|
||||
return 'stale'
|
||||
}
|
||||
if (!instanceMatches()) {
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: isWorkTreeStdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--is-inside-work-tree'],
|
||||
{ cwd: args.createdWorktreePath }
|
||||
)
|
||||
if (isWorkTreeStdout.trim() !== 'true') {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const { stdout: headStdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', 'HEAD^{commit}'],
|
||||
{ cwd: args.createdWorktreePath }
|
||||
)
|
||||
if (headStdout.trim() !== args.createdBaseSha) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const { stdout: branchStdout } = await gitExecFileAsync(
|
||||
['symbolic-ref', '--quiet', '--short', 'HEAD'],
|
||||
{ cwd: args.createdWorktreePath }
|
||||
)
|
||||
if (branchStdout.trim() !== args.branchName) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const { stdout: statusStdout } = await gitExecFileAsync(['status', '--porcelain'], {
|
||||
cwd: args.createdWorktreePath
|
||||
})
|
||||
if (statusStdout.trim()) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
if (!args.stillCurrent()) {
|
||||
return 'stale'
|
||||
}
|
||||
if (!instanceMatches()) {
|
||||
return 'stale'
|
||||
}
|
||||
await gitExecFileAsync(['reset', '--hard', args.postFetchSha], {
|
||||
cwd: args.createdWorktreePath
|
||||
})
|
||||
return args.stillCurrent() && instanceMatches() ? 'refreshed' : 'stale'
|
||||
} catch {
|
||||
return args.stillCurrent() ? 'skipped' : 'stale'
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileWorktreeBaseStatus(args: {
|
||||
repoId: string
|
||||
repoPath: string
|
||||
worktreeId: string
|
||||
createdWorktreePath: string
|
||||
createdInstanceId: string
|
||||
base: RemoteTrackingBase
|
||||
branchName: string
|
||||
createdBaseSha: string
|
||||
@@ -6176,6 +6243,24 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
|
||||
const refreshResult = await this.refreshUntouchedCreatedWorktree({
|
||||
worktreeId: args.worktreeId,
|
||||
createdWorktreePath: args.createdWorktreePath,
|
||||
createdInstanceId: args.createdInstanceId,
|
||||
branchName: args.branchName,
|
||||
createdBaseSha: args.createdBaseSha,
|
||||
postFetchSha,
|
||||
stillCurrent
|
||||
})
|
||||
if (refreshResult === 'stale') {
|
||||
return
|
||||
}
|
||||
if (refreshResult === 'refreshed') {
|
||||
emit({ status: 'current' })
|
||||
await checkPublishRemoteConflict()
|
||||
return
|
||||
}
|
||||
|
||||
const { stdout: countStdout } = await gitExecFileAsync(
|
||||
['rev-list', '--count', `${args.createdBaseSha}..${postFetchSha}`],
|
||||
{ cwd: args.repoPath }
|
||||
|
||||
Reference in New Issue
Block a user