fix: avoid repeated macOS privacy prompts (#1524)

* fix: avoid repeated macos privacy prompts

* fix: reduce background worktree permission probes

* chore: pin oxlint for ci

* fix: preserve optional rpc params with zod 4.4

* fix: preserve optional inline rpc params with zod 4.4
This commit is contained in:
Neil
2026-05-06 23:30:03 -07:00
committed by GitHub
parent 43a258951f
commit 7b83b2dcdc
28 changed files with 532 additions and 208 deletions
@@ -11,7 +11,7 @@ export function useGitStatusPolling(): void {
const activeWorktree = useActiveWorktree()
const allWorktrees = useAllWorktrees()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
const setConflictOperation = useAppStore((s) => s.setConflictOperation)
@@ -56,11 +56,25 @@ export function useGitStatusPolling(): void {
connectionId
})) as GitStatusResult
setGitStatus(activeWorktreeId, status)
// Why: branch switches can happen inside a terminal. `git status
// --branch` gives us the new identity without a separate worktree-list
// poll that would repeatedly touch repo/worktree roots.
updateWorktreeGitIdentity(activeWorktreeId, {
head: status.head,
branch: status.branch
})
await fetchUpstreamStatus(activeWorktreeId, worktreePath, connectionId)
} catch {
// ignore
}
}, [activeRepoSupportsGit, activeWorktreeId, fetchUpstreamStatus, worktreePath, setGitStatus])
}, [
activeRepoSupportsGit,
activeWorktreeId,
fetchUpstreamStatus,
worktreePath,
setGitStatus,
updateWorktreeGitIdentity
])
useEffect(() => {
void fetchStatus()
@@ -83,29 +97,6 @@ export function useGitStatusPolling(): void {
}
}, [fetchStatus])
useEffect(() => {
if (!activeRepoId || !activeRepoSupportsGit) {
return
}
// Why: checkout/switch operations happen inside the terminal, outside the
// renderer's normal worktree-change events. Poll the active repo's worktree
// list so a branch change updates the sidebar's PR key instead of leaving
// the previous merged PR attached to this worktree indefinitely.
void fetchWorktrees(activeRepoId)
const intervalId = setInterval(() => {
if (document.hasFocus()) {
void fetchWorktrees(activeRepoId)
}
}, POLL_INTERVAL_MS)
const onFocus = (): void => void fetchWorktrees(activeRepoId)
window.addEventListener('focus', onFocus)
return () => {
clearInterval(intervalId)
window.removeEventListener('focus', onFocus)
}
}, [activeRepoId, activeRepoSupportsGit, fetchWorktrees])
// Why: poll conflict operation for non-active worktrees that have a stale
// non-unknown operation. This is a lightweight fs-only check (no git status)
// so it won't cause performance issues even with many worktrees.
@@ -108,6 +108,10 @@ export type WorktreeSlice = {
* one-shot at hydration time. See design §4.4.
*/
purgeWorktreeTerminalState: (worktreeIds: string[]) => void
updateWorktreeGitIdentity: (
worktreeId: string,
identity: { head?: string; branch?: string }
) => void
}
export function findWorktreeById(
@@ -167,6 +167,37 @@ describe('fetchWorktrees', () => {
})
})
describe('updateWorktreeGitIdentity', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates branch identity from git status without fetching worktrees', () => {
const store = createTestStore()
const existing = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
head: 'old-head',
branch: 'refs/heads/main'
})
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 3 } as Partial<AppState>)
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
head: 'new-head',
branch: 'refs/heads/feature'
})
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
head: 'new-head',
branch: 'refs/heads/feature'
})
expect(store.getState().sortEpoch).toBe(4)
expect(mockApi.worktrees.list).not.toHaveBeenCalled()
})
})
describe('removeWorktree state cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -652,6 +683,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(2)
expect(store.getState().tabsByWorktree).toEqual({
'repoA::/a/wt1': [{ id: 'tab-A', worktreeId: 'repoA::/a/wt1' }],
'repoB::/b/wt1': [{ id: 'tab-B', worktreeId: 'repoB::/b/wt1' }]
@@ -667,6 +699,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
await store.getState().fetchAllWorktrees()
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(4)
expect(store.getState().tabsByWorktree['repoA::/a/new-zombie']).toBeDefined()
})
})
+47 -14
View File
@@ -114,24 +114,24 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// (design §2c), which means the user would still need a second restart
// post-upgrade to reclaim memory.
//
// Safety gate: fetchWorktrees swallows IPC errors (catch at :93-95)
// and short-circuits on empty-replace when cached data exists
// (empty-guard at :82-84). Neither signal bubbles up to the caller, so
// we can't distinguish "threw" from "returned []" from "returned ≥1"
// by inspecting post-state alone. If we declared the union of
// worktreesByRepo authoritative without confirming every repo
// succeeded, a single transient git error at launch would wipe every
// tabsByWorktree entry for the affected repo — the exact data-loss
// class the empty-guard exists to prevent. Probe the IPC directly to
// get the precise per-repo result, and defer the purge until every
// repo returns success AND at least one has >0 worktrees. In steady
// state this fires on the first fully-successful launch; in the
// degraded state it simply waits.
// Safety gate: fetchWorktrees swallows IPC errors and short-circuits on
// empty-replace when cached data exists. Neither signal bubbles up to the
// caller, so we probe the IPC directly to get the per-repo success signal,
// then apply that same payload to state instead of listing each repo again.
const results = await Promise.all(
repos.map(async (r) => {
try {
const list = await window.api.worktrees.list({ repoId: r.id })
await get().fetchWorktrees(r.id)
const current = get().worktreesByRepo[r.id]
if (
!areWorktreesEqual(current, list) &&
!(list.length === 0 && current && current.length > 0)
) {
set((s) => ({
worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list },
sortEpoch: s.sortEpoch + 1
}))
}
return { repoId: r.id, ok: list.length > 0 }
} catch (err) {
console.error(`Failed to fetch worktrees for repo ${r.id}:`, err)
@@ -162,6 +162,39 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
set({ hasHydratedWorktreePurge: true })
},
updateWorktreeGitIdentity: (worktreeId, identity) => {
set((s) => {
const repoId = getRepoIdFromWorktreeId(worktreeId)
const current = s.worktreesByRepo[repoId]
if (!current) {
return {}
}
let changed = false
const next = current.map((worktree) => {
if (worktree.id !== worktreeId) {
return worktree
}
const nextHead = identity.head ?? worktree.head
const nextBranch = identity.branch ?? worktree.branch
if (nextHead === worktree.head && nextBranch === worktree.branch) {
return worktree
}
changed = true
return { ...worktree, head: nextHead, branch: nextBranch }
})
if (!changed) {
return {}
}
return {
worktreesByRepo: { ...s.worktreesByRepo, [repoId]: next },
sortEpoch: s.sortEpoch + 1
}
})
},
createWorktree: async (
repoId,
name,