mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(worktrees): keep each same-path copy on its own host branch (#33428)
* fix(worktrees): one host's git status no longer rewrites a same-path twin's branch (#32918) * fix(worktrees): match a twin through its runtime alias and persist its review clear on the same copy * fix(worktrees): keep the id-only patch for a lone copy; background refreshes refuse an unplaced twin * fix(worktrees): a twin's identity patch skips the id-keyed review clear instead of reaching its sibling * fix(worktrees): count runtime provenance when finding twins; Source Control refresh refuses an unplaced twin * fix(worktrees): a twin's branch switch clears its own push target and review link on its host * fix(worktrees): a twin's review clear keeps its stale-refetch guard and relink check, scoped to its own copy * fix(worktrees): a twin tombstone names its full owner, and detected rows are stamped before sanitizing * fix(worktrees): each twin copy keeps its own review-clear tombstone * fix(worktrees): a rename carries each twin copy's review-clear tombstone * fix(worktrees): a copy's tombstone from its twin days still drives its clear retry * chore(worktrees): name why the existing review-link cast is safe * fix(worktrees): one resolver names the copy for every git-identity read and write * fix(worktrees): a Create PR run refuses when its copy no longer resolves; local null stays local * fix(worktrees): an unselected twin stays unplaced; a legacy row's guard survives its first stamp * fix(worktrees): a Create PR run keeps the copy it pinned at start; a legacy row's relink drops its stamped guard * fix(worktrees): key review clears by the full stamp the next listing gives the row * fix(worktrees): fill only missing stamp parts when keying review clears * fix(worktrees): stamp review-clear keys from repo registrations, not the focused host * fix(worktrees): a local row keeps its review-clear guard when discovery stamps it local * fix(worktrees): stamp review-clear keys from the sole repo registration first * fix(worktrees): a runtime-hosted copy's owner key ignores backfilled provenance * fix(worktrees): Create PR and post-generation refresh read the pinned copy's push target * fix(worktrees): a lone copy's push target still answers when the host index misses * fix(worktrees): the push-target fallback accepts only an unstamped lone copy for a named host * fix(worktrees): only SSH copies borrow a registration's provenance for review-clear keys
This commit is contained in:
@@ -71,10 +71,10 @@
|
||||
13 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts # store writer
|
||||
6 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts # store writer
|
||||
3 src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts # store writer
|
||||
6 src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts # store writer
|
||||
4 src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts # store writer
|
||||
6 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts # store writer
|
||||
8 src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts # store writer
|
||||
3 src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts # store writer
|
||||
2 src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts # store writer
|
||||
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts # store writer
|
||||
6 src/renderer/src/store/slices/worktrees/session/worktree-activity-bump.ts # store writer
|
||||
6 src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-row-removal.ts # store writer
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ChecksPanelManualRefreshInput = Pick<
|
||||
| 'fetchPRForBranch'
|
||||
| 'gitStatusSnapshot'
|
||||
| 'ownerTarget'
|
||||
| 'ownerCopyHostId'
|
||||
| 'panelContextKey'
|
||||
| 'panelContextKeyRef'
|
||||
| 'pollIntervalRef'
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState, type SetStateAction } from 'react'
|
||||
import { useWorktreeGitOwnerTarget } from '../worktree-git-owner-target'
|
||||
import { useWorktreeGitCopyHostId } from '@/lib/worktree-git-copy'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { useChecksPanelTerminalWorktree } from '../use-checks-panel-terminal-worktree'
|
||||
@@ -234,6 +235,7 @@ export function useChecksPanelControllerState() {
|
||||
)
|
||||
// Route Git by workspace ownership rather than focused server.
|
||||
const ownerTarget = useWorktreeGitOwnerTarget(activeWorktreeId)
|
||||
const ownerCopyHostId = useWorktreeGitCopyHostId(activeWorktreeId)
|
||||
const repoConnectionId = repo?.connectionId?.trim() || null
|
||||
// Separate local host/WSL distros; remote IDs already scope SSH/runtime contexts.
|
||||
const localExecutionScope = useMemo<string | null>(() => {
|
||||
@@ -388,6 +390,7 @@ export function useChecksPanelControllerState() {
|
||||
runtimeEnvironmentId,
|
||||
ownerSettings,
|
||||
ownerTarget,
|
||||
ownerCopyHostId,
|
||||
repoConnectionId,
|
||||
localExecutionScope,
|
||||
sshConnectionStatus,
|
||||
|
||||
+6
-2
@@ -5,6 +5,7 @@ import type { PullRequestGenerationOutcome } from '../create-pull-request-dialog
|
||||
import { settlePullRequestGenerationRequest } from '../pull-request-generation-request-outcome'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import {
|
||||
generateRuntimePullRequestFields,
|
||||
cancelRuntimeGeneratePullRequestFields
|
||||
@@ -86,10 +87,13 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) {
|
||||
return undefined
|
||||
}
|
||||
const requestId = allocatePullRequestGenerationRequestId()
|
||||
// Why one resolution: the result must land on this copy, not a same-path twin.
|
||||
const copy = activeWorktreeId ? resolveWorktreeGitCopyNow(activeWorktreeId) : null
|
||||
const context: PullRequestGenerationContext = {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
|
||||
connectionId: copy ? copy.connectionId : (getConnectionId(activeWorktreeId) ?? undefined),
|
||||
ownerHostId: copy?.hostId ?? null,
|
||||
jiraIssueKey:
|
||||
(hostedReviewCreateProvider === 'github' || hostedReviewCreateProvider === 'gitlab') &&
|
||||
activeWorktreeId
|
||||
@@ -102,7 +106,7 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) {
|
||||
requestId,
|
||||
repoId: repo.id,
|
||||
branch,
|
||||
runtimeTarget: ownerTarget
|
||||
runtimeTarget: copy?.transport ?? ownerTarget
|
||||
}
|
||||
const seed = { ...fields }
|
||||
const previousRequiresPushBeforeCreate =
|
||||
|
||||
+8
@@ -15,6 +15,14 @@ vi.mock('@/runtime/runtime-git-status-client', () => ({
|
||||
vi.mock('@/runtime/runtime-git-sync-client', () => ({
|
||||
getRuntimeGitUpstreamStatus: mocks.getRuntimeGitUpstreamStatus
|
||||
}))
|
||||
vi.mock('@/lib/worktree-git-copy', () => ({
|
||||
resolveWorktreeGitCopyNow: (worktreeId: string) => ({
|
||||
worktreeId,
|
||||
hostId: 'local',
|
||||
connectionId: undefined,
|
||||
transport: { kind: 'local' }
|
||||
})
|
||||
}))
|
||||
|
||||
import { useChecksPanelGitStatusEffects } from './use-checks-panel-git-status-effects'
|
||||
import { deferred, flush, mountProbe, unmountProbes } from '../source-control-hook-test-harness'
|
||||
|
||||
+17
-7
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client'
|
||||
import { getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-sync-client'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import {
|
||||
buildChecksPanelEligibilityGitFingerprint,
|
||||
shouldClearChecksPanelGitStatusSnapshot,
|
||||
@@ -157,7 +158,6 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect
|
||||
}
|
||||
let stale = false
|
||||
const requestContextKey = panelContextKey
|
||||
const connectionId = activeConnectionId ?? undefined
|
||||
// Worktree-keyed global maps only invalidate; fetch an owner-scoped local snapshot.
|
||||
if (gitStatusSnapshotRetryTimerRef.current) {
|
||||
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
|
||||
@@ -166,12 +166,18 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect
|
||||
setGitStatusSnapshot((snapshot) =>
|
||||
shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot
|
||||
)
|
||||
// Why one resolution: a same-path twin must not get this copy's transport, SSH target or answer.
|
||||
const copy = resolveWorktreeGitCopyNow(activeWorktreeId)
|
||||
if (!copy) {
|
||||
return
|
||||
}
|
||||
const context = {
|
||||
target: ownerTarget,
|
||||
target: copy.transport,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
connectionId
|
||||
connectionId: copy.connectionId
|
||||
}
|
||||
const ownerHostId = copy.hostId
|
||||
const runRequest = async (): Promise<void> => {
|
||||
try {
|
||||
const status = await getRuntimeGitStatus(context, { admissionTier: 'status' })
|
||||
@@ -180,10 +186,14 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect
|
||||
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
|
||||
) {
|
||||
// Checks may be the sole Git surface; commit branch identity before upstream refresh can fail.
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: status.head,
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
})
|
||||
updateWorktreeGitIdentity(
|
||||
activeWorktreeId,
|
||||
{
|
||||
head: status.head,
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
},
|
||||
ownerHostId
|
||||
)
|
||||
}
|
||||
let freshRemoteStatus = status.upstreamStatus
|
||||
if (activeWorktreePushTarget) {
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ describe('useChecksPanelManualRefresh ordering', () => {
|
||||
linkedGiteaPR: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedPR: null,
|
||||
ownerCopyHostId: null,
|
||||
ownerTarget: { kind: 'local' },
|
||||
panelContextKey: 'repo-1::worktree-1::main',
|
||||
panelContextKeyRef: { current: 'repo-1::worktree-1::main' },
|
||||
|
||||
+18
-10
@@ -4,6 +4,7 @@ import { buildGitHubPRRefreshStateClearToken } from '@/store/github/pr-refresh-s
|
||||
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client'
|
||||
import { getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-sync-client'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import {
|
||||
hasChecksPanelGitStatusBranchChanged,
|
||||
readChecksPanelRefreshGitIdentitySnapshot,
|
||||
@@ -40,6 +41,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput
|
||||
linkedGiteaPR,
|
||||
linkedGitLabMR,
|
||||
linkedPR,
|
||||
ownerCopyHostId,
|
||||
ownerTarget,
|
||||
panelContextKey,
|
||||
panelContextKeyRef,
|
||||
@@ -97,35 +99,40 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput
|
||||
})
|
||||
try {
|
||||
if (activeWorktreeId && activeWorktreePath && !isFolder && ownerTarget) {
|
||||
// Why one resolution: a same-path twin must not get this copy's transport, SSH target or answer.
|
||||
const copy = resolveWorktreeGitCopyNow(activeWorktreeId, ownerCopyHostId)
|
||||
const ownerHostId = copy?.hostId ?? null
|
||||
const snapshotIdentity = readChecksPanelRefreshGitIdentitySnapshot({
|
||||
snapshot: gitStatusSnapshot,
|
||||
contextKey: panelContextKey,
|
||||
currentBranch: branch
|
||||
})
|
||||
if (snapshotIdentity.kind === 'changed') {
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: snapshotIdentity.head,
|
||||
branch: snapshotIdentity.branch
|
||||
})
|
||||
updateWorktreeGitIdentity(
|
||||
activeWorktreeId,
|
||||
{ head: snapshotIdentity.head, branch: snapshotIdentity.branch },
|
||||
ownerHostId
|
||||
)
|
||||
// A discovered terminal branch switch must restart branch-keyed effects rather than refresh old PR data.
|
||||
refreshOutcome = 'branch-changed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const statusContext = {
|
||||
target: ownerTarget,
|
||||
target: copy?.transport ?? ownerTarget,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath,
|
||||
connectionId: activeConnectionId ?? undefined
|
||||
connectionId: copy ? copy.connectionId : (activeConnectionId ?? undefined)
|
||||
}
|
||||
const status = await getRuntimeGitStatus(statusContext, {
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
const observedBranch = status.branch ?? (status.head ? null : undefined)
|
||||
updateWorktreeGitIdentity(activeWorktreeId, {
|
||||
head: status.head,
|
||||
branch: observedBranch
|
||||
})
|
||||
updateWorktreeGitIdentity(
|
||||
activeWorktreeId,
|
||||
{ head: status.head, branch: observedBranch },
|
||||
ownerHostId
|
||||
)
|
||||
if (
|
||||
observedBranch !== undefined &&
|
||||
hasChecksPanelGitStatusBranchChanged({
|
||||
@@ -381,6 +388,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput
|
||||
fetchHostedReviewForBranch,
|
||||
expireGitHubPRRefreshState,
|
||||
isCurrentAsyncResult,
|
||||
ownerCopyHostId,
|
||||
ownerTarget,
|
||||
updateWorktreeGitIdentity,
|
||||
panelContextKeyRef,
|
||||
|
||||
+10
@@ -53,6 +53,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-hidden',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
@@ -74,6 +75,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
@@ -92,6 +94,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-background',
|
||||
worktreePath: '/other-repo',
|
||||
deps: makeDeps()
|
||||
@@ -111,6 +114,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
// The common watcher refresh used to omit options and silently lose the gate.
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
@@ -131,6 +135,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps(),
|
||||
@@ -153,6 +158,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps(),
|
||||
@@ -173,6 +179,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
@@ -180,6 +187,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base')
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps: makeDeps()
|
||||
@@ -204,6 +212,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-strict',
|
||||
worktreePath: '/repo',
|
||||
deps: { ...makeDeps(), fetchUpstreamStatus: undefined }
|
||||
@@ -224,6 +233,7 @@ describe('branch line total request gate on git status refreshes', () => {
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-strict',
|
||||
worktreePath: '/repo',
|
||||
deps: { ...makeDeps(), fetchUpstreamStatus: undefined }
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
@@ -68,10 +69,14 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
admissionTier: 'status'
|
||||
})
|
||||
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status)
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', {
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
{
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature'
|
||||
},
|
||||
null
|
||||
)
|
||||
expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-1', status.upstreamStatus)
|
||||
expect(deps.fetchUpstreamStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -93,6 +98,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -118,6 +124,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-2',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-2',
|
||||
@@ -125,10 +132,14 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
})
|
||||
|
||||
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-2', status)
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-2', {
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'wt-2',
|
||||
{
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
expect(deps.setUpstreamStatus).not.toHaveBeenCalled()
|
||||
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, {
|
||||
runtimeTarget: { kind: 'local' },
|
||||
@@ -148,12 +159,14 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-normal',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
})
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-strict',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -198,6 +211,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
const automatic = refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -206,6 +220,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -236,6 +251,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
const olderRefresh = refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-automatic-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -243,6 +259,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-automatic-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -269,6 +286,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
const olderRefresh = refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-failed-veto',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -277,6 +295,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
await expect(
|
||||
refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-failed-veto',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -306,6 +325,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-stale',
|
||||
worktreePath: '/repo',
|
||||
deps,
|
||||
@@ -330,6 +350,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-current',
|
||||
worktreePath: '/repo',
|
||||
deps,
|
||||
@@ -366,6 +387,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
const automatic = refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-fetch-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -374,6 +396,7 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-fetch-race',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
@@ -400,14 +423,19 @@ describe('refreshGitStatusForWorktree', () => {
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-detached',
|
||||
worktreePath: '/repo',
|
||||
deps
|
||||
})
|
||||
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-detached', {
|
||||
head: 'abc123456789',
|
||||
branch: null
|
||||
})
|
||||
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'wt-detached',
|
||||
{
|
||||
head: 'abc123456789',
|
||||
branch: null
|
||||
},
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './push-target-upstream-refresh-cache'
|
||||
import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
beginAutomaticUpstreamRefresh,
|
||||
@@ -24,7 +25,8 @@ export type GitStatusRefreshDeps = {
|
||||
setGitStatus: (worktreeId: string, status: GitStatusResult) => void
|
||||
updateWorktreeGitIdentity: (
|
||||
worktreeId: string,
|
||||
identity: { head?: string; branch?: string | null }
|
||||
identity: { head?: string; branch?: string | null },
|
||||
ownerHostId: ExecutionHostId | null
|
||||
) => void
|
||||
setUpstreamStatus: (worktreeId: string, status: GitUpstreamStatus) => void
|
||||
fetchUpstreamStatus: (
|
||||
@@ -99,6 +101,7 @@ export function clearGitStatusRefreshOrderingForTests(): void {
|
||||
|
||||
export async function refreshGitStatusForWorktree({
|
||||
target,
|
||||
ownerHostId,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
@@ -108,6 +111,8 @@ export async function refreshGitStatusForWorktree({
|
||||
}: {
|
||||
/** The worktree owner's transport. */
|
||||
target: RuntimeClientTarget
|
||||
/** The copy `target` answers for, read when the request starts. */
|
||||
ownerHostId: ExecutionHostId | null
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
@@ -146,11 +151,15 @@ export async function refreshGitStatusForWorktree({
|
||||
|
||||
deps.setGitStatus(worktreeId, status)
|
||||
// Read shell-driven branch changes from git status --branch without another worktree-list poll.
|
||||
deps.updateWorktreeGitIdentity(worktreeId, {
|
||||
head: status.head,
|
||||
// Detached HEAD needs explicit null to clear any stale branch name.
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
})
|
||||
deps.updateWorktreeGitIdentity(
|
||||
worktreeId,
|
||||
{
|
||||
head: status.head,
|
||||
// Detached HEAD needs explicit null to clear any stale branch name.
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
},
|
||||
ownerHostId
|
||||
)
|
||||
request?.onStatusAccepted?.(status)
|
||||
if (pushTarget) {
|
||||
// Use Orca's explicit publish target for PR-created worktrees rather than Git's configured upstream.
|
||||
@@ -179,7 +188,14 @@ export async function refreshGitStatusForWorktree({
|
||||
if (upstreamStatus) {
|
||||
// Briefly reuse unchanged automatic comparisons because explicit targets require several Git subprocesses.
|
||||
storeCachedAutomaticPushTargetUpstreamStatus(
|
||||
{ target, worktreeId, worktreePath, connectionId, pushTarget, status },
|
||||
{
|
||||
target,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
pushTarget,
|
||||
status
|
||||
},
|
||||
upstreamStatus
|
||||
)
|
||||
}
|
||||
@@ -225,6 +241,7 @@ export async function refreshGitStatusForWorktree({
|
||||
|
||||
export async function refreshGitStatusForWorktreeStrict({
|
||||
target,
|
||||
ownerHostId,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
@@ -233,6 +250,8 @@ export async function refreshGitStatusForWorktreeStrict({
|
||||
}: {
|
||||
/** The worktree owner's transport. */
|
||||
target: RuntimeClientTarget
|
||||
/** The copy `target` answers for, read when the request starts. */
|
||||
ownerHostId: ExecutionHostId | null
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
@@ -264,11 +283,15 @@ export async function refreshGitStatusForWorktreeStrict({
|
||||
|
||||
deps.setGitStatus(worktreeId, status)
|
||||
// Read shell-driven branch changes from git status --branch without another worktree-list poll.
|
||||
deps.updateWorktreeGitIdentity(worktreeId, {
|
||||
head: status.head,
|
||||
// Detached HEAD needs explicit null to clear any stale branch name.
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
})
|
||||
deps.updateWorktreeGitIdentity(
|
||||
worktreeId,
|
||||
{
|
||||
head: status.head,
|
||||
// Detached HEAD needs explicit null to clear any stale branch name.
|
||||
branch: status.branch ?? (status.head ? null : undefined)
|
||||
},
|
||||
ownerHostId
|
||||
)
|
||||
if (pushTarget) {
|
||||
// Use Orca's explicit publish target for PR-created worktrees rather than Git's configured upstream.
|
||||
const upstreamStatus = await getRuntimeGitUpstreamStatus(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { GitStatusResult } from '../../../../shared/git-status-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveWorktreeGitCopyNow, worktreeCopyPushTarget } from '@/lib/worktree-git-copy'
|
||||
import { refreshGitStatusForWorktree } from './git-status-refresh'
|
||||
|
||||
// #32918: the same project at the same path on this machine and on an SSH host.
|
||||
const id = 'repo1::/srv/ai-tools'
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
|
||||
function repo(overrides: Partial<Repo>): Repo {
|
||||
return {
|
||||
id: 'repo1',
|
||||
path: '/srv/ai-tools',
|
||||
displayName: 'ai-tools',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function copy(overrides: Partial<Worktree>): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
path: '/srv/ai-tools',
|
||||
head: 'aaa',
|
||||
branch: 'refs/heads/master',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
displayName: 'master',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const status = vi.fn<(args: { connectionId?: string }) => Promise<GitStatusResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
status.mockResolvedValue({
|
||||
entries: [],
|
||||
head: 'bbb2',
|
||||
branch: 'refs/heads/feature',
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
vi.stubGlobal('window', { ...window, api: { git: { status } } })
|
||||
useAppStore.setState({
|
||||
repos: [repo({ connectionId: null }), repo({ connectionId: 'box-b' })],
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
copy({ hostId: 'local' }),
|
||||
copy({
|
||||
hostId: 'ssh:box-b',
|
||||
head: 'bbb',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'feature'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeWorktreeId: id,
|
||||
activeWorkspaceExecutionHostId: 'ssh:box-b'
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppState, true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('git status poll for a same-path twin', () => {
|
||||
it('refuses an unselected twin instead of guessing a copy', () => {
|
||||
useAppStore.setState({ activeWorktreeId: null, activeWorkspaceExecutionHostId: null })
|
||||
|
||||
expect(resolveWorktreeGitCopyNow(id)).toBeNull()
|
||||
})
|
||||
|
||||
it("asks the selected SSH copy's host and patches only that copy", async () => {
|
||||
const target = resolveWorktreeGitCopyNow(id)
|
||||
expect(target).toMatchObject({ hostId: 'ssh:box-b', connectionId: 'box-b' })
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
|
||||
await refreshGitStatusForWorktree({
|
||||
target: target.transport,
|
||||
ownerHostId: target.hostId,
|
||||
worktreeId: id,
|
||||
worktreePath: '/srv/ai-tools',
|
||||
connectionId: target.connectionId,
|
||||
deps: {
|
||||
setGitStatus: state.setGitStatus,
|
||||
updateWorktreeGitIdentity: state.updateWorktreeGitIdentity,
|
||||
setUpstreamStatus: state.setUpstreamStatus,
|
||||
fetchUpstreamStatus: async () => null
|
||||
}
|
||||
})
|
||||
|
||||
expect(status).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'box-b' }))
|
||||
const [local, remote] = useAppStore.getState().worktreesByRepo.repo1
|
||||
expect(local).toMatchObject({ head: 'aaa', branch: 'refs/heads/master' })
|
||||
expect(remote).toMatchObject({ head: 'bbb2', branch: 'refs/heads/feature' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('push target for a same-path twin', () => {
|
||||
it("names the pinned SSH copy's remote and branch, not the local copy's", () => {
|
||||
const [local, remote] = useAppStore.getState().worktreesByRepo.repo1
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
{ ...local, pushTarget: { remoteName: 'origin', branchName: 'local-review' } },
|
||||
{ ...remote, pushTarget: { remoteName: 'fork', branchName: 'remote-review' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(worktreeCopyPushTarget(useAppStore.getState(), id, 'ssh:box-b')).toEqual({
|
||||
remoteName: 'fork',
|
||||
branchName: 'remote-review'
|
||||
})
|
||||
// An unplaced host must not fall back to either twin, nor to a lone copy stamped elsewhere.
|
||||
expect(worktreeCopyPushTarget(useAppStore.getState(), id, 'ssh:other')).toBeUndefined()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { repo1: [useAppStore.getState().worktreesByRepo.repo1[0]] }
|
||||
})
|
||||
expect(worktreeCopyPushTarget(useAppStore.getState(), id, 'ssh:box-b')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,7 @@ async function refreshAutomatically(options: {
|
||||
}): Promise<void> {
|
||||
await refreshGitStatusForWorktree({
|
||||
target: runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId ?? null),
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: options.connectionId,
|
||||
@@ -232,6 +233,7 @@ describe('push-target upstream refresh cache', () => {
|
||||
await refreshAutomatically({ deps })
|
||||
await refreshGitStatusForWorktreeStrict({
|
||||
target: { kind: 'local' },
|
||||
ownerHostId: null,
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
pushTarget,
|
||||
|
||||
+8
-4
@@ -25,7 +25,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature',
|
||||
provider: 'github',
|
||||
baseRef: 'origin/main'
|
||||
baseRef: 'origin/main',
|
||||
pinnedCopy: null
|
||||
})
|
||||
|
||||
expect(token.startedAt).toBe(123)
|
||||
@@ -49,7 +50,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature/pr',
|
||||
provider: 'github'
|
||||
provider: 'github',
|
||||
pinnedCopy: null
|
||||
})
|
||||
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
|
||||
@@ -69,7 +71,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: wt1Path,
|
||||
branch: 'feature/pr',
|
||||
provider: 'github'
|
||||
provider: 'github',
|
||||
pinnedCopy: null
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -99,7 +102,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
provider: 'github',
|
||||
baseRef: 'refs/remotes/origin/main'
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
pinnedCopy: null
|
||||
})
|
||||
|
||||
expect(
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import type { RuntimeClientTarget } from '../../../../../../shared/runtime-clien
|
||||
import { requireGitOwnerTarget } from '../../worktree-git-owner-target'
|
||||
import type { GitConflictOperation } from '../../../../../../shared/git-status-types'
|
||||
import type { GitPushTarget } from '../../../../../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
|
||||
export type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
|
||||
|
||||
@@ -11,6 +12,8 @@ export type SourceControlOperationTarget = Omit<RuntimeGitContext, 'target'> & {
|
||||
/** The repo owner's transport; `null` when no repo row names one, which refuses the call. */
|
||||
target: RuntimeClientTarget | null
|
||||
worktreeId: string
|
||||
/** The worktree copy `target` answers for; a same-path twin on another host shares the id. */
|
||||
ownerHostId?: ExecutionHostId | null
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
|
||||
@@ -19,5 +22,7 @@ export function operationGitContext(
|
||||
operation: SourceControlOperationTarget,
|
||||
prefs?: RuntimeGitContext['prefs']
|
||||
): RuntimeGitContext {
|
||||
return { ...operation, target: requireGitOwnerTarget(operation.target), prefs }
|
||||
// Why drop ownerHostId: it names the store copy to patch and is not part of the request.
|
||||
const { ownerHostId: _ownerHostId, ...request } = operation
|
||||
return { ...request, target: requireGitOwnerTarget(operation.target), prefs }
|
||||
}
|
||||
|
||||
@@ -54,8 +54,7 @@ export function useSourceControlPanelState() {
|
||||
isFolder,
|
||||
repositoryHuge,
|
||||
activeConnectionId,
|
||||
activeWorktreeInstanceId,
|
||||
worktreeMap
|
||||
activeWorktreeInstanceId
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
+6
@@ -1,3 +1,4 @@
|
||||
import type { WorktreeGitCopy } from '@/lib/worktree-git-copy'
|
||||
import {
|
||||
isBehindOnlyUpstream,
|
||||
shouldForcePushWithLeaseForUpstream
|
||||
@@ -29,6 +30,11 @@ export type CreatePrIntentRunToken = {
|
||||
branch: string
|
||||
provider: HostedReviewProvider
|
||||
baseRef?: string | null
|
||||
/**
|
||||
* The worktree copy the run started on, pinned with its transport: a same-path twin on another
|
||||
* host shares the id, and re-resolving later could land on it. Null refuses every call.
|
||||
*/
|
||||
pinnedCopy: WorktreeGitCopy | null
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPrIntentOperationTarget } from './use-create-pr-intent-target'
|
||||
|
||||
// #32918: the same project at the same path locally and on an SSH host share one worktree id.
|
||||
describe('createPrIntentOperationTarget', () => {
|
||||
it("refuses when the run's copy did not resolve, never using the local twin", () => {
|
||||
const target = createPrIntentOperationTarget(
|
||||
{
|
||||
repoId: 'repo1',
|
||||
worktreeId: 'repo1::/srv/ai-tools',
|
||||
worktreePath: '/srv/ai-tools',
|
||||
branch: 'feature',
|
||||
provider: 'github',
|
||||
pinnedCopy: null,
|
||||
startedAt: 0
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(target).toMatchObject({ target: null, connectionId: undefined, ownerHostId: null })
|
||||
})
|
||||
})
|
||||
+1
@@ -46,6 +46,7 @@ it.each(['github', 'gitlab'] as const)(
|
||||
branch: 'user/alt-42-fix-login',
|
||||
provider,
|
||||
baseRef: null,
|
||||
pinnedCopy: null,
|
||||
startedAt: 1
|
||||
}
|
||||
const eligibility: HostedReviewCreationEligibility = {
|
||||
|
||||
+1
@@ -151,6 +151,7 @@ export function useSourceControlCreatePrIntentProbes({
|
||||
return await refreshGitStatusForWorktreeStrict({
|
||||
// Background completion must check the worktree that started the intent.
|
||||
target: requireGitOwnerTarget(target.target),
|
||||
ownerHostId: target.ownerHostId ?? null,
|
||||
worktreeId: target.worktreeId,
|
||||
worktreePath: target.worktreePath,
|
||||
connectionId: target.connectionId,
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { createCreatePrIntentRunToken } from './create-pr-intent-flow'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import type { SourceControlCommitAction } from '../commit/use-commit-action'
|
||||
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
|
||||
import type { SourceControlFileListing } from '../listing/use-file-listing'
|
||||
@@ -107,7 +108,9 @@ export function useSourceControlCreatePrIntentRun({
|
||||
// Preserve the UI provider so failed eligibility probes can synthesize the right host’s local prep steps.
|
||||
provider: provisionalHostedReviewProvider,
|
||||
// Pin the selected base across asynchronous commit/push steps.
|
||||
baseRef: effectiveBaseRef ?? null
|
||||
baseRef: effectiveBaseRef ?? null,
|
||||
// The run outlives navigation; its git answers stay on the copy it started from.
|
||||
pinnedCopy: resolveWorktreeGitCopyNow(activeWorktreeId)
|
||||
})
|
||||
const operationTarget = getCreatePrIntentOperationTarget(token)
|
||||
const snapshot = createCreatePrIntentRunSnapshot({
|
||||
|
||||
+33
-14
@@ -1,28 +1,24 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import { worktreeCopyPushTarget } from '@/lib/worktree-git-copy'
|
||||
import type { CreatePrIntentRunToken } from './create-pr-intent-flow'
|
||||
import type { SourceControlOperationTarget } from '../listing/operation-target'
|
||||
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
|
||||
import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state'
|
||||
|
||||
/** Track foreground ownership and pin in-flight Git calls to their original host. */
|
||||
export function useSourceControlCreatePrIntentTarget({
|
||||
activeRepoId,
|
||||
activeRepoTarget,
|
||||
activeWorktreeId,
|
||||
branchName,
|
||||
createPrIntentCurrentTargetRef,
|
||||
effectiveBaseRef,
|
||||
worktreeMap,
|
||||
worktreePath
|
||||
}: {
|
||||
activeRepoId: string | null
|
||||
activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget']
|
||||
activeWorktreeId: string | null
|
||||
branchName: string
|
||||
createPrIntentCurrentTargetRef: SourceControlWorktreeOperationState['createPrIntentCurrentTargetRef']
|
||||
effectiveBaseRef: string | null
|
||||
worktreeMap: SourceControlWorktreeContext['worktreeMap']
|
||||
worktreePath: string | null
|
||||
}) {
|
||||
useEffect(() => {
|
||||
@@ -43,19 +39,42 @@ export function useSourceControlCreatePrIntentTarget({
|
||||
])
|
||||
|
||||
const getCreatePrIntentOperationTarget = useCallback(
|
||||
(token: CreatePrIntentRunToken): SourceControlOperationTarget => ({
|
||||
target: activeRepoTarget,
|
||||
worktreeId: token.worktreeId,
|
||||
worktreePath: token.worktreePath,
|
||||
connectionId: getConnectionId(token.worktreeId) ?? undefined,
|
||||
pushTarget: worktreeMap.get(token.worktreeId)?.pushTarget
|
||||
}),
|
||||
[activeRepoTarget, worktreeMap]
|
||||
(token: CreatePrIntentRunToken): SourceControlOperationTarget =>
|
||||
createPrIntentOperationTarget(
|
||||
token,
|
||||
// Why the pinned copy's row: a same-path twin's push target names another remote and branch.
|
||||
worktreeCopyPushTarget(
|
||||
useAppStore.getState(),
|
||||
token.worktreeId,
|
||||
token.pinnedCopy?.hostId ?? null
|
||||
)
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
return { getCreatePrIntentOperationTarget }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a Create PR run's calls to the copy and transport captured when it started, never
|
||||
* re-resolved: a copy that did not resolve gets no transport, so the run refuses instead of
|
||||
* committing or pushing on a same-path twin's checkout.
|
||||
*/
|
||||
export function createPrIntentOperationTarget(
|
||||
token: CreatePrIntentRunToken,
|
||||
pushTarget: SourceControlOperationTarget['pushTarget']
|
||||
): SourceControlOperationTarget {
|
||||
const copy = token.pinnedCopy
|
||||
return {
|
||||
worktreeId: token.worktreeId,
|
||||
worktreePath: token.worktreePath,
|
||||
pushTarget,
|
||||
target: copy?.transport ?? null,
|
||||
connectionId: copy?.connectionId,
|
||||
ownerHostId: copy?.hostId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export type SourceControlCreatePrIntentTarget = ReturnType<
|
||||
typeof useSourceControlCreatePrIntentTarget
|
||||
>
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@ import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { getWorkspaceJiraIssueKey } from '../../../../../../shared/jira/jira-review-title'
|
||||
import { useCallback } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import {
|
||||
cancelRuntimeGeneratePullRequestFields,
|
||||
generateRuntimePullRequestFields
|
||||
@@ -101,10 +102,13 @@ export function useSourceControlPullRequestGeneration({
|
||||
return undefined
|
||||
}
|
||||
const requestId = allocatePullRequestGenerationRequestId()
|
||||
// Why one resolution: the result must land on this copy, not a same-path twin.
|
||||
const copy = activeWorktreeId ? resolveWorktreeGitCopyNow(activeWorktreeId) : null
|
||||
const context: PullRequestGenerationContext = {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
|
||||
connectionId: copy ? copy.connectionId : (getConnectionId(activeWorktreeId) ?? undefined),
|
||||
ownerHostId: copy?.hostId ?? null,
|
||||
jiraIssueKey:
|
||||
(hostedReviewCreateProvider === 'github' || hostedReviewCreateProvider === 'gitlab') &&
|
||||
activeWorktreeId
|
||||
@@ -117,7 +121,7 @@ export function useSourceControlPullRequestGeneration({
|
||||
requestId,
|
||||
repoId: activeRepo.id,
|
||||
branch: branchName,
|
||||
runtimeTarget: activeRepoTarget
|
||||
runtimeTarget: copy?.transport ?? activeRepoTarget
|
||||
}
|
||||
const seed = { ...fields }
|
||||
setPullRequestGenerationRecord(
|
||||
|
||||
@@ -29,7 +29,6 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat
|
||||
isFolder,
|
||||
remoteStatus,
|
||||
settings,
|
||||
worktreeMap,
|
||||
worktreePath
|
||||
} = panelState
|
||||
|
||||
@@ -77,12 +76,10 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat
|
||||
})
|
||||
const createPrIntentTarget = useSourceControlCreatePrIntentTarget({
|
||||
activeRepoId,
|
||||
activeRepoTarget,
|
||||
activeWorktreeId,
|
||||
branchName,
|
||||
createPrIntentCurrentTargetRef,
|
||||
effectiveBaseRef,
|
||||
worktreeMap,
|
||||
worktreePath
|
||||
})
|
||||
const linkedReviews = useSourceControlLinkedReviews({
|
||||
|
||||
+1
-2
@@ -59,8 +59,7 @@ async function renderOwner(
|
||||
worktreePath: '/same/path',
|
||||
isFolder: false,
|
||||
repositoryHuge: { limit: 1000 },
|
||||
activeConnectionId: connectionId,
|
||||
worktreeMap: new Map()
|
||||
activeConnectionId: connectionId
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
+23
-17
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import {
|
||||
resolveWorktreeGitCopyNow,
|
||||
useWorktreeGitCopyHostId,
|
||||
worktreeCopyPushTarget
|
||||
} from '@/lib/worktree-git-copy'
|
||||
import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from '@/lib/local-path-open-guard'
|
||||
import {
|
||||
beginHugeRepoWarningProbe,
|
||||
@@ -30,8 +34,7 @@ export function useSourceControlStatusRefresh({
|
||||
isFolder,
|
||||
repositoryHuge,
|
||||
activeConnectionId,
|
||||
activeWorktreeInstanceId,
|
||||
worktreeMap
|
||||
activeWorktreeInstanceId
|
||||
}: {
|
||||
activeRepoTarget: RuntimeClientTarget | null
|
||||
activeWorktreeId: string | null
|
||||
@@ -41,12 +44,12 @@ export function useSourceControlStatusRefresh({
|
||||
repositoryHuge: { limit: number } | null | undefined
|
||||
activeConnectionId: string | null
|
||||
activeWorktreeInstanceId?: string
|
||||
worktreeMap: ReadonlyMap<string, { pushTarget?: GitPushTarget }>
|
||||
}): SourceControlStatusRefresh {
|
||||
const setGitStatus = useAppStore((s) => s.setGitStatus)
|
||||
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
|
||||
const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus)
|
||||
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
|
||||
const activeCopyHostId = useWorktreeGitCopyHostId(activeWorktreeId)
|
||||
const localIgnoreBlocked = isLocalPathOpenBlocked(
|
||||
getLocalPathOpenOwnerForRoute({
|
||||
runtimeEnvironmentId:
|
||||
@@ -59,13 +62,17 @@ export function useSourceControlStatusRefresh({
|
||||
if (!activeWorktreeId || !worktreePath || isFolder || !activeRepoTarget) {
|
||||
return
|
||||
}
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
// Why the captured host: a mutation callback can outlive a switch to a same-path twin.
|
||||
const copy = resolveWorktreeGitCopyNow(activeWorktreeId, activeCopyHostId)
|
||||
if (!copy) {
|
||||
return
|
||||
}
|
||||
await refreshGitStatusForWorktree({
|
||||
// Read status on the repo owner, which may differ from the focused host.
|
||||
target: activeRepoTarget,
|
||||
target: copy.transport,
|
||||
ownerHostId: copy.hostId,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
connectionId: copy.connectionId,
|
||||
pushTarget: activePushTarget,
|
||||
deps: {
|
||||
setGitStatus,
|
||||
@@ -77,6 +84,7 @@ export function useSourceControlStatusRefresh({
|
||||
})
|
||||
},
|
||||
[
|
||||
activeCopyHostId,
|
||||
activeRepoTarget,
|
||||
activeWorktreeId,
|
||||
activePushTarget,
|
||||
@@ -167,10 +175,15 @@ export function useSourceControlStatusRefresh({
|
||||
await refreshGitStatusForWorktree({
|
||||
// Generation may finish after host navigation; refresh its original owner.
|
||||
target: context.runtimeTarget,
|
||||
ownerHostId: context.ownerHostId ?? null,
|
||||
worktreeId: context.worktreeId,
|
||||
worktreePath: context.worktreePath,
|
||||
connectionId: context.connectionId,
|
||||
pushTarget: worktreeMap.get(context.worktreeId)?.pushTarget,
|
||||
pushTarget: worktreeCopyPushTarget(
|
||||
useAppStore.getState(),
|
||||
context.worktreeId,
|
||||
context.ownerHostId ?? null
|
||||
),
|
||||
deps: {
|
||||
setGitStatus,
|
||||
updateWorktreeGitIdentity,
|
||||
@@ -182,14 +195,7 @@ export function useSourceControlStatusRefresh({
|
||||
console.warn('[SourceControl] post-generation git status refresh failed', error)
|
||||
}
|
||||
},
|
||||
[
|
||||
fetchUpstreamStatus,
|
||||
isFolder,
|
||||
setGitStatus,
|
||||
setUpstreamStatus,
|
||||
updateWorktreeGitIdentity,
|
||||
worktreeMap
|
||||
]
|
||||
[fetchUpstreamStatus, isFolder, setGitStatus, setUpstreamStatus, updateWorktreeGitIdentity]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
|
||||
const worktree = { id: 'repo-1::/repo', repoId: 'repo-1', path: '/repo' }
|
||||
const repo = { id: 'repo-1', path: '/repo', kind: 'git', connectionId: null as string | null }
|
||||
const LOCAL = { kind: 'local' } as const
|
||||
const localGitOwnerModule = () => ({ getWorktreeGitOwnerTarget: () => LOCAL })
|
||||
const gitCopyModule = (connectionId?: string) => () => ({
|
||||
resolveWorktreeGitCopyNow: (id: string) => ({ worktreeId: id, transport: LOCAL, connectionId })
|
||||
})
|
||||
|
||||
type PollState = {
|
||||
activeWorktreeId: string
|
||||
@@ -94,9 +99,7 @@ async function usePollingOnce(
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('./worktree-git-owner-target', () => ({
|
||||
getWorktreeGitOwnerTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.doMock('./worktree-git-owner-target', localGitOwnerModule)
|
||||
vi.doMock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
|
||||
getState: () => ({ settings: null })
|
||||
@@ -116,6 +119,7 @@ async function usePollingOnce(
|
||||
vi.doMock('@/lib/connection-context', () => ({
|
||||
getConnectionId: () => options.connectionId ?? undefined
|
||||
}))
|
||||
vi.doMock('@/lib/worktree-git-copy', gitCopyModule(options.connectionId ?? undefined))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -378,9 +382,7 @@ describe('useGitStatusPolling', () => {
|
||||
useRef: <T>(initial: T) => ({ current: initial })
|
||||
}
|
||||
})
|
||||
vi.doMock('./worktree-git-owner-target', () => ({
|
||||
getWorktreeGitOwnerTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.doMock('./worktree-git-owner-target', localGitOwnerModule)
|
||||
vi.doMock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
|
||||
getState: () => ({ settings: null })
|
||||
@@ -394,6 +396,7 @@ describe('useGitStatusPolling', () => {
|
||||
useRepoMap: () => new Map([[repo.id, repo]])
|
||||
}))
|
||||
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
|
||||
vi.doMock('@/lib/worktree-git-copy', gitCopyModule())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { status: gitStatus },
|
||||
@@ -494,9 +497,7 @@ describe('useGitStatusPolling', () => {
|
||||
useRef: <T>(initial: T) => ({ current: initial })
|
||||
}
|
||||
})
|
||||
vi.doMock('./worktree-git-owner-target', () => ({
|
||||
getWorktreeGitOwnerTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.doMock('./worktree-git-owner-target', localGitOwnerModule)
|
||||
vi.doMock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
|
||||
getState: () => ({ settings: null })
|
||||
@@ -510,6 +511,7 @@ describe('useGitStatusPolling', () => {
|
||||
useRepoMap: () => new Map([[repo.id, repo]])
|
||||
}))
|
||||
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
|
||||
vi.doMock('@/lib/worktree-git-copy', gitCopyModule())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { status: gitStatus },
|
||||
@@ -621,9 +623,7 @@ describe('useGitStatusPolling', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.doMock('./worktree-git-owner-target', () => ({
|
||||
getWorktreeGitOwnerTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.doMock('./worktree-git-owner-target', localGitOwnerModule)
|
||||
vi.doMock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
|
||||
getState: () => ({ settings: null })
|
||||
@@ -637,6 +637,7 @@ describe('useGitStatusPolling', () => {
|
||||
useRepoMap: () => new Map([[repo.id, repo]])
|
||||
}))
|
||||
vi.doMock('@/lib/connection-context', () => ({ getConnectionId: () => undefined }))
|
||||
vi.doMock('@/lib/worktree-git-copy', gitCopyModule())
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { status: gitStatus },
|
||||
@@ -804,9 +805,7 @@ describe('useGitStatusPolling', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('./worktree-git-owner-target', () => ({
|
||||
getWorktreeGitOwnerTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
vi.doMock('./worktree-git-owner-target', localGitOwnerModule)
|
||||
vi.doMock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
|
||||
getState: () => ({ settings: null })
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { refreshGitStatusForWorktree } from './git-status-refresh'
|
||||
import { isWindowVisible } from '@/lib/window-visibility-interval'
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
hasInteractiveActiveGitStatusConsumer,
|
||||
shouldPollActiveGitStatus
|
||||
} from '@/lib/passive-macos-app-data-access'
|
||||
import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target'
|
||||
import { resolveWorktreeGitCopyNow } from '@/lib/worktree-git-copy'
|
||||
import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh'
|
||||
import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh'
|
||||
import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll'
|
||||
@@ -127,23 +126,19 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
const target = getWorktreeGitOwnerTarget(activeWorktreeId)
|
||||
if (!target) {
|
||||
// Why one resolution: a same-path twin must not get this copy's transport, SSH target or answer.
|
||||
const copy = resolveWorktreeGitCopyNow(activeWorktreeId)
|
||||
if (!copy) {
|
||||
return
|
||||
}
|
||||
await refreshGitStatusForWorktree({
|
||||
target,
|
||||
target: copy.transport,
|
||||
ownerHostId: copy.hostId,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
connectionId: copy.connectionId,
|
||||
pushTarget: activePushTarget,
|
||||
deps: {
|
||||
setGitStatus,
|
||||
updateWorktreeGitIdentity,
|
||||
setUpstreamStatus,
|
||||
fetchUpstreamStatus
|
||||
},
|
||||
deps: { setGitStatus, updateWorktreeGitIdentity, setUpstreamStatus, fetchUpstreamStatus },
|
||||
request: {
|
||||
admissionTier: admissionTierForGitStatusRefreshReason(request.reason),
|
||||
...(request.reason === 'safety' ? { reuseLineStats: true } : {}),
|
||||
|
||||
@@ -30,10 +30,14 @@ describe('applyWorktreeHeadIdentities', () => {
|
||||
}
|
||||
)
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo-1::/repos/project/wt-a', {
|
||||
head: 'bbb222',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'repo-1::/repos/project/wt-a',
|
||||
{
|
||||
head: 'bbb222',
|
||||
branch: 'refs/heads/feature'
|
||||
},
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches Windows-flavored paths that differ only by separators and casing', () => {
|
||||
@@ -50,10 +54,14 @@ describe('applyWorktreeHeadIdentities', () => {
|
||||
updateWorktreeGitIdentity
|
||||
}
|
||||
)
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo-1::C:/repos/project/wt-a', {
|
||||
head: 'bbb222',
|
||||
branch: null
|
||||
})
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'repo-1::C:/repos/project/wt-a',
|
||||
{
|
||||
head: 'bbb222',
|
||||
branch: null
|
||||
},
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips identities whose worktree row does not exist', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Worktree, WorktreeHeadIdentity } from '../../../shared/worktree/types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
|
||||
|
||||
export type WorktreeHeadIdentityApplyDeps = {
|
||||
getWorktreesForRepo: (repoId: string) => Worktree[] | undefined
|
||||
updateWorktreeGitIdentity: (
|
||||
worktreeId: string,
|
||||
identity: { head?: string; branch?: string | null }
|
||||
identity: { head?: string; branch?: string | null },
|
||||
ownerHostId: ExecutionHostId | null
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -35,9 +37,11 @@ export function applyWorktreeHeadIdentities(
|
||||
if (!worktree) {
|
||||
continue
|
||||
}
|
||||
deps.updateWorktreeGitIdentity(worktree.id, {
|
||||
head: identity.head,
|
||||
branch: identity.branch
|
||||
})
|
||||
// Why: this watcher reads this machine's checkouts, never a same-path twin on another host.
|
||||
deps.updateWorktreeGitIdentity(
|
||||
worktree.id,
|
||||
{ head: identity.head, branch: identity.branch },
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
getFolderWorkspaceCandidateRepos,
|
||||
getFolderWorkspaceConnectionId
|
||||
} from './folder-workspace-connection'
|
||||
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
|
||||
import {
|
||||
selectFolderWorkspaces,
|
||||
selectWorkspaceByHostTarget,
|
||||
selectWorktreesByRepo
|
||||
} from '@/store/workspace-record-selectors'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
type ConnectionOwnerState = Pick<
|
||||
AppState,
|
||||
@@ -128,3 +134,32 @@ export function getConnectionIdForFileFromState(
|
||||
)
|
||||
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH connectionId of one host's copy of `worktreeId`. Ids repeat across hosts, so the bare-id
|
||||
* lookup above answers `undefined` for a same-path twin; naming the copy's host resolves it.
|
||||
*/
|
||||
export function getConnectionIdForWorktreeCopy(
|
||||
state: ConnectionOwnerState,
|
||||
worktreeId: string,
|
||||
hostId: ExecutionHostId
|
||||
): string | null | undefined {
|
||||
const row = selectWorkspaceByHostTarget(
|
||||
{
|
||||
worktreesByRepo: selectWorktreesByRepo(state),
|
||||
folderWorkspaces: selectFolderWorkspaces(state)
|
||||
},
|
||||
hostWorktreeTarget({ id: worktreeId, hostId })
|
||||
)
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
const resolution = resolveWorktreeExecutionHost(
|
||||
{
|
||||
byId: (id) => resolveIndexedRepoOwner(state.repos, id),
|
||||
byHost: (id, repoHostId) => findIndexedRepoOwnerForHost(state.repos, id, repoHostId)
|
||||
},
|
||||
{ repoId: row.repoId, hostId: row.hostId ?? hostId }
|
||||
)
|
||||
return resolution.kind === 'resolved' ? resolution.connectionId : undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { parseRoutableExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { getKnownExecutionHostIdForWorktree } from './worktree-runtime-owner'
|
||||
import { getActiveWorkspaceExecutionHostId } from './active-workspace-host'
|
||||
import {
|
||||
selectWorkspaceByHostTarget,
|
||||
selectWorktreesByRepo
|
||||
} from '@/store/workspace-record-selectors'
|
||||
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import type { GitPushTarget } from '../../../shared/worktree/types'
|
||||
import { worktreeCopyOwnerKey } from '@/store/slices/worktrees/metadata/worktree-copy-review-clear'
|
||||
import { runtimeTargetForWorkspaceOwner } from './resolve-owner'
|
||||
import {
|
||||
getConnectionIdForWorktreeCopy,
|
||||
getConnectionIdFromState
|
||||
} from './connection-owner-resolution'
|
||||
|
||||
/**
|
||||
* One host's copy of a worktree, for git work whose answer is written back to the store. Worktree
|
||||
* ids are `repoId::path` and repeat across hosts, so the transport, the SSH target and the copy
|
||||
* the answer lands on must all come from this one resolution.
|
||||
*/
|
||||
export type WorktreeGitCopy = {
|
||||
readonly worktreeId: string
|
||||
/** The copy's host; null only when no row places it, which leaves a same-id twin untouched. */
|
||||
readonly hostId: ExecutionHostId | null
|
||||
readonly connectionId: string | undefined
|
||||
readonly transport: RuntimeClientTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected copy's host, else the owner's when the unqualified owner resolves. An ambiguous twin
|
||||
* with no selection stays unplaced, so its git calls refuse instead of guessing a copy.
|
||||
*/
|
||||
function placedHostId(state: AppState, worktreeId: string): ExecutionHostId | null {
|
||||
const selected = getActiveWorkspaceExecutionHostId(state, worktreeId)
|
||||
if (selected) {
|
||||
return parseRoutableExecutionHostId(selected)?.id ?? null
|
||||
}
|
||||
if (!runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId })) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
parseRoutableExecutionHostId(getKnownExecutionHostIdForWorktree(state, worktreeId))?.id ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `worktreeId` on `hostId`, or on the selected copy when no host is named. Null when the
|
||||
* owner's transport is unknown, so callers skip the call instead of reading the path locally.
|
||||
*/
|
||||
export function resolveWorktreeGitCopy(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId | null
|
||||
): WorktreeGitCopy | null {
|
||||
const copyHostId = hostId ?? placedHostId(state, worktreeId)
|
||||
const transport = runtimeTargetForWorkspaceOwner(state, {
|
||||
workspaceId: worktreeId,
|
||||
...(copyHostId ? { hostId: copyHostId } : {})
|
||||
})
|
||||
if (!transport) {
|
||||
return null
|
||||
}
|
||||
// Why the copy lookup: the bare-id one answers undefined for a twin, which a caller would
|
||||
// otherwise turn into a local `git status` of the SSH copy's path.
|
||||
// A resolved null (local) is an answer; only "unknown" falls through to the copy lookup.
|
||||
const bareConnectionId = getConnectionIdFromState(state, worktreeId)
|
||||
const connectionId =
|
||||
bareConnectionId === undefined && copyHostId
|
||||
? getConnectionIdForWorktreeCopy(state, worktreeId, copyHostId)
|
||||
: bareConnectionId
|
||||
return {
|
||||
worktreeId,
|
||||
hostId: copyHostId,
|
||||
connectionId: connectionId ?? undefined,
|
||||
transport
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link resolveWorktreeGitCopy} against the live store. */
|
||||
export function resolveWorktreeGitCopyNow(
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId | null
|
||||
): WorktreeGitCopy | null {
|
||||
return resolveWorktreeGitCopy(useAppStore.getState(), worktreeId, hostId)
|
||||
}
|
||||
|
||||
/** The selected copy's host, for a callback that must keep answering for it after a switch. */
|
||||
export function useWorktreeGitCopyHostId(worktreeId: string | null): ExecutionHostId | null {
|
||||
return useAppStore((state) => (worktreeId ? placedHostId(state, worktreeId) : null))
|
||||
}
|
||||
|
||||
/**
|
||||
* The publish target of one copy. Ids repeat across hosts, so an id-keyed lookup could hand a Create
|
||||
* PR or refresh the other copy's remote and branch; with no host, only a lone copy answers.
|
||||
*/
|
||||
export function worktreeCopyPushTarget(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
hostId: ExecutionHostId | null
|
||||
): GitPushTarget | undefined {
|
||||
const placed = hostId
|
||||
? selectWorkspaceByHostTarget(state, hostWorktreeTarget({ id: worktreeId, hostId }))
|
||||
: undefined
|
||||
if (placed) {
|
||||
return placed.pushTarget
|
||||
}
|
||||
// Why the lone-copy fallback: the host index files an unstamped row only under `local`. A row
|
||||
// stamped for another host is never this copy, so a named host accepts only an unstamped one.
|
||||
const rows = (selectWorktreesByRepo(state)[getRepoIdFromWorktreeId(worktreeId)] ?? []).filter(
|
||||
(row) => row.id === worktreeId
|
||||
)
|
||||
const lone = new Set(rows.map(worktreeCopyOwnerKey)).size === 1 ? rows[0] : undefined
|
||||
return lone && (!hostId || lone.hostId === undefined) ? lone.pushTarget : undefined
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
|
||||
export type PullRequestFieldRevisions = Record<PullRequestFieldName, number>
|
||||
@@ -20,6 +21,8 @@ export type PullRequestGenerationContext = {
|
||||
jiraIssueKey?: string | null
|
||||
/** The worktree owner's transport, captured when the request started. */
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
/** The copy `runtimeTarget` answers for, so a same-path twin isn't patched with its result. */
|
||||
ownerHostId?: ExecutionHostId | null
|
||||
}
|
||||
|
||||
export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
|
||||
|
||||
@@ -289,9 +289,11 @@ export type WorktreeSlice = {
|
||||
purgeStaleRuntimeHostState: (removedEnvironmentIds: Iterable<string>) => void
|
||||
/** Move worktree-scoped maps/pointers on path-derived ID rename, preserving live tabs/selection; equal IDs are inert. */
|
||||
migrateWorktreeIdentity: (oldWorktreeId: string, newWorktreeId: string) => void
|
||||
/** `ownerHostId` names the copy git answered for; null patches only an id no twin shares. */
|
||||
updateWorktreeGitIdentity: (
|
||||
worktreeId: string,
|
||||
identity: { head?: string; branch?: string | null }
|
||||
identity: { head?: string; branch?: string | null },
|
||||
ownerHostId: ExecutionHostId | null
|
||||
) => void
|
||||
updateWorktreeBaseStatus: (event: WorktreeBaseStatusEvent) => void
|
||||
updateWorktreeRemoteBranchConflict: (event: WorktreeRemoteBranchConflictEvent) => void
|
||||
|
||||
@@ -50,10 +50,14 @@ describe('routeListingBranchSwitchesThroughGitIdentity', () => {
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo1::/path/wt1', {
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature-two'
|
||||
})
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature-two'
|
||||
},
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when the branch is unchanged', () => {
|
||||
@@ -156,10 +160,14 @@ describe('routeListingBranchSwitchesThroughGitIdentity', () => {
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo1::/path/wt1', {
|
||||
head: 'def456',
|
||||
branch: null
|
||||
})
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'def456',
|
||||
branch: null
|
||||
},
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a listing response when the branch changed again after the request started', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
function indexUnambiguousWorktrees(
|
||||
worktrees: readonly Worktree[],
|
||||
@@ -35,7 +36,8 @@ export function routeListingBranchSwitchesThroughGitIdentity(args: {
|
||||
hasBranchScopedReviewContext: (worktree: Worktree) => boolean
|
||||
updateWorktreeGitIdentity: (
|
||||
worktreeId: string,
|
||||
identity: { head?: string; branch?: string | null }
|
||||
identity: { head?: string; branch?: string | null },
|
||||
ownerHostId: ExecutionHostId | null
|
||||
) => void
|
||||
}): Worktree[] {
|
||||
const {
|
||||
@@ -80,12 +82,17 @@ export function routeListingBranchSwitchesThroughGitIdentity(args: {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
updateWorktreeGitIdentity(worktree.id, {
|
||||
head: worktree.head,
|
||||
// Empty branch means detached HEAD in listing results; null is the
|
||||
// explicit detached signal expected by updateWorktreeGitIdentity.
|
||||
branch: worktree.branch === '' ? null : worktree.branch
|
||||
})
|
||||
updateWorktreeGitIdentity(
|
||||
worktree.id,
|
||||
{
|
||||
head: worktree.head,
|
||||
// Empty branch means detached HEAD in listing results; null is the
|
||||
// explicit detached signal expected by updateWorktreeGitIdentity.
|
||||
branch: worktree.branch === '' ? null : worktree.branch
|
||||
},
|
||||
// Why null: ambiguous ids were skipped above, so this id has one copy.
|
||||
null
|
||||
)
|
||||
}
|
||||
return reconciled ?? incoming
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ describe('fetchWorktrees', () => {
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
store.getState().updateWorktreeGitIdentity(existing.id, { branch: 'refs/heads/next' })
|
||||
store.getState().updateWorktreeGitIdentity(existing.id, { branch: 'refs/heads/next' }, null)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
displayName: 'feature',
|
||||
|
||||
@@ -46,10 +46,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 3 } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
head: 'new-head',
|
||||
@@ -74,14 +78,22 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
notifications += 1
|
||||
})
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'old-head',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/missing', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'old-head',
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/missing',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/feature'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
unsubscribe()
|
||||
expect(notifications).toBe(0)
|
||||
@@ -105,9 +117,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: 'refs/heads/stack/two',
|
||||
@@ -133,9 +149,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/one'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/one'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: 'refs/heads/stack/one',
|
||||
@@ -159,10 +179,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/one'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/one'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
head: 'new-head',
|
||||
@@ -184,9 +208,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('main')
|
||||
})
|
||||
@@ -203,9 +231,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('My Cool Work')
|
||||
})
|
||||
@@ -222,9 +254,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature')
|
||||
})
|
||||
@@ -241,9 +277,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature')
|
||||
})
|
||||
@@ -261,10 +301,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 3 } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: null
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: null
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
head: 'new-head',
|
||||
@@ -287,10 +331,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: null
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: null
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: '',
|
||||
@@ -311,14 +359,22 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'detached-head',
|
||||
branch: null
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'reattached-head',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'detached-head',
|
||||
branch: null
|
||||
},
|
||||
null
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'reattached-head',
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: 'refs/heads/main',
|
||||
@@ -339,10 +395,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'reattached-head',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'reattached-head',
|
||||
branch: 'refs/heads/main'
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: 'refs/heads/main',
|
||||
|
||||
+86
-38
@@ -48,9 +48,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
@@ -100,9 +104,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await Promise.resolve()
|
||||
await store.getState().updateWorktreeMeta('repo1::/path/wt1', { linkedGitLabMR: 202 })
|
||||
resolveClearPersist()
|
||||
@@ -148,9 +156,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await Promise.resolve()
|
||||
await store.getState().updateWorktreeMeta('repo1::/path/wt1', { pushTarget: nextPushTarget })
|
||||
resolveClearPersist()
|
||||
@@ -196,13 +208,21 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await Promise.resolve()
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/three'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/three'
|
||||
},
|
||||
null
|
||||
)
|
||||
releaseFirstClear()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -243,9 +263,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await Promise.resolve()
|
||||
const switched = store.getState().worktreesByRepo.repo1[0]
|
||||
store.setState({
|
||||
@@ -295,9 +319,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
const switched = store.getState().worktreesByRepo.repo1[0]
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
@@ -353,10 +381,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: 'repo1::/path/wt1',
|
||||
@@ -405,9 +437,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: 'repo1::/path/wt1',
|
||||
@@ -415,9 +451,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/one'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/one'
|
||||
},
|
||||
null
|
||||
)
|
||||
worktreeListMock.mockResolvedValue([laterBranch])
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
@@ -455,10 +495,14 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: 'repo1::/path/wt1',
|
||||
@@ -497,9 +541,13 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: 'repo1::/path/wt1',
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { resetHostedReviewLinkMutationGenerationForTests } from './worktrees'
|
||||
import { makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
import {
|
||||
bumpHostedReviewLinkMutationGeneration,
|
||||
migrateHostedReviewLinkMutationGeneration,
|
||||
sanitizeHostedReviewLinksForBranchClears
|
||||
} from './worktrees/metadata/hosted-review-link-mutation'
|
||||
import {
|
||||
createTestStore,
|
||||
mockApi,
|
||||
resetRemoteRuntimeMocks,
|
||||
resetWorktreeSliceModuleMemory
|
||||
} from './worktrees-slice-test-harness'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() }
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetWorktreeSliceModuleMemory()
|
||||
resetRemoteRuntimeMocks()
|
||||
resetHostedReviewLinkMutationGenerationForTests()
|
||||
})
|
||||
|
||||
// #32918: one project at the same path on two hosts shares the id `repoId::path`.
|
||||
const id = 'repo1::/srv/ai-tools'
|
||||
|
||||
function storeWith(...rows: Partial<Worktree>[]) {
|
||||
const store = createTestStore()
|
||||
const worktrees = rows.map((row) =>
|
||||
makeWorktree({ id, repoId: 'repo1', path: '/srv/ai-tools', ...row })
|
||||
)
|
||||
store.setState({ worktreesByRepo: { repo1: worktrees } })
|
||||
return store
|
||||
}
|
||||
|
||||
function twinStore() {
|
||||
return storeWith(
|
||||
{ hostId: 'runtime:env-a', head: 'aaa', branch: 'refs/heads/master', displayName: 'master' },
|
||||
{ hostId: 'runtime:env-b', head: 'bbb', branch: 'refs/heads/feature', displayName: 'feature' }
|
||||
)
|
||||
}
|
||||
|
||||
describe('updateWorktreeGitIdentity with a same-path twin on another host', () => {
|
||||
it("writes one host's git status only to that host's copy", () => {
|
||||
const store = twinStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.updateWorktreeGitIdentity(id, { head: 'aaa2', branch: 'refs/heads/master' }, 'runtime:env-a')
|
||||
|
||||
const [onA, onB] = store.getState().worktreesByRepo.repo1
|
||||
expect(onA).toMatchObject({ head: 'aaa2', branch: 'refs/heads/master' })
|
||||
expect(onB).toMatchObject({ head: 'bbb', branch: 'refs/heads/feature', displayName: 'feature' })
|
||||
})
|
||||
|
||||
it('leaves both copies alone when the answering host is unknown', () => {
|
||||
const store = twinStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.updateWorktreeGitIdentity(id, { head: 'ccc', branch: 'refs/heads/other' }, null)
|
||||
|
||||
const [onA, onB] = store.getState().worktreesByRepo.repo1
|
||||
expect(onA.branch).toBe('refs/heads/master')
|
||||
expect(onB.branch).toBe('refs/heads/feature')
|
||||
})
|
||||
|
||||
it("clears only the switching twin's push target and review link, on its own host", async () => {
|
||||
const pushTarget = { remoteName: 'fork', branchName: 'old/review-head' }
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/stack/two', linkedPR: 7, pushTarget },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/stack/one', linkedPR: 101, pushTarget }
|
||||
)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/stack/two' }, 'ssh:box-b')
|
||||
|
||||
await vi.waitFor(() => expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(1))
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: id,
|
||||
executionHostId: 'ssh:box-b',
|
||||
updates: expect.objectContaining({ linkedPR: null, pushTarget: undefined })
|
||||
})
|
||||
)
|
||||
const [localAfter, remoteAfter] = store.getState().worktreesByRepo.repo1
|
||||
expect(localAfter).toMatchObject({ linkedPR: 7, pushTarget })
|
||||
expect(remoteAfter).toMatchObject({ branch: 'refs/heads/stack/two', pushTarget: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a twin review-clear tombstone', () => {
|
||||
it("guards only the switching copy's refetched rows, never its sibling's", () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/stack/two', linkedPR: 7 },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/stack/one', linkedPR: 101 }
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/stack/two' }, 'ssh:box-b')
|
||||
|
||||
const [sibling, staleRefetch] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'local',
|
||||
branch: 'refs/heads/stack/two',
|
||||
linkedPR: 7
|
||||
}),
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'ssh:box-b',
|
||||
branch: 'refs/heads/stack/one',
|
||||
linkedPR: 101
|
||||
})
|
||||
])
|
||||
|
||||
expect(sibling.linkedPR).toBe(7)
|
||||
expect(staleRefetch).toMatchObject({ branch: 'refs/heads/stack/two', linkedPR: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('twin review-clear tombstones when both copies switch', () => {
|
||||
it('keep guarding the first copy after the second clears', () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/b1', linkedPR: 2 }
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'local')
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/b2' }, 'ssh:box-b')
|
||||
|
||||
const [first] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
])
|
||||
|
||||
expect(first).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a twin review-clear tombstone across a rename', () => {
|
||||
it('still guards the renamed copy', () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/a1' },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/b1', linkedPR: 2 }
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/b2' }, 'ssh:box-b')
|
||||
const renamed = 'repo1::/srv/ai-tools-renamed'
|
||||
migrateHostedReviewLinkMutationGeneration(id, renamed)
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id: renamed,
|
||||
repoId: 'repo1',
|
||||
hostId: 'ssh:box-b',
|
||||
branch: 'refs/heads/b1',
|
||||
linkedPR: 2
|
||||
})
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/b2', linkedPR: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a twin review-clear tombstone after the sibling goes away', () => {
|
||||
it('still drives a retry of the clear on the next branch switch', async () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/a1' },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/b1', linkedPR: 2 }
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/b2' }, 'ssh:box-b')
|
||||
await vi.waitFor(() => expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(1))
|
||||
const [, remote] = store.getState().worktreesByRepo.repo1
|
||||
store.setState({ worktreesByRepo: { repo1: [remote] } })
|
||||
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/b3' }, 'ssh:box-b')
|
||||
|
||||
await vi.waitFor(() => expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('a provenance twin review-clear tombstone', () => {
|
||||
it('spares the sibling another paired server publishes under the same SSH host id', () => {
|
||||
const shared = { hostId: 'ssh:shared' as const, branch: 'refs/heads/stack/one' }
|
||||
const store = storeWith(
|
||||
{ ...shared, runtimeOwnerEnvironmentId: 'hub-a', linkedPR: 1 },
|
||||
{ ...shared, runtimeOwnerEnvironmentId: 'hub-b', linkedPR: 2 }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.updateWorktreeGitIdentity(id, { branch: 'refs/heads/stack/two' }, 'runtime:hub-a')
|
||||
|
||||
const [sibling] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
...shared,
|
||||
runtimeOwnerEnvironmentId: 'hub-b',
|
||||
linkedPR: 2
|
||||
})
|
||||
])
|
||||
|
||||
expect(sibling.linkedPR).toBe(2)
|
||||
expect(store.getState().worktreesByRepo.repo1[1].linkedPR).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateWorktreeGitIdentity host matching', () => {
|
||||
it("reaches a paired server's SSH checkout through its runtime alias, not its twin", () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/master' },
|
||||
{
|
||||
hostId: 'ssh:private-target',
|
||||
runtimeOwnerEnvironmentId: 'hub-a',
|
||||
branch: 'refs/heads/master'
|
||||
}
|
||||
)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.updateWorktreeGitIdentity(id, { branch: 'refs/heads/feature' }, 'runtime:hub-a')
|
||||
|
||||
const [local, paired] = store.getState().worktreesByRepo.repo1
|
||||
expect(local.branch).toBe('refs/heads/master')
|
||||
expect(paired.branch).toBe('refs/heads/feature')
|
||||
})
|
||||
|
||||
it('keeps two paired servers apart when both publish the same SSH host id', () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'ssh:shared', runtimeOwnerEnvironmentId: 'hub-a', branch: 'refs/heads/main' },
|
||||
{ hostId: 'ssh:shared', runtimeOwnerEnvironmentId: 'hub-b', branch: 'refs/heads/feature' }
|
||||
)
|
||||
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/main' }, 'runtime:hub-a')
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[1].branch).toBe('refs/heads/feature')
|
||||
})
|
||||
})
|
||||
|
||||
describe('review-clear tombstones are always keyed by the copy', () => {
|
||||
it('still guard a legacy unstamped row after its listing stamps it', () => {
|
||||
const store = storeWith({ branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'ssh:box')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', hostId: 'ssh:box', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it('never rewrite a same-path twin that arrives after a lone copy cleared', () => {
|
||||
const store = storeWith({ hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'local')
|
||||
|
||||
const [newcomer] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'ssh:box-b',
|
||||
branch: 'refs/heads/b1',
|
||||
linkedPR: 2
|
||||
})
|
||||
])
|
||||
|
||||
expect(newcomer).toMatchObject({ branch: 'refs/heads/b1', linkedPR: 2 })
|
||||
})
|
||||
|
||||
it('still guard a runtime row once its listing adds the server provenance', () => {
|
||||
const store = storeWith({ hostId: 'runtime:env-a', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'runtime:env-a')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'runtime:env-a',
|
||||
runtimeOwnerEnvironmentId: 'env-a',
|
||||
branch: 'refs/heads/a1',
|
||||
linkedPR: 1
|
||||
})
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it('still guard a legacy local row after discovery stamps it local', () => {
|
||||
const store = storeWith({ branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'local')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it("add the publishing server's provenance to a partially stamped SSH row", () => {
|
||||
const store = storeWith({ hostId: 'ssh:x', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/srv/ai-tools',
|
||||
displayName: 'ai-tools',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:hub'
|
||||
}
|
||||
]
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'ssh:x')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'ssh:x',
|
||||
runtimeOwnerEnvironmentId: 'hub',
|
||||
branch: 'refs/heads/a1',
|
||||
linkedPR: 1
|
||||
})
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it("follow a runtime row's own host, not its repo's, when provenance is backfilled", () => {
|
||||
const store = storeWith({ hostId: 'runtime:env-b', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/srv/ai-tools',
|
||||
displayName: 'ai-tools',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-a'
|
||||
}
|
||||
]
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'runtime:env-b')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'runtime:env-b',
|
||||
runtimeOwnerEnvironmentId: 'env-b',
|
||||
branch: 'refs/heads/a1',
|
||||
linkedPR: 1
|
||||
})
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it("never give a local copy a paired server's provenance", () => {
|
||||
const store = storeWith({ hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/srv/ai-tools',
|
||||
displayName: 'ai-tools',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:hub'
|
||||
}
|
||||
]
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'local')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it("keep a paired server's provenance when its SSH host answers", () => {
|
||||
const paired = { hostId: 'ssh:x' as const, runtimeOwnerEnvironmentId: 'hub' }
|
||||
const store = storeWith({ ...paired, branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'ssh:x')
|
||||
|
||||
const [stale] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', ...paired, branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
])
|
||||
|
||||
expect(stale).toMatchObject({ branch: 'refs/heads/a2', linkedPR: null })
|
||||
})
|
||||
|
||||
it('let a relink of the still-unstamped legacy row drop its stamped guard', () => {
|
||||
const store = storeWith({ branch: 'refs/heads/a1', linkedPR: 1 })
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'ssh:box')
|
||||
bumpHostedReviewLinkMutationGeneration(id, {})
|
||||
|
||||
const [relinked] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({ id, repoId: 'repo1', hostId: 'ssh:box', branch: 'refs/heads/a2', linkedPR: 9 })
|
||||
])
|
||||
|
||||
expect(relinked.linkedPR).toBe(9)
|
||||
})
|
||||
|
||||
it("keep the sibling's guard when one copy is relinked", () => {
|
||||
const store = storeWith(
|
||||
{ hostId: 'local', branch: 'refs/heads/a1', linkedPR: 1 },
|
||||
{ hostId: 'ssh:box-b', branch: 'refs/heads/b1', linkedPR: 2 }
|
||||
)
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/a2' }, 'local')
|
||||
store.getState().updateWorktreeGitIdentity(id, { branch: 'refs/heads/b2' }, 'ssh:box-b')
|
||||
bumpHostedReviewLinkMutationGeneration(id, { hostId: 'local' })
|
||||
|
||||
const [staleRemote] = sanitizeHostedReviewLinksForBranchClears([
|
||||
makeWorktree({
|
||||
id,
|
||||
repoId: 'repo1',
|
||||
hostId: 'ssh:box-b',
|
||||
branch: 'refs/heads/b1',
|
||||
linkedPR: 2
|
||||
})
|
||||
])
|
||||
|
||||
expect(staleRemote).toMatchObject({ branch: 'refs/heads/b2', linkedPR: null })
|
||||
})
|
||||
})
|
||||
@@ -251,7 +251,7 @@ describe('migrateWorktreeIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [oldWorktree] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' })
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' }, null)
|
||||
await vi.waitFor(() => {
|
||||
expect(getHostedReviewLinkMutationGenerationForTests(OLD)).toBe(0)
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
@@ -303,10 +303,14 @@ describe('migrateWorktreeIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [oldWorktree] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity(OLD, {
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
})
|
||||
store.getState().updateWorktreeGitIdentity(
|
||||
OLD,
|
||||
{
|
||||
head: 'new-head',
|
||||
branch: 'refs/heads/stack/two'
|
||||
},
|
||||
null
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: OLD,
|
||||
@@ -348,7 +352,7 @@ describe('migrateWorktreeIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [oldWorktree] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' })
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' }, null)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
|
||||
worktreeId: OLD,
|
||||
@@ -377,7 +381,7 @@ describe('migrateWorktreeIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [oldWorktree] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' })
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' }, null)
|
||||
const switched = store.getState().worktreesByRepo.repo1[0]
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
@@ -433,7 +437,7 @@ describe('migrateWorktreeIdentity', () => {
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [oldWorktree] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' })
|
||||
store.getState().updateWorktreeGitIdentity(OLD, { branch: 'refs/heads/stack/two' }, null)
|
||||
await oldPersistStartedPromise
|
||||
const switched = store.getState().worktreesByRepo.repo1[0]
|
||||
store.setState({
|
||||
|
||||
@@ -12,10 +12,11 @@ export function mergeDetectedWorktreesForHost(
|
||||
setup?: ProjectHostSetup,
|
||||
options?: WorktreeHostMatchOptions
|
||||
): DetectedWorktreeListResult {
|
||||
// Why stamp first: a twin's review-clear tombstone matches rows by their owner stamp.
|
||||
const refreshedForHost = sanitizeHostedReviewLinksForBranchClears(
|
||||
refreshed.worktrees,
|
||||
refreshed.worktrees.map((worktree) => withRepoHostOwnership(worktree, hostId, setup)),
|
||||
current?.worktrees
|
||||
).map((worktree) => withRepoHostOwnership(worktree, hostId, setup))
|
||||
)
|
||||
const worktrees = mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options)
|
||||
if (
|
||||
current &&
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../../../types'
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import { applyWorktreeUpdates } from '../../worktree-helpers'
|
||||
import {
|
||||
clearCopyReviewLinks,
|
||||
deleteCopyTombstones,
|
||||
dropCopyTombstone,
|
||||
moveCopyTombstones,
|
||||
reviewClearTombstoneKey,
|
||||
type WorktreeCopyClear,
|
||||
worktreeCopyOwnerKey
|
||||
} from './worktree-copy-review-clear'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
|
||||
import { normalizeWorkspaceAttachmentUpdate } from '../../../../../../shared/workspace-attachments'
|
||||
@@ -23,9 +31,18 @@ export {
|
||||
}
|
||||
|
||||
export const hostedReviewLinkMutationGenerationByWorktreeId = new Map<string, number>()
|
||||
export const hostedReviewLinkClearTombstonesByWorktreeId = new Map<
|
||||
export const hostedReviewLinkClearTombstonesByCopy = new Map<
|
||||
string,
|
||||
{ branch: string; branchIdentity: string; generation: number; head?: string }
|
||||
{
|
||||
branch: string
|
||||
branchIdentity: string
|
||||
generation: number
|
||||
head?: string
|
||||
/** The guarded copy's stamped {@link worktreeCopyOwnerKey}; part of the map key too. */
|
||||
owner: string
|
||||
/** Its key before a listing stamped it; a relink of the unstamped row still matches. */
|
||||
rowOwner: string
|
||||
}
|
||||
>()
|
||||
export const hostedReviewLinkWorktreeIdAliases = new Map<string, string>()
|
||||
|
||||
@@ -52,19 +69,34 @@ export function getHostedReviewLinkMutationGeneration(worktreeId: string): numbe
|
||||
return hostedReviewLinkMutationGenerationByWorktreeId.get(worktreeId) ?? 0
|
||||
}
|
||||
|
||||
export function bumpHostedReviewLinkMutationGeneration(worktreeId: string): void {
|
||||
hostedReviewLinkMutationGenerationByWorktreeId.set(
|
||||
/** A manual relink; naming the relinked copy keeps a same-path twin's own clear guard. */
|
||||
export function bumpHostedReviewLinkMutationGeneration(
|
||||
worktreeId: string,
|
||||
relinkedCopy?: Partial<Pick<Worktree, 'hostId' | 'runtimeOwnerEnvironmentId'>>
|
||||
): void {
|
||||
const generation = getHostedReviewLinkMutationGeneration(worktreeId) + 1
|
||||
hostedReviewLinkMutationGenerationByWorktreeId.set(worktreeId, generation)
|
||||
const owner = relinkedCopy ? worktreeCopyOwnerKey(relinkedCopy) : undefined
|
||||
if (owner === undefined) {
|
||||
deleteCopyTombstones(hostedReviewLinkClearTombstonesByCopy, worktreeId)
|
||||
pruneHostedReviewLinkWorktreeAliasesForId(worktreeId)
|
||||
return
|
||||
}
|
||||
const siblingGuarded = dropCopyTombstone(
|
||||
hostedReviewLinkClearTombstonesByCopy,
|
||||
worktreeId,
|
||||
getHostedReviewLinkMutationGeneration(worktreeId) + 1
|
||||
owner,
|
||||
generation
|
||||
)
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId)
|
||||
pruneHostedReviewLinkWorktreeAliasesForId(worktreeId)
|
||||
if (!siblingGuarded) {
|
||||
pruneHostedReviewLinkWorktreeAliasesForId(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneHostedReviewLinkMutationGenerations(worktreeIds: Iterable<string>): void {
|
||||
for (const worktreeId of worktreeIds) {
|
||||
hostedReviewLinkMutationGenerationByWorktreeId.delete(worktreeId)
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId)
|
||||
deleteCopyTombstones(hostedReviewLinkClearTombstonesByCopy, worktreeId)
|
||||
hostedReviewLinkWorktreeIdAliases.delete(worktreeId)
|
||||
for (const [oldWorktreeId, newWorktreeId] of hostedReviewLinkWorktreeIdAliases) {
|
||||
if (newWorktreeId === worktreeId) {
|
||||
@@ -104,10 +136,14 @@ export function migrateHostedReviewLinkMutationGeneration(
|
||||
oldWorktreeId: string,
|
||||
newWorktreeId: string
|
||||
): void {
|
||||
const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(oldWorktreeId)
|
||||
const hasTombstone = moveCopyTombstones(
|
||||
hostedReviewLinkClearTombstonesByCopy,
|
||||
oldWorktreeId,
|
||||
newWorktreeId
|
||||
)
|
||||
for (const [alias, target] of hostedReviewLinkWorktreeIdAliases) {
|
||||
if (target === oldWorktreeId) {
|
||||
if (tombstone) {
|
||||
if (hasTombstone) {
|
||||
hostedReviewLinkWorktreeIdAliases.set(alias, newWorktreeId)
|
||||
} else {
|
||||
hostedReviewLinkWorktreeIdAliases.delete(alias)
|
||||
@@ -115,7 +151,7 @@ export function migrateHostedReviewLinkMutationGeneration(
|
||||
}
|
||||
}
|
||||
const hasGeneration = hostedReviewLinkMutationGenerationByWorktreeId.has(oldWorktreeId)
|
||||
if (tombstone) {
|
||||
if (hasTombstone) {
|
||||
hostedReviewLinkWorktreeIdAliases.set(oldWorktreeId, newWorktreeId)
|
||||
}
|
||||
if (hasGeneration) {
|
||||
@@ -125,10 +161,6 @@ export function migrateHostedReviewLinkMutationGeneration(
|
||||
)
|
||||
hostedReviewLinkMutationGenerationByWorktreeId.delete(oldWorktreeId)
|
||||
}
|
||||
if (tombstone) {
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.set(newWorktreeId, tombstone)
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.delete(oldWorktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getHostedReviewLinkMutationGenerationForTests(worktreeId: string): number {
|
||||
@@ -141,7 +173,7 @@ export function getHostedReviewLinkWorktreeAliasCountForTests(): number {
|
||||
|
||||
export function resetHostedReviewLinkMutationGenerationForTests(): void {
|
||||
hostedReviewLinkMutationGenerationByWorktreeId.clear()
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.clear()
|
||||
hostedReviewLinkClearTombstonesByCopy.clear()
|
||||
hostedReviewLinkWorktreeIdAliases.clear()
|
||||
}
|
||||
|
||||
@@ -166,22 +198,38 @@ export function rememberHostedReviewLinkClear(
|
||||
worktreeId: string,
|
||||
branch: string,
|
||||
generation: number,
|
||||
head?: string
|
||||
head: string | undefined,
|
||||
copy: Pick<WorktreeCopyClear, 'owner' | 'rowOwner'>
|
||||
): void {
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, {
|
||||
hostedReviewLinkClearTombstonesByCopy.set(reviewClearTombstoneKey(worktreeId, copy.owner), {
|
||||
branch,
|
||||
branchIdentity: canonicalHostedReviewBranchIdentity(branch),
|
||||
generation,
|
||||
head
|
||||
head,
|
||||
owner: copy.owner,
|
||||
rowOwner: copy.rowOwner
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeHostedReviewLinksForBranchClear<
|
||||
T extends Pick<Worktree, 'id' | 'branch'> &
|
||||
Partial<Pick<Worktree, HostedReviewLinkKey | 'pushTarget' | 'head' | 'linkedItems'>>
|
||||
Partial<
|
||||
Pick<
|
||||
Worktree,
|
||||
| HostedReviewLinkKey
|
||||
| 'pushTarget'
|
||||
| 'head'
|
||||
| 'linkedItems'
|
||||
| 'hostId'
|
||||
| 'runtimeOwnerEnvironmentId'
|
||||
>
|
||||
>
|
||||
>(worktree: T, currentWorktrees?: readonly T[]): T {
|
||||
const hostedReviewWorktreeId = resolveHostedReviewLinkWorktreeId(worktree.id)
|
||||
const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(hostedReviewWorktreeId)
|
||||
// Why the copy key only: a bare-id guard would also rewrite a same-path twin that arrives later.
|
||||
const tombstone = hostedReviewLinkClearTombstonesByCopy.get(
|
||||
reviewClearTombstoneKey(hostedReviewWorktreeId, worktreeCopyOwnerKey(worktree))
|
||||
)
|
||||
const hasBranchScopedContext =
|
||||
HOSTED_REVIEW_LINK_KEYS.some((key) => worktree[key] != null) ||
|
||||
worktree.pushTarget !== undefined
|
||||
@@ -194,8 +242,9 @@ export function sanitizeHostedReviewLinksForBranchClear<
|
||||
}
|
||||
const current = currentWorktrees?.find(
|
||||
(entry) =>
|
||||
entry.id === worktree.id ||
|
||||
resolveHostedReviewLinkWorktreeId(entry.id) === hostedReviewWorktreeId
|
||||
worktreeCopyOwnerKey(entry) === tombstone.owner &&
|
||||
(entry.id === worktree.id ||
|
||||
resolveHostedReviewLinkWorktreeId(entry.id) === hostedReviewWorktreeId)
|
||||
)
|
||||
const currentClean =
|
||||
current &&
|
||||
@@ -230,27 +279,23 @@ export function sanitizeHostedReviewLinksForBranchClears<
|
||||
|
||||
export function applyHostedReviewLinkClear(
|
||||
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
|
||||
worktreeId: string
|
||||
worktreeId: string,
|
||||
copy: WorktreeCopyClear
|
||||
): void {
|
||||
set((s) => {
|
||||
const nextWorktrees = applyWorktreeUpdates(
|
||||
s.worktreesByRepo,
|
||||
worktreeId,
|
||||
CLEARED_HOSTED_REVIEW_LINK_UPDATES
|
||||
)
|
||||
const byRepo = s.worktreesByRepo
|
||||
const nextWorktrees = clearCopyReviewLinks(byRepo, worktreeId, copy)
|
||||
const nextDetectedWorktrees = applyDetectedWorktreeUpdates(
|
||||
s.detectedWorktreesByRepo,
|
||||
worktreeId,
|
||||
CLEARED_HOSTED_REVIEW_LINK_UPDATES
|
||||
CLEARED_HOSTED_REVIEW_LINK_UPDATES,
|
||||
copy.hostId
|
||||
)
|
||||
if (
|
||||
nextWorktrees === s.worktreesByRepo &&
|
||||
nextDetectedWorktrees === s.detectedWorktreesByRepo
|
||||
) {
|
||||
if (nextWorktrees === byRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
...(nextWorktrees !== s.worktreesByRepo
|
||||
...(nextWorktrees !== byRepo
|
||||
? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 }
|
||||
: {}),
|
||||
...(nextDetectedWorktrees !== s.detectedWorktreesByRepo
|
||||
|
||||
@@ -254,7 +254,7 @@ export function createUpdateWorktreeMeta(
|
||||
return { ok: true }
|
||||
}
|
||||
if (hasHostedReviewLinkUpdates(enriched)) {
|
||||
bumpHostedReviewLinkMutationGeneration(worktreeId)
|
||||
bumpHostedReviewLinkMutationGeneration(worktreeId, worktreeForUpdate)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import { normalizeWorkspaceAttachmentUpdate } from '../../../../../../shared/workspace-attachments'
|
||||
import { CLEARED_HOSTED_REVIEW_LINK_UPDATES } from '../../../../../../shared/worktree/hosted-review-links'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id'
|
||||
|
||||
// Why this module: worktree ids repeat across hosts (`repoId::path`), so branch-switch review
|
||||
// clears are keyed by id plus the copy's owner and never by the bare id.
|
||||
|
||||
/**
|
||||
* One copy's owner: its host plus the paired server that published it (two can share an `ssh:` id).
|
||||
* Listing paths must stamp rows (`withRepoHostOwnership`) before comparing this key.
|
||||
*/
|
||||
export function worktreeCopyOwnerKey(
|
||||
row: Partial<Pick<Worktree, 'hostId' | 'runtimeOwnerEnvironmentId'>>
|
||||
): string {
|
||||
// Why local folds into unstamped: discovery backfills `hostId: 'local'` on the same checkout.
|
||||
const hostId = row.hostId === LOCAL_EXECUTION_HOST_ID ? undefined : row.hostId
|
||||
// A `runtime:` host is its own server, so backfilled provenance must not change the key.
|
||||
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
|
||||
return `${hostId}\0`
|
||||
}
|
||||
return `${hostId ?? ''}\0${row.runtimeOwnerEnvironmentId ?? ''}`
|
||||
}
|
||||
|
||||
/** The copy a review clear is for. */
|
||||
export type WorktreeCopyClear = {
|
||||
/** The owner key of the row once stamped; tombstones use it. */
|
||||
owner: string
|
||||
/** The row's own key at clear time, which differs only while a legacy row is unstamped. */
|
||||
rowOwner: string
|
||||
hostId: ExecutionHostId | undefined
|
||||
}
|
||||
|
||||
/** Clears branch-scoped review context on the one copy `copy` names, stamped or not. */
|
||||
export function clearCopyReviewLinks(
|
||||
byRepo: Record<string, Worktree[]>,
|
||||
worktreeId: string,
|
||||
copy: Pick<WorktreeCopyClear, 'owner' | 'rowOwner'>
|
||||
): Record<string, Worktree[]> {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
let changed = false
|
||||
const next = byRepo[repoId]?.map((row) => {
|
||||
const rowOwner = worktreeCopyOwnerKey(row)
|
||||
if (row.id !== worktreeId || (rowOwner !== copy.owner && rowOwner !== copy.rowOwner)) {
|
||||
return row
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...row,
|
||||
...normalizeWorkspaceAttachmentUpdate(row, CLEARED_HOSTED_REVIEW_LINK_UPDATES)
|
||||
}
|
||||
})
|
||||
return changed && next ? { ...byRepo, [repoId]: next } : byRepo
|
||||
}
|
||||
|
||||
export function reviewClearTombstoneKey(worktreeId: string, owner: string): string {
|
||||
return `${worktreeId}\0${owner}`
|
||||
}
|
||||
|
||||
/** Drops every copy's tombstone for `worktreeId`. */
|
||||
export function deleteCopyTombstones(tombstones: Map<string, unknown>, worktreeId: string): void {
|
||||
for (const key of Array.from(tombstones.keys())) {
|
||||
if (key === worktreeId || key.startsWith(`${worktreeId}\0`)) {
|
||||
tombstones.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-keys every copy's tombstone after a rename; true when any moved. */
|
||||
export function moveCopyTombstones<T>(
|
||||
tombstones: Map<string, T>,
|
||||
oldWorktreeId: string,
|
||||
newWorktreeId: string
|
||||
): boolean {
|
||||
const prefix = `${oldWorktreeId}\0`
|
||||
let moved = false
|
||||
for (const [key, tombstone] of Array.from(tombstones)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
tombstones.set(`${newWorktreeId}\0${key.slice(prefix.length)}`, tombstone)
|
||||
tombstones.delete(key)
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
/**
|
||||
* A relink of one copy: drops that copy's tombstone (matched stamped or not) and moves the
|
||||
* siblings' guards to `generation` so they stay live. True when a sibling is still guarded.
|
||||
*/
|
||||
export function dropCopyTombstone<
|
||||
T extends { owner: string; rowOwner: string; generation: number }
|
||||
>(tombstones: Map<string, T>, worktreeId: string, owner: string, generation: number): boolean {
|
||||
let siblingGuarded = false
|
||||
for (const [key, tombstone] of Array.from(tombstones)) {
|
||||
if (!key.startsWith(`${worktreeId}\0`)) {
|
||||
continue
|
||||
}
|
||||
if (tombstone.owner === owner || tombstone.rowOwner === owner) {
|
||||
tombstones.delete(key)
|
||||
} else {
|
||||
tombstones.set(key, { ...tombstone, generation })
|
||||
siblingGuarded = true
|
||||
}
|
||||
}
|
||||
return siblingGuarded
|
||||
}
|
||||
@@ -13,30 +13,92 @@ import {
|
||||
getHostedReviewLinkMutationGeneration,
|
||||
getHostedReviewLinkUpdates,
|
||||
hasBranchScopedHostedReviewContext,
|
||||
hostedReviewLinkClearTombstonesByWorktreeId,
|
||||
hostedReviewLinkClearTombstonesByCopy,
|
||||
hostedReviewLinksAreCleared,
|
||||
rememberHostedReviewLinkClear,
|
||||
resolveHostedReviewLinkWorktreeId
|
||||
} from './hosted-review-link-mutation'
|
||||
import {
|
||||
reviewClearTombstoneKey,
|
||||
worktreeCopyOwnerKey,
|
||||
type WorktreeCopyClear
|
||||
} from './worktree-copy-review-clear'
|
||||
import { persistWorktreeMeta } from './worktree-meta-persist'
|
||||
import { withRepoHostOwnership } from '../listing/worktree-host-ownership'
|
||||
import { getRepoRegistrationHostIds } from '../../../../../../shared/repo-host-identity'
|
||||
import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
selectWorkspaceByHostTarget,
|
||||
selectWorktreesByRepo,
|
||||
type WorkspaceRecordState
|
||||
} from '../../../workspace-record-selectors'
|
||||
|
||||
type GitIdentityTargets = {
|
||||
rows: Set<Worktree>
|
||||
/** Rows with this id exist on more than one host. */
|
||||
isTwin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows one host's git answer for `worktreeId` describes. Ids are `repoId::path`, so a same-path
|
||||
* twin on another host shares it. An id on one host keeps the id-only patch; a twin needs the
|
||||
* answering host, and without one gets nothing until its listing places each copy.
|
||||
*/
|
||||
function gitIdentityTargets(
|
||||
state: WorkspaceRecordState,
|
||||
worktreeId: string,
|
||||
ownerHostId: ExecutionHostId | null
|
||||
): GitIdentityTargets {
|
||||
const rows = (selectWorktreesByRepo(state)[getRepoIdFromWorktreeId(worktreeId)] ?? []).filter(
|
||||
(worktree) => worktree.id === worktreeId
|
||||
)
|
||||
// Why provenance too: two paired servers can each publish the same `ssh:` host id.
|
||||
const owners = new Set(rows.map(worktreeCopyOwnerKey))
|
||||
if (owners.size <= 1) {
|
||||
return { rows: new Set(rows), isTwin: false }
|
||||
}
|
||||
if (!ownerHostId) {
|
||||
return { rows: new Set(), isTwin: true }
|
||||
}
|
||||
// Why the host target: it also places a paired server's SSH checkout under its `runtime:` alias.
|
||||
const row = selectWorkspaceByHostTarget(
|
||||
state,
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: ownerHostId })
|
||||
)
|
||||
return { rows: new Set(row ? [row] : []), isTwin: true }
|
||||
}
|
||||
|
||||
/** The one host registering `repoId`, which its listings stamp rows with; else undefined. */
|
||||
function soleRegistrationHostId(
|
||||
repos: Parameters<typeof getRepoRegistrationHostIds>[0],
|
||||
repoId: string
|
||||
): ExecutionHostId | undefined {
|
||||
const hosts = getRepoRegistrationHostIds(repos, repoId)
|
||||
return hosts.size === 1 ? [...hosts][0] : undefined
|
||||
}
|
||||
|
||||
export function createUpdateWorktreeGitIdentity(
|
||||
set: WorktreeSliceSet,
|
||||
get: WorktreeSliceGet
|
||||
): WorktreeSlice['updateWorktreeGitIdentity'] {
|
||||
return (worktreeId, identity) => {
|
||||
return (worktreeId, identity, ownerHostId) => {
|
||||
let shouldPersistHostedReviewClear = false
|
||||
let clearedBranch: string | null = null
|
||||
let clearedCopy: WorktreeCopyClear | undefined
|
||||
let clearedOnTwin = false
|
||||
let clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId)
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const existing = get().worktreesByRepo[repoId]?.find((worktree) => worktree.id === worktreeId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const expectedHead = identity.head ?? existing.head
|
||||
const expectedBranch = identity.branch === null ? '' : (identity.branch ?? existing.branch)
|
||||
if (expectedHead === existing.head && expectedBranch === existing.branch) {
|
||||
const nextHeadFor = (worktree: Worktree): string => identity.head ?? worktree.head
|
||||
const nextBranchFor = (worktree: Worktree): string =>
|
||||
identity.branch === null ? '' : (identity.branch ?? worktree.branch)
|
||||
const initialTargets = gitIdentityTargets(get(), worktreeId, ownerHostId)
|
||||
const anyTargetChanges = [...initialTargets.rows].some(
|
||||
(worktree) =>
|
||||
nextHeadFor(worktree) !== worktree.head || nextBranchFor(worktree) !== worktree.branch
|
||||
)
|
||||
if (!anyTargetChanges) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,12 +109,13 @@ export function createUpdateWorktreeGitIdentity(
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const targets = gitIdentityTargets(s, worktreeId, ownerHostId)
|
||||
const next = current.map((worktree) => {
|
||||
if (worktree.id !== worktreeId) {
|
||||
if (!targets.rows.has(worktree)) {
|
||||
return worktree
|
||||
}
|
||||
const nextHead = identity.head ?? worktree.head
|
||||
const nextBranch = identity.branch === null ? '' : (identity.branch ?? worktree.branch)
|
||||
const nextHead = nextHeadFor(worktree)
|
||||
const nextBranch = nextBranchFor(worktree)
|
||||
if (nextHead === worktree.head && nextBranch === worktree.branch) {
|
||||
return worktree
|
||||
}
|
||||
@@ -62,16 +125,41 @@ export function createUpdateWorktreeGitIdentity(
|
||||
canonicalHostedReviewBranchIdentity(worktree.branch)
|
||||
const shouldClearHostedReviewContext =
|
||||
hostedReviewBranchChanged && hasBranchScopedHostedReviewContext(worktree)
|
||||
// Why the copy: a same-path twin shares the id, so every guard and clear names this row.
|
||||
// Keyed by the stamp the next listing gives the row, so legacy or partial stamps still match.
|
||||
// Only missing parts are filled in; a row's own host or publishing server is never replaced.
|
||||
const stampHostId = soleRegistrationHostId(s.repos, worktree.repoId) ?? ownerHostId
|
||||
const listingStamp = stampHostId ? withRepoHostOwnership(worktree, stampHostId) : worktree
|
||||
const stampedHostId = worktree.hostId ?? listingStamp.hostId
|
||||
// Provenance only distinguishes SSH copies; local listings never carry it.
|
||||
const stamped = {
|
||||
hostId: stampedHostId,
|
||||
runtimeOwnerEnvironmentId:
|
||||
worktree.runtimeOwnerEnvironmentId ??
|
||||
(parseExecutionHostId(stampedHostId)?.kind === 'ssh'
|
||||
? listingStamp.runtimeOwnerEnvironmentId
|
||||
: undefined)
|
||||
}
|
||||
const copy = {
|
||||
owner: worktreeCopyOwnerKey(stamped),
|
||||
rowOwner: worktreeCopyOwnerKey(worktree),
|
||||
hostId: worktree.hostId
|
||||
}
|
||||
if (shouldClearHostedReviewContext) {
|
||||
shouldPersistHostedReviewClear = true
|
||||
clearedBranch = nextBranch
|
||||
clearedCopy = copy
|
||||
clearedOnTwin = targets.isTwin
|
||||
clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId)
|
||||
rememberHostedReviewLinkClear(worktreeId, nextBranch, clearGeneration, nextHead)
|
||||
rememberHostedReviewLinkClear(worktreeId, nextBranch, clearGeneration, nextHead, copy)
|
||||
} else {
|
||||
const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(worktreeId)
|
||||
const tombstoneKey = reviewClearTombstoneKey(worktreeId, copy.owner)
|
||||
const tombstone = hostedReviewLinkClearTombstonesByCopy.get(tombstoneKey)
|
||||
if (tombstone) {
|
||||
clearedCopy = copy
|
||||
clearedOnTwin = targets.isTwin
|
||||
const nextBranchIdentity = canonicalHostedReviewBranchIdentity(nextBranch)
|
||||
hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, {
|
||||
hostedReviewLinkClearTombstonesByCopy.set(tombstoneKey, {
|
||||
...tombstone,
|
||||
branch: nextBranch,
|
||||
branchIdentity: nextBranchIdentity,
|
||||
@@ -127,6 +215,16 @@ export function createUpdateWorktreeGitIdentity(
|
||||
if (!shouldPersistHostedReviewClear || clearedBranch === null) {
|
||||
return
|
||||
}
|
||||
const clearCopy = clearedCopy
|
||||
if (!clearCopy) {
|
||||
return
|
||||
}
|
||||
// Why: for a twin every read, clear and save below stays on the copy this answer patched.
|
||||
const ownerForTwin = clearedOnTwin ? (ownerHostId ?? undefined) : undefined
|
||||
const readRow = (id: string) => get().getKnownWorktreeById(id, ownerForTwin)
|
||||
const persistTo = (id: string) => runtimeTargetForWorktreeOwner(get(), id, ownerForTwin)
|
||||
// Why both: main matches an unstamped local twin by the answering host, not the row stamp.
|
||||
const persistHostId = ownerHostId ?? clearCopy.hostId
|
||||
|
||||
void Promise.resolve()
|
||||
.then(async () => {
|
||||
@@ -138,7 +236,7 @@ export function createUpdateWorktreeGitIdentity(
|
||||
return
|
||||
}
|
||||
persistedWorktreeIds.add(currentWorktreeId)
|
||||
let current = get().getKnownWorktreeById(currentWorktreeId)
|
||||
let current = readRow(currentWorktreeId)
|
||||
if (
|
||||
!current ||
|
||||
current.branch !== clearedBranch ||
|
||||
@@ -148,16 +246,17 @@ export function createUpdateWorktreeGitIdentity(
|
||||
}
|
||||
if (!hostedReviewLinksAreCleared(current as Worktree)) {
|
||||
// Why: a refetch can rehydrate stale linked-review metadata before this async clear starts; clear it again.
|
||||
applyHostedReviewLinkClear(set, currentWorktreeId)
|
||||
current = get().getKnownWorktreeById(currentWorktreeId)
|
||||
applyHostedReviewLinkClear(set, currentWorktreeId, clearCopy)
|
||||
current = readRow(currentWorktreeId)
|
||||
if (!current || current.branch !== clearedBranch) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await persistWorktreeMeta(
|
||||
runtimeTargetForWorktreeOwner(get(), currentWorktreeId),
|
||||
persistTo(currentWorktreeId),
|
||||
currentWorktreeId,
|
||||
CLEARED_HOSTED_REVIEW_LINK_UPDATES
|
||||
CLEARED_HOSTED_REVIEW_LINK_UPDATES,
|
||||
persistHostId
|
||||
)
|
||||
const migratedWorktreeId = resolveHostedReviewLinkWorktreeId(currentWorktreeId)
|
||||
if (migratedWorktreeId === currentWorktreeId) {
|
||||
@@ -166,7 +265,7 @@ export function createUpdateWorktreeGitIdentity(
|
||||
// Why: worktree creation can migrate ids mid-IPC; persist the clear under the new durable id too.
|
||||
currentWorktreeId = migratedWorktreeId
|
||||
}
|
||||
const latest = get().getKnownWorktreeById(currentWorktreeId)
|
||||
const latest = readRow(currentWorktreeId)
|
||||
if (
|
||||
!latest ||
|
||||
latest.branch !== clearedBranch ||
|
||||
@@ -177,14 +276,16 @@ export function createUpdateWorktreeGitIdentity(
|
||||
if (getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration) {
|
||||
// Why: a delayed branch-switch clear must not win over a newer manual relink.
|
||||
await persistWorktreeMeta(
|
||||
runtimeTargetForWorktreeOwner(get(), currentWorktreeId),
|
||||
persistTo(currentWorktreeId),
|
||||
currentWorktreeId,
|
||||
getHostedReviewLinkUpdates(latest as Worktree)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a visible or detected row; both carry every review-link field read here.
|
||||
getHostedReviewLinkUpdates(latest as Worktree),
|
||||
persistHostId
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: a refetch can rehydrate old metadata before the branch-switch clear reaches disk; don't write the stale link back.
|
||||
applyHostedReviewLinkClear(set, currentWorktreeId)
|
||||
applyHostedReviewLinkClear(set, currentWorktreeId, clearCopy)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isRuntimeSelectorNotFoundError(err)) {
|
||||
|
||||
Reference in New Issue
Block a user