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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 532 additions and 208 deletions

View File

@ -2,7 +2,6 @@
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import type { Repo } from '../../shared/types'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@ -13,16 +12,10 @@ import type {
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import { mergeWorktree } from '../ipc/worktree-logic'
import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import {
createWorktreeRefs,
getDefaultWorktreeLabel,
getSessionProjectLabel,
scanClaudeUsageFiles
} from './scanner'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
const SCHEMA_VERSION = 1
const STALE_MS = 5 * 60_000
@ -131,12 +124,6 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
type ClaudeUsageWorktree = {
worktreeId: string
path: string
displayName: string
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
@ -226,7 +213,7 @@ export class ClaudeUsageStore {
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo))
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
@ -502,25 +489,4 @@ export class ClaudeUsageStore {
return true
})
}
private async loadWorktreesByRepo(repos: Repo[]): Promise<Map<string, ClaudeUsageWorktree[]>> {
const worktreesByRepo = new Map<string, ClaudeUsageWorktree[]>()
for (const repo of repos) {
const gitWorktrees = await listRepoWorktrees(repo)
const mapped = gitWorktrees.map((worktree) => {
const worktreeId = `${repo.id}::${worktree.path}`
const meta = this.store.getWorktreeMeta(worktreeId)
const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName)
return {
worktreeId,
path: worktree.path,
displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path)
}
})
worktreesByRepo.set(repo.id, mapped)
}
return worktreesByRepo
}
}

View File

@ -2,7 +2,6 @@
import { app } from 'electron'
import { dirname, join } from 'path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import type { Repo } from '../../shared/types'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@ -13,11 +12,10 @@ import type {
CodexUsageSessionRow,
CodexUsageSummary
} from '../../shared/codex-usage-types'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import { mergeWorktree } from '../ipc/worktree-logic'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, getDefaultWorktreeLabel, scanCodexUsageFiles } from './scanner'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
const SCHEMA_VERSION = 2
const STALE_MS = 5 * 60_000
@ -141,12 +139,6 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
type CodexUsageWorktree = {
worktreeId: string
path: string
displayName: string
}
type ScopedCodexUsageModelRow = {
modelKey: string
modelLabel: string
@ -159,7 +151,7 @@ type ScopedCodexUsageModelRow = {
totalTokens: number
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, CodexUsageWorktree[]>): string {
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
@ -259,7 +251,7 @@ export class CodexUsageStore {
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanCodexUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
@ -591,28 +583,7 @@ export class CodexUsageStore {
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
private async loadWorktreesByRepo(repos: Repo[]): Promise<Map<string, CodexUsageWorktree[]>> {
const worktreesByRepo = new Map<string, CodexUsageWorktree[]>()
for (const repo of repos) {
const gitWorktrees = await listRepoWorktrees(repo)
const mapped = gitWorktrees.map((worktree) => {
const worktreeId = `${repo.id}::${worktree.path}`
const meta = this.store.getWorktreeMeta(worktreeId)
const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName)
return {
worktreeId,
path: worktree.path,
displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path)
}
})
worktreesByRepo.set(repo.id, mapped)
}
return worktreesByRepo
}
}

View File

@ -294,13 +294,36 @@ describe('getStatus', () => {
// wrapped in double quotes) and the parser would store that literal
// string as entry.path, breaking sidebar display + downstream blob reads.
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
[
'-c',
'core.quotePath=false',
'status',
'--porcelain=v2',
'--branch',
'--untracked-files=all'
],
{ cwd: '/repo' }
)
expect(result.entries).toEqual([
{ path: 'docs/日本語/sample.md', status: 'modified', area: 'unstaged' }
])
})
it('parses branch identity from porcelain v2 branch headers', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'# branch.oid abcdef1234567890\n# branch.head feature/prompts\n1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a src/app.ts\n'
})
const result = await getStatus('/repo')
expect(result).toMatchObject({
head: 'abcdef1234567890',
branch: 'refs/heads/feature/prompts'
})
})
})
describe('detectConflictOperation', () => {

View File

@ -23,6 +23,8 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
*/
export async function getStatus(worktreePath: string): Promise<GitStatusResult> {
const entries: GitStatusEntry[] = []
let head: string | undefined
let branch: string | undefined
// Why: detectConflictOperation (4 existsSync + readFile) and git status are
// independent. Running them concurrently saves one round-trip of I/O latency.
@ -32,7 +34,7 @@ export async function getStatus(worktreePath: string): Promise<GitStatusResult>
// in double quotes. Without it, the parsed entry.path is unreadable in the
// sidebar and downstream `git show :"docs/\346..."` lookups silently miss.
const statusPromise = gitExecFileAsync(
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all'],
{ cwd: worktreePath }
)
const conflictOperation = await conflictPromise
@ -47,6 +49,17 @@ export async function getStatus(worktreePath: string): Promise<GitStatusResult>
continue
}
if (line.startsWith('# branch.oid ')) {
head = line.slice('# branch.oid '.length).trim()
continue
}
if (line.startsWith('# branch.head ')) {
const branchHead = line.slice('# branch.head '.length).trim()
branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : ''
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
// Changed entries: "1 XY sub mH mI mW hH path" or "2 XY sub mH mI mW hH X\tscore\tpath\torigPath"
const parts = line.split(' ')
@ -95,7 +108,7 @@ export async function getStatus(worktreePath: string): Promise<GitStatusResult>
// Not a git repo or git not available
}
return { entries, conflictOperation }
return { entries, conflictOperation, head, branch }
}
function parseStatusChar(char: string): GitFileStatus {

View File

@ -1,5 +1,5 @@
import { realpath } from 'fs/promises'
import { resolve, relative, dirname, basename, isAbsolute } from 'path'
import { realpath } from 'fs/promises'
import type { Store } from '../persistence'
import { listRepoWorktrees } from '../repo-worktrees'
@ -8,6 +8,8 @@ export const PATH_ACCESS_DENIED_MESSAGE =
const authorizedExternalPaths = new Set<string>()
const registeredWorktreeRoots = new Set<string>()
const registeredWorktreeRootsByRepo = new Map<string, Set<string>>()
const registeredWorktreeRootRepoIds = new Set<string>()
let registeredWorktreeRootsDirty = true
let registeredWorktreeRootsRefresh: Promise<void> | null = null
@ -17,6 +19,18 @@ export function authorizeExternalPath(targetPath: string): void {
export function invalidateAuthorizedRootsCache(): void {
registeredWorktreeRootsDirty = true
// Why: dirty roots cannot be trusted for auth short-circuits. Fresh
// worktrees:list results will seed safe per-repo roots before a full rebuild.
registeredWorktreeRoots.clear()
registeredWorktreeRootsByRepo.clear()
registeredWorktreeRootRepoIds.clear()
}
function getLocalRepos(store: Store) {
// Why: SSH repo paths are meaningful on the remote host. Treating them as
// local roots can both authorize unrelated local folders and probe paths
// that Orca should only touch through the SSH provider.
return store.getRepos().filter((repo) => !repo.connectionId)
}
/**
@ -40,7 +54,7 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string
}
export function getAllowedRoots(store: Store): string[] {
const roots = store.getRepos().map((repo) => resolve(repo.path))
const roots = getLocalRepos(store).map((repo) => resolve(repo.path))
const workspaceDir = store.getSettings().workspaceDir
if (workspaceDir) {
roots.push(resolve(workspaceDir))
@ -67,17 +81,22 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
// all repos. The previous sequential loop was the main bottleneck on
// Windows where each `git worktree list` + realpath chain takes 500 ms+
// due to slower process creation and antivirus I/O scanning.
const repos = store.getRepos()
//
// Why no realpath() here: this rebuild runs on repo/worktree invalidation,
// so canonicalizing every repo root would repeatedly touch TCC-protected
// folders on macOS even when the user is idle. The actual
// file handlers still canonicalize the specific target path before any
// destructive or read/write operation, so the security boundary remains
// enforced where it matters.
const repos = getLocalRepos(store)
const perRepoResults = await Promise.all(
repos.map(async (repo) => {
const roots: string[] = []
try {
roots.push(await normalizeExistingPath(repo.path))
roots.push(resolve(repo.path))
const worktrees = await listRepoWorktrees(repo)
const worktreeRoots = await Promise.all(
worktrees.map((wt) => normalizeExistingPath(wt.path))
)
const worktreeRoots = worktrees.map((wt) => resolve(wt.path))
roots.push(...worktreeRoots)
} catch (error) {
// Why: a single inaccessible repo (EACCES, EIO, etc.) must not break
@ -86,19 +105,50 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
// the rest proceed.
console.warn(`[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, error)
}
return roots
return { repoId: repo.id, roots }
})
)
registeredWorktreeRoots.clear()
for (const roots of perRepoResults) {
registeredWorktreeRootsByRepo.clear()
registeredWorktreeRootRepoIds.clear()
for (const { repoId, roots } of perRepoResults) {
const normalizedRoots = new Set<string>()
for (const root of roots) {
normalizedRoots.add(root)
registeredWorktreeRoots.add(root)
}
registeredWorktreeRootsByRepo.set(repoId, normalizedRoots)
registeredWorktreeRootRepoIds.add(repoId)
}
registeredWorktreeRootsDirty = false
}
export function registerWorktreeRootsForRepo(
store: Store,
repoId: string,
worktreeRoots: string[]
): void {
const localRepoIds = new Set(getLocalRepos(store).map((repo) => repo.id))
for (const registeredRepoId of registeredWorktreeRootsByRepo.keys()) {
if (!localRepoIds.has(registeredRepoId)) {
registeredWorktreeRootsByRepo.delete(registeredRepoId)
registeredWorktreeRootRepoIds.delete(registeredRepoId)
}
}
if (!localRepoIds.has(repoId)) {
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds)
return
}
registeredWorktreeRootsByRepo.set(repoId, new Set(worktreeRoots.map((root) => resolve(root))))
registeredWorktreeRootRepoIds.add(repoId)
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds)
}
export async function ensureAuthorizedRootsCache(store: Store): Promise<void> {
if (!registeredWorktreeRootsDirty) {
return
@ -181,26 +231,16 @@ async function isPathAllowedIncludingRegisteredWorktrees(
return true
}
if (isRegisteredWorktreePath(targetPath)) {
return true
}
await ensureAuthorizedRootsCache(store)
// Why: external linked worktrees are already trusted for git operations.
// Cache their normalized roots once and reuse that index so quick-open and
// file explorer do not spawn `git worktree list` on every filesystem read.
for (const root of registeredWorktreeRoots) {
if (isDescendantOrEqual(targetPath, root)) {
return true
}
}
return false
}
async function normalizeExistingPath(targetPath: string): Promise<string> {
try {
return await realpath(targetPath)
} catch {
return resolve(targetPath)
}
return isRegisteredWorktreePath(targetPath)
}
/**
@ -225,21 +265,66 @@ export async function resolveRegisteredWorktreePath(
const resolvedTarget = resolve(worktreePath)
// Resolve through symlinks when the path exists on disk, so that we
// compare canonical paths on both sides (git worktree list also resolves
// symlinks).
const normalizedTarget = await normalizeExistingPath(resolvedTarget)
if (registeredWorktreeRoots.has(resolvedTarget)) {
return resolvedTarget
}
await ensureAuthorizedRootsCache(store)
for (const root of registeredWorktreeRoots) {
if (normalizedTarget === root) {
return normalizedTarget
}
if (registeredWorktreeRootsDirty) {
await ensureAuthorizedRootsCache(store)
}
if (registeredWorktreeRoots.has(resolvedTarget)) {
return resolvedTarget
}
// Resolve through symlinks only after the cheap registered-root check.
// On macOS, realpath() can itself trigger TCC prompts for protected roots.
const normalizedTarget = await normalizeExistingPath(resolvedTarget)
if (registeredWorktreeRoots.has(normalizedTarget)) {
return normalizedTarget
}
throw new Error('Access denied: unknown repository or worktree path')
}
function refreshRegisteredWorktreeRoots(): void {
registeredWorktreeRoots.clear()
for (const roots of registeredWorktreeRootsByRepo.values()) {
for (const root of roots) {
registeredWorktreeRoots.add(root)
}
}
}
function allLocalRepoRootsRegistered(localRepoIds: Set<string>): boolean {
for (const repoId of localRepoIds) {
if (!registeredWorktreeRootRepoIds.has(repoId)) {
return false
}
}
return true
}
function isRegisteredWorktreePath(targetPath: string): boolean {
for (const root of registeredWorktreeRoots) {
if (isDescendantOrEqual(targetPath, root)) {
return true
}
}
return false
}
async function normalizeExistingPath(resolvedPath: string): Promise<string> {
try {
return resolve(await realpath(resolvedPath))
} catch (error) {
if (isENOENT(error)) {
return resolvedPath
}
throw error
}
}
export function validateGitRelativeFilePath(worktreePath: string, filePath: string): string {
if (!filePath || filePath.includes('\0') || resolve(filePath) === filePath) {
throw new Error('Access denied: invalid git file path')

View File

@ -96,7 +96,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
}))
import { registerFilesystemHandlers } from './filesystem'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
@ -219,6 +219,12 @@ describe('registerFilesystemHandlers', () => {
expect(readFileMock).not.toHaveBeenCalled()
})
it('does not enumerate worktrees when filesystem handlers register', () => {
registerFilesystemHandlers(store as never)
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('rejects writes to directories', async () => {
lstatMock.mockResolvedValue({ isDirectory: () => true })
@ -344,6 +350,19 @@ describe('registerFilesystemHandlers', () => {
expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts'))
})
it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [] })
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
expect(listWorktreesMock).not.toHaveBeenCalled()
expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH)
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH)
})
it('rejects git file paths that escape the selected worktree', async () => {
registerFilesystemHandlers(store as never)

View File

@ -46,8 +46,7 @@ import {
resolveRegisteredWorktreePath,
validateGitRelativeFilePath,
isENOENT,
authorizeExternalPath,
rebuildAuthorizedRootsCache
authorizeExternalPath
} from './filesystem-auth'
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
@ -106,7 +105,6 @@ async function isBinaryFilePrefix(filePath: string): Promise<boolean> {
}
export function registerFilesystemHandlers(store: Store): void {
void rebuildAuthorizedRootsCache(store)
const activeTextSearches = new Map<string, ChildProcess>()
// ─── Filesystem ─────────────────────────────────────────

View File

@ -20,7 +20,7 @@ const {
readdirMock,
rmMock,
gitExecFileAsyncMock,
rebuildAuthorizedRootsCacheMock
invalidateAuthorizedRootsCacheMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
@ -36,7 +36,7 @@ const {
readdirMock: vi.fn(),
rmMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
rebuildAuthorizedRootsCacheMock: vi.fn().mockResolvedValue(undefined)
invalidateAuthorizedRootsCacheMock: vi.fn()
}))
vi.mock('electron', () => ({
@ -68,7 +68,7 @@ vi.mock('../git/repo', () => ({
}))
vi.mock('./filesystem-auth', () => ({
rebuildAuthorizedRootsCache: rebuildAuthorizedRootsCacheMock
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
@ -111,7 +111,7 @@ describe('repos:create', () => {
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.addRepo.mockReset()
mockWindow.webContents.send.mockReset()
rebuildAuthorizedRootsCacheMock.mockReset().mockResolvedValue(undefined)
invalidateAuthorizedRootsCacheMock.mockReset()
// Default baseline: target does NOT exist yet, mkdir succeeds, git OK.
accessMock.mockReset().mockRejectedValue(new Error('ENOENT'))
@ -340,17 +340,17 @@ describe('repos:create', () => {
// ── authorized-roots cache refresh ────────────────────────────────
it('rebuilds the authorized-roots cache after a successful folder create', async () => {
// Roots cache must be refreshed so the renderer can read the new path
// without waiting for the next full reconciliation.
it('invalidates the authorized-roots cache after a successful folder create', async () => {
// Direct repo roots come from store state; invalidation clears stale linked
// roots without scanning every existing repo during creation.
await callCreate({ parentPath: '/tmp', name: 'rooted', kind: 'folder' })
expect(rebuildAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1)
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1)
})
it('does NOT rebuild the authorized-roots cache on a validation failure', async () => {
const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' })
expect(result).toEqual({ error: 'Name cannot be empty' })
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled()
})
it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => {
@ -359,7 +359,7 @@ describe('repos:create', () => {
await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' })
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled()
})
// ── dedup-by-path ─────────────────────────────────────────────────

View File

@ -54,7 +54,7 @@ vi.mock('../git/repo', async () => {
})
vi.mock('./filesystem-auth', () => ({
rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined)
invalidateAuthorizedRootsCache: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({

View File

@ -44,7 +44,7 @@ vi.mock('../git/repo', () => ({
}))
vi.mock('./filesystem-auth', () => ({
rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined)
invalidateAuthorizedRootsCache: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({

View File

@ -8,7 +8,7 @@ import type { Store } from '../persistence'
import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { REPO_COLORS } from '../../shared/constants'
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { access, mkdir, readdir, rm } from 'fs/promises'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
@ -107,7 +107,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
store.addRepo(repo)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', false)
return { repo }
@ -404,7 +404,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
store.addRepo(repo)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', false)
return { repo }
@ -413,7 +413,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
store.removeRepo(args.repoId)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
})
@ -665,7 +665,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
store.addRepo(repo)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('clone_url', false)
return repo

View File

@ -237,13 +237,9 @@ describe('registerWorktreeHandlers Windows path handling', () => {
isBare: false,
isMainWorktree: false
}
// Three calls: (1) worktrees:create finds the new worktree,
// (2) rebuildAuthorizedRootsCache enumerates worktrees for the repo,
// (3) worktrees:list enumerates worktrees again.
listWorktreesMock
.mockResolvedValueOnce([worktreeEntry])
.mockResolvedValueOnce([worktreeEntry])
.mockResolvedValueOnce([worktreeEntry])
// Two calls: (1) worktrees:create finds the new worktree,
// (2) worktrees:list enumerates worktrees again.
listWorktreesMock.mockResolvedValueOnce([worktreeEntry]).mockResolvedValueOnce([worktreeEntry])
store.setWorktreeMeta.mockReturnValue({
lastActivityAt: 123,
displayName: 'Improve Dashboard'

View File

@ -5,7 +5,13 @@ import { rm } from 'fs/promises'
import type { Store } from '../persistence'
import { isFolderRepo } from '../../shared/repo-kind'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
GitWorktreeInfo,
Repo,
WorktreeMeta
} from '../../shared/types'
import { removeWorktree } from '../git/worktree'
import { gitExecFileAsync } from '../git/runner'
import { getDefaultRemote } from '../git/repo'
@ -33,7 +39,7 @@ import {
createRemoteWorktree,
notifyWorktreesChanged
} from './worktree-remote'
import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } from './filesystem-auth'
import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { killAllProcessesForWorktree } from '../runtime/worktree-teardown'
import { getLocalPtyProvider } from './pty'
@ -70,6 +76,23 @@ function warnOnce(keySet: Set<string>, key: string, message: string, error?: unk
}
}
function rememberLocalWorktreeRoots(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[]
): void {
if (repo.connectionId) {
return
}
// Why: worktrees:list already paid the `git worktree list` cost. Reusing
// that result keeps later git/file IPC validation from doing a second
// background scan that can trigger macOS folder-permission prompts.
registerWorktreeRootsForRepo(store, repo.id, [
repo.path,
...gitWorktrees.map((worktree) => worktree.path)
])
}
export function registerWorktreeHandlers(
mainWindow: BrowserWindow,
store: Store,
@ -90,10 +113,6 @@ export function registerWorktreeHandlers(
ipcMain.removeHandler('hooks:writeIssueCommand')
ipcMain.handle('worktrees:listAll', async () => {
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
// listing git worktrees when the cache is already fresh — the handler
// itself calls listWorktrees for every repo below.
await ensureAuthorizedRootsCache(store)
const repos = store.getRepos()
// Why: repos are listed in parallel so total time = slowest repo, not
@ -119,6 +138,7 @@ export function registerWorktreeHandlers(
} else {
gitWorktrees = await listRepoWorktrees(repo)
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return gitWorktrees.map((gw) => {
const worktreeId = `${repo.id}::${gw.path}`
@ -132,6 +152,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
rememberLocalWorktreeRoots(store, repo, [])
return []
}
})
@ -141,10 +162,6 @@ export function registerWorktreeHandlers(
})
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
// listing git worktrees when the cache is already fresh — the handler
// itself calls listWorktrees below.
await ensureAuthorizedRootsCache(store)
const repo = store.getRepo(args.repoId)
if (!repo) {
return []
@ -175,6 +192,7 @@ export function registerWorktreeHandlers(
} else {
gitWorktrees = await listRepoWorktrees(repo)
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return gitWorktrees.map((gw) => {
const worktreeId = `${repo.id}::${gw.path}`
@ -188,6 +206,7 @@ export function registerWorktreeHandlers(
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
rememberLocalWorktreeRoots(store, repo, [])
return []
}
})
@ -412,7 +431,7 @@ export function registerWorktreeHandlers(
await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {})
store.removeWorktreeMeta(args.worktreeId)
deleteWorktreeHistoryDir(args.worktreeId)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyWorktreesChanged(mainWindow, repoId)
return
}
@ -420,7 +439,7 @@ export function registerWorktreeHandlers(
}
store.removeWorktreeMeta(args.worktreeId)
deleteWorktreeHistoryDir(args.worktreeId)
await rebuildAuthorizedRootsCache(store)
invalidateAuthorizedRootsCache()
notifyWorktreesChanged(mainWindow, repoId)
}

View File

@ -60,6 +60,7 @@ export const Screenshot = BrowserTarget.extend({
export const FullScreenshot = BrowserTarget.extend({
format: z
.unknown()
.optional()
.transform((v) => (v === 'jpeg' ? 'jpeg' : 'png'))
.pipe(z.enum(['png', 'jpeg']))
})
@ -166,6 +167,7 @@ export const Check = BrowserTarget.extend({
element: requiredString('Missing required --element'),
checked: z
.unknown()
.optional()
.transform((v) => v !== false)
.pipe(z.boolean())
})

View File

@ -309,9 +309,8 @@ function gcScanRoot(
return result
}
/** Run background GC to prune history directories for worktrees that no
* longer exist. Must be called after live worktree enumeration is complete
* to avoid deleting history for worktrees that haven't been discovered yet. */
/** Run background GC to prune history directories for worktrees that are no
* longer in Orca's known live-worktree set. */
export function runHistoryGc(liveWorktreeIds: Set<string>): void {
try {
const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds)
@ -348,8 +347,7 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
}
/** Schedule GC after a delay so it runs after workspace hydration completes.
* `getLiveWorktreeIds` should enumerate all currently known worktree IDs
* (e.g. by listing repos and their git worktrees). */
* `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */
export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise<Set<string>>): void {
// Why 10s: avoids competing with startup-critical I/O while still running
// early enough to clean up before the user notices disk usage (§7.6).

View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import { loadKnownUsageWorktreesByRepo } from './usage-worktree-metadata'
describe('loadKnownUsageWorktreesByRepo', () => {
it('builds usage worktree refs from repo roots and persisted metadata', () => {
const store = {
getAllWorktreeMeta: vi.fn(() => ({
'repo-1::/workspace/repo-a-feature': {
displayName: 'Feature A'
},
'repo-2::/remote/repo-b-feature': {
displayName: 'Remote feature'
},
malformed: {
displayName: 'Ignored'
}
}))
}
const repos = [
{
id: 'repo-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
},
{
id: 'repo-2',
path: '/remote/repo-b',
displayName: 'Remote Repo',
connectionId: 'ssh-1'
}
]
expect(loadKnownUsageWorktreesByRepo(store as never, repos as never)).toEqual(
new Map([
[
'repo-1',
[
{
worktreeId: 'repo-1::/workspace/repo-a',
path: '/workspace/repo-a',
displayName: 'Repo A'
},
{
worktreeId: 'repo-1::/workspace/repo-a-feature',
path: '/workspace/repo-a-feature',
displayName: 'Feature A'
}
]
]
])
)
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,66 @@
import { basename } from 'path'
import type { Repo } from '../shared/types'
import type { Store } from './persistence'
export type UsageWorktreeRef = {
worktreeId: string
path: string
displayName: string
}
function getDefaultUsageWorktreeLabel(pathValue: string): string {
return basename(pathValue)
}
function parseKnownWorktreeId(worktreeId: string): { repoId: string; worktreePath: string } | null {
const sepIdx = worktreeId.indexOf('::')
if (sepIdx === -1) {
return null
}
return {
repoId: worktreeId.slice(0, sepIdx),
worktreePath: worktreeId.slice(sepIdx + 2)
}
}
export function loadKnownUsageWorktreesByRepo(
store: Pick<Store, 'getAllWorktreeMeta'>,
repos: Repo[]
): Map<string, UsageWorktreeRef[]> {
const localRepos = repos.filter((repo) => !repo.connectionId)
const repoIds = new Set(localRepos.map((repo) => repo.id))
const worktreesByRepo = new Map<string, UsageWorktreeRef[]>()
const seenPathsByRepo = new Map<string, Set<string>>()
for (const repo of localRepos) {
worktreesByRepo.set(repo.id, [
{
worktreeId: `${repo.id}::${repo.path}`,
path: repo.path,
displayName: repo.displayName || getDefaultUsageWorktreeLabel(repo.path)
}
])
seenPathsByRepo.set(repo.id, new Set([repo.path]))
}
// Why: usage scans are background/opt-in analytics. Do not spawn
// `git worktree list` here; it can re-touch macOS protected folders.
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
const parsed = parseKnownWorktreeId(worktreeId)
if (!parsed || !repoIds.has(parsed.repoId)) {
continue
}
const seenPaths = seenPathsByRepo.get(parsed.repoId)
if (seenPaths?.has(parsed.worktreePath)) {
continue
}
seenPaths?.add(parsed.worktreePath)
worktreesByRepo.get(parsed.repoId)?.push({
worktreeId,
path: parsed.worktreePath,
displayName: meta.displayName || getDefaultUsageWorktreeLabel(parsed.worktreePath)
})
}
return worktreesByRepo
}

View File

@ -23,8 +23,8 @@ import {
dismissNudge
} from '../updater'
import { scheduleHistoryGc } from '../terminal-history'
import { listRepoWorktrees } from '../repo-worktrees'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids'
export function attachMainWindowServices(
mainWindow: BrowserWindow,
@ -50,19 +50,10 @@ export function attachMainWindowServices(
// and ensures the handlers are re-installed on macOS app re-activation when
// the main window is recreated.
registerDaemonManagementHandlers()
// Why: GC runs on a 10s delay so live worktree enumeration completes first.
// Uses git worktree list (not store.getWorktreeMeta) because untouched
// worktrees have no metadata entries — see design doc §7.6.
// Why: do not enumerate repo paths from background GC. `git worktree list`
// can re-touch protected folders on macOS and trigger folder-access prompts.
scheduleHistoryGc(async () => {
const repos = store.getRepos()
const ids = new Set<string>()
for (const repo of repos) {
const worktrees = await listRepoWorktrees(repo)
for (const wt of worktrees) {
ids.add(`${repo.id}::${wt.path}`)
}
}
return ids
return getKnownWorktreeIdsForHistoryGc(store)
})
registerSshHandlers(store, () => mainWindow, runtime)
registerFileDropRelay(mainWindow)

View File

@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids'
describe('getKnownWorktreeIdsForHistoryGc', () => {
it('uses persisted metadata keys without probing repo paths', () => {
const store = {
getAllWorktreeMeta: vi.fn(() => ({
'repo-1::/worktree-a': {},
'repo-2::/worktree-b': {}
}))
}
expect(getKnownWorktreeIdsForHistoryGc(store as never)).toEqual(
new Set(['repo-1::/worktree-a', 'repo-2::/worktree-b'])
)
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,7 @@
import type { Store } from '../persistence'
export function getKnownWorktreeIdsForHistoryGc(
store: Pick<Store, 'getAllWorktreeMeta'>
): Set<string> {
return new Set(Object.keys(store.getAllWorktreeMeta()))
}

View File

@ -49,11 +49,18 @@ export async function getStatusOp(
git: GitExec,
validatePath: (p: string) => void,
params: Record<string, unknown>
): Promise<{ entries: Record<string, unknown>[]; conflictOperation: string }> {
): Promise<{
entries: Record<string, unknown>[]
conflictOperation: string
head?: string
branch?: string
}> {
const worktreePath = params.worktreePath as string
validatePath(worktreePath)
const conflictOperation = await detectConflictOperation(worktreePath)
const entries: Record<string, unknown>[] = []
let head: string | undefined
let branch: string | undefined
try {
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in
@ -61,11 +68,20 @@ export async function getStatusOp(
// entry.path renders as gibberish in the source-control sidebar and
// downstream blob lookups miss.
const { stdout } = await git(
['-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--untracked-files=all'],
[
'-c',
'core.quotePath=false',
'status',
'--porcelain=v2',
'--branch',
'--untracked-files=all'
],
worktreePath
)
const parsed = parseStatusOutput(stdout)
entries.push(...parsed.entries)
head = parsed.head
branch = parsed.branch
for (const uLine of parsed.unmergedLines) {
const entry = parseUnmergedEntry(worktreePath, uLine)
@ -77,5 +93,5 @@ export async function getStatusOp(
// not a git repo or git not available
}
return { entries, conflictOperation }
return { entries, conflictOperation, head, branch }
}

View File

@ -72,15 +72,30 @@ export function parseConflictKind(xy: string): string | null {
export function parseStatusOutput(stdout: string): {
entries: Record<string, unknown>[]
unmergedLines: string[]
head?: string
branch?: string
} {
const entries: Record<string, unknown>[] = []
const unmergedLines: string[] = []
let head: string | undefined
let branch: string | undefined
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
if (line.startsWith('# branch.oid ')) {
head = line.slice('# branch.oid '.length).trim()
continue
}
if (line.startsWith('# branch.head ')) {
const branchHead = line.slice('# branch.head '.length).trim()
branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : ''
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
const parts = line.split(' ')
const xy = parts[1]
@ -130,7 +145,7 @@ export function parseStatusOutput(stdout: string): {
}
}
return { entries, unmergedLines }
return { entries, unmergedLines, head, branch }
}
/**

View File

@ -65,9 +65,13 @@ describe('GitHandler', () => {
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
entries: Record<string, unknown>[]
conflictOperation: string
head?: string
branch?: string
}
expect(result.entries).toEqual([])
expect(result.conflictOperation).toBe('unknown')
expect(result.branch).toMatch(/^refs\/heads\//)
expect(typeof result.head).toBe('string')
})
it('detects untracked files', async () => {

View File

@ -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.

View File

@ -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(

View File

@ -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()
})
})

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,

View File

@ -1586,6 +1586,8 @@ export type GitStatusEntry = GitUncommittedEntry
export type GitStatusResult = {
entries: GitStatusEntry[]
conflictOperation: GitConflictOperation
head?: string
branch?: string
}
// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a