diff --git a/docs/worktree-post-create-base-refresh.md b/docs/worktree-post-create-base-refresh.md new file mode 100644 index 00000000000..84ec2d4a9d5 --- /dev/null +++ b/docs/worktree-post-create-base-refresh.md @@ -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//`. + +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 ` 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`). diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index f97b0af4229..8d59c2df0ed 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.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 = { // 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, diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 5e53b672d3a..c93220957cb 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2738a83fb50..d9fff331d69 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 + } { + 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 { + 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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 974f296014a..c930561b44b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 }