diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index c4cb3b55d..84eb40c4c 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -93,6 +93,7 @@ vi.mock('./rate-limit', () => ({ import { getPRComments, getPRForBranch, + getRepoUpstream, getWorkItem, getPullRequestPushTarget, mergePR, @@ -1414,6 +1415,58 @@ describe('getPRForBranch', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) + it('resolves a distinct upstream remote as the repo upstream', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + + await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ + owner: 'stablyai', + repo: 'orca' + }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not treat a same-repository upstream remote as a fork', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'StablyAI', repo: 'Orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ isFork: false, parent: null }) + }) + + await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'], + { cwd: '/repo-root', timeout: 10_000 } + ) + }) + + it('does not mark an upstream-only GitHub remote as a fork', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + + await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() + + expect(getOwnerRepoForRemoteMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('falls back to the GitHub parent when no upstream remote is configured', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce(null) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + isFork: true, + parent: { name: 'orca', owner: { login: 'stablyai' } } + }) + }) + + await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ + owner: 'stablyai', + repo: 'orca' + }) + }) + it('probes additional PR repo candidates when the first lookup is not found', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 73feeb4cc..9254157e0 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1413,6 +1413,48 @@ export async function getRepoSlug( return getOwnerRepo(repoPath, connectionId) } +/** + * Resolve a fork's upstream/parent owner/repo, or null when the repo is not a + * fork. Why: a fork's `origin` points at the personal copy, so repo identity + * (notably the avatar) should prefer the upstream. Fast-paths the `upstream` + * remote (offline); otherwise asks the GitHub API for the fork parent. The API + * call targets the explicit origin slug, so it works for SSH repos too. + * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. + */ +export async function getRepoUpstream( + repoPath: string, + connectionId?: string | null +): Promise { + const origin = await getOwnerRepo(repoPath, connectionId) + if (!origin) { + return null + } + const upstreamRemote = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId) + if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { + return upstreamRemote + } + await acquire() + try { + const { stdout } = await ghExecFileAsync( + ['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], + // Why: best-effort fork lookup runs at add-time; cap latency so a stalled + // gh process can't hold up repo creation. + { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId)), timeout: 10_000 } + ) + const data = JSON.parse(stdout) as { + isFork?: boolean + parent?: { name?: string; owner?: { login?: string } } | null + } + const owner = data.parent?.owner?.login + const repo = data.parent?.name + return data.isFork && owner && repo ? { owner, repo } : null + } catch { + return null + } finally { + release() + } +} + function classifyCreatePRError(error: unknown): CreateHostedReviewResult { const { stderr, stdout } = extractExecError(error) const message = `${stderr}\n${stdout}`.trim() diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index bce2e4544..e6810e1d1 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -20,6 +20,7 @@ import { getPRForBranch, getIssue, getRepoSlug, + getRepoUpstream, listIssues, listWorkItems, countWorkItems, @@ -404,6 +405,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi return getRepoSlug(repo.path, repoConnectionId(repo)) }) + ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredRepo(args, store) + return getRepoUpstream(repo.path, repoConnectionId(repo)) + }) + ipcMain.handle( 'gh:prChecks', ( diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index a0c1733a2..83a711689 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -61,7 +61,7 @@ import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' -import { detectRepoIcon } from '../repo-icon-autodetect' +import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -606,11 +606,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) continue } + const detected = await detectRepoIconAndUpstream({ + repoPath, + kind: 'git', + connectionId: args.connectionId + }) const repo: Repo = { id: randomUUID(), path: repoPath, displayName: getRepoName(repoPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...detected, addedAt: Date.now(), kind: 'git', ...(args.connectionId ? { connectionId: args.connectionId } : {}), @@ -679,13 +685,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return { repo: existing } } - const repoIcon = await detectRepoIcon({ repoPath: args.path, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: args.path, displayName: getRepoName(args.path), badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -785,7 +791,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } } - const repoIcon = await detectRepoIcon({ + const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind, connectionId: args.connectionId @@ -795,7 +801,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v path: resolvedPath, displayName, badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: repoKind, connectionId: args.connectionId, @@ -996,13 +1002,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return { repo: raceWinner } } - const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: targetPath, displayName: name, badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -1055,6 +1061,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'displayName' | 'badgeColor' | 'repoIcon' + | 'upstream' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' @@ -1400,13 +1407,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return existing } - const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) + const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) const repo: Repo = { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 7e069f605..8574c871e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1830,6 +1830,29 @@ describe('Store', () => { expect(store.getRepos()[0]!.repoIcon).toBeUndefined() }) + it('updateRepo normalizes and persists repo upstream metadata', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + upstream: { owner: ' stablyai ', repo: ' orca ' } + }) + expect(updated!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + + store.updateRepo('r1', { upstream: null }) + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.upstream).toBeNull() + }) + + it('getRepo does not expose invalid persisted repo upstream metadata', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: '', repo: 42 } as never })) + + expect(store.getRepo('r1')!.upstream).toBeUndefined() + expect(store.getRepos()[0]!.upstream).toBeUndefined() + }) + it('updateRepo returns null for nonexistent id', async () => { const store = await createStore() expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 50edca981..a5193095f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -599,8 +599,24 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick } +function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { + if (value === undefined) { + return undefined + } + if (value === null) { + return null + } + if (!value || typeof value !== 'object') { + return undefined + } + const candidate = value as { owner?: unknown; repo?: unknown } + const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' + const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' + return owner && repo ? { owner, repo } : undefined +} + function sanitizeRepoUpdatesForPersistence< - T extends Partial> + T extends Partial> >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -619,6 +635,15 @@ function sanitizeRepoUpdatesForPersistence< sanitized.repoIcon = repoIcon } } + // Why: `null` is a valid "not a fork" marker; only drop malformed shapes. + if ('upstream' in sanitized) { + const upstream = sanitizeRepoUpstream(sanitized.upstream) + if (upstream === undefined) { + delete sanitized.upstream + } else { + sanitized.upstream = upstream + } + } if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { if (typeof sanitized.worktreeBasePath === 'string') { sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined @@ -2423,6 +2448,7 @@ export class Store { | 'displayName' | 'badgeColor' | 'repoIcon' + | 'upstream' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' @@ -2504,8 +2530,9 @@ export class Store { } private hydrateRepo(repo: Repo): Repo { - const { repoIcon: rawRepoIcon, ...repoWithoutIcon } = repo + const { repoIcon: rawRepoIcon, upstream: rawUpstream, ...repoWithoutIcon } = repo const repoIcon = sanitizeRepoIcon(rawRepoIcon) + const upstream = sanitizeRepoUpstream(rawUpstream) const gitUsername = isFolderRepo(repo) ? '' : (this.gitUsernameCache.get(repo.path) ?? @@ -2518,6 +2545,7 @@ export class Store { return { ...repoWithoutIcon, ...(repoIcon !== undefined ? { repoIcon } : {}), + ...(upstream !== undefined ? { upstream } : {}), kind: isFolderRepo(repo) ? 'folder' : 'git', gitUsername, hookSettings: { diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 7d0aa0cec..ea2ea2ea8 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -3,7 +3,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { afterEach, describe, expect, it } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { detectRepoIcon } from './repo-icon-autodetect' +import { detectRepoIcon, detectRepoIconAndUpstream } from './repo-icon-autodetect' const PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' @@ -147,4 +147,34 @@ describe('detectRepoIcon', () => { label: 'stablyai/orca' }) }) + + it('stores a null upstream marker for git repos without a resolved fork parent', async () => { + const repoPath = await makeTempRepoDir() + await gitExecFileAsync(['init'], { cwd: repoPath }) + + await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + upstream: null + }) + }) + + it('uses the resolved fork upstream for both metadata and the GitHub avatar', async () => { + const repoPath = await makeTempRepoDir() + await gitExecFileAsync(['init'], { cwd: repoPath }) + await gitExecFileAsync(['remote', 'add', 'origin', 'git@github.com:tmchow/orca.git'], { + cwd: repoPath + }) + await gitExecFileAsync(['remote', 'add', 'upstream', 'git@github.com:stablyai/orca.git'], { + cwd: repoPath + }) + + await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({ + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + }, + upstream: { owner: 'stablyai', repo: 'orca' } + }) + }) }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 6101c3a79..f9f0b52a6 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,11 +1,12 @@ import { readFile, stat } from 'fs/promises' -import type { RepoKind } from '../shared/types' +import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' import { faviconUrlFromWebsite, + githubAvatarIcon, MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' -import { getRepoSlug } from './github/client' +import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { iconHrefCandidates } from './repo-icon-href-candidates' @@ -257,18 +258,13 @@ async function detectRemotePackageHomepageIcon( async function detectGitHubAvatarIcon( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + upstream?: GitHubRepositoryIdentity | null ): Promise { try { - const slug = await getRepoSlug(repoPath, connectionId) - return slug - ? { - type: 'image', - src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`, - source: 'github', - label: `${slug.owner}/${slug.repo}` - } - : null + // Why: a fork's origin is the personal copy, so prefer the upstream owner. + const slug = upstream ?? (await getRepoSlug(repoPath, connectionId)) + return slug ? githubAvatarIcon(slug) : null } catch { return null } @@ -277,11 +273,13 @@ async function detectGitHubAvatarIcon( export async function detectRepoIcon({ repoPath, kind, - connectionId + connectionId, + upstream }: { repoPath: string kind: RepoKind connectionId?: string | null + upstream?: GitHubRepositoryIdentity | null }): Promise { try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined @@ -300,10 +298,33 @@ export async function detectRepoIcon({ } if (kind === 'git') { - return (await detectGitHubAvatarIcon(repoPath, connectionId)) ?? undefined + return (await detectGitHubAvatarIcon(repoPath, connectionId, upstream)) ?? undefined } } catch { // Repo creation must not fail because a best-effort icon probe failed. } return undefined } + +/** + * Detect a repo's icon and its fork upstream together. The upstream is resolved + * once and reused for the avatar so a fork shows the upstream owner's avatar. + * Returns a spread-ready slice of `Repo`. For git repos, `upstream: null` + * is a resolved "not a fork" marker and prevents repeated best-effort probes. + */ +export async function detectRepoIconAndUpstream({ + repoPath, + kind, + connectionId +}: { + repoPath: string + kind: RepoKind + connectionId?: string | null +}): Promise<{ repoIcon?: RepoIcon; upstream?: GitHubRepositoryIdentity | null }> { + const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null + const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream }) + return { + ...(repoIcon ? { repoIcon } : {}), + ...(kind === 'git' ? { upstream: upstream ?? null } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 26b4e9650..1da5e7e32 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -175,6 +175,7 @@ import { BrowserError } from '../browser/cdp-bridge' import { getPRForBranch, getRepoSlug, + getRepoUpstream, getWorkItem, listIssues as listGitHubIssues, listWorkItems, @@ -442,7 +443,8 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { detectRepoIcon } from '../repo-icon-autodetect' +import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' +import { githubAvatarIcon } from '../../shared/repo-icon' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' @@ -1231,6 +1233,7 @@ export class OrcaRuntimeService { private waitersByHandle = new Map>() private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null + private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null @@ -1852,6 +1855,12 @@ export class OrcaRuntimeService { setNotifier(notifier: RuntimeNotifier | null): void { this.notifier = notifier + // Why: run the one-shot fork-upstream backfill once a renderer is attached, + // so existing forks self-correct on launch and the result can be broadcast. + if (notifier && !this.forkBackfillStarted) { + this.forkBackfillStarted = true + void this.backfillForkUpstreams() + } } setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void { @@ -5557,13 +5566,13 @@ export class OrcaRuntimeService { return existing } - const repoIcon = await detectRepoIcon({ repoPath: path, kind }) + const detected = await detectRepoIconAndUpstream({ repoPath: path, kind }) const repo: Repo = { id: randomUUID(), path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind, ...(kind === 'git' @@ -5673,13 +5682,13 @@ export class OrcaRuntimeService { return { repo: raceWinner } } - const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind }) + const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: targetPath, displayName: trimmedName, badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' @@ -5818,13 +5827,13 @@ export class OrcaRuntimeService { return existing } - const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' }) + const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) const repo: Repo = { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(repoIcon ? { repoIcon } : {}), + ...detected, addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', @@ -5866,6 +5875,7 @@ export class OrcaRuntimeService { | 'displayName' | 'badgeColor' | 'repoIcon' + | 'upstream' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' @@ -6078,6 +6088,46 @@ export class OrcaRuntimeService { return getRepoSlug(repo.path, repo.connectionId ?? null) } + async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> { + const repo = await this.resolveRepoSelector(repoSelector) + return getRepoUpstream(repo.path, repo.connectionId ?? null) + } + + // Why: repos added before fork detection existed have no stored `upstream`, so + // their avatar/badge would never self-correct. Resolve it once at startup for + // local git repos; SSH repos resolve lazily when their settings open (their + // connection may not be up yet). Sequential to respect the gh rate limit; + // failures leave `upstream` unset so the next launch retries. + private async backfillForkUpstreams(): Promise { + try { + const store = this.requireStore() + let changed = false + for (const repo of store.getRepos()) { + if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { + continue + } + let upstream: { owner: string; repo: string } | null + try { + upstream = await getRepoUpstream(repo.path, null) + } catch { + continue + } + const updates: Partial = { upstream: upstream ?? null } + // Only migrate the auto-detected origin avatar; never touch a chosen icon. + if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') { + updates.repoIcon = githubAvatarIcon(upstream) + } + store.updateRepo(repo.id, updates) + changed = true + } + if (changed) { + this.notifier?.reposChanged() + } + } catch { + // Best-effort startup backfill; never disrupt launch. + } + } + async listRepoWorkItems( repoSelector: string, limit?: number, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 4c6db9b58..b9a2b5dca 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -285,6 +285,11 @@ export const GITHUB_METHODS: RpcMethod[] = [ params: RepoSelector, handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo) }), + defineMethod({ + name: 'github.repoUpstream', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoUpstream(params.repo) + }), defineMethod({ name: 'github.rateLimit', params: RateLimit, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index c18bc1c53..319d8b0ff 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -226,6 +226,33 @@ describe('repo RPC methods', () => { }) }) + it('persists resolved GitHub upstream metadata updates', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + upstream: { owner: 'stablyai', repo: 'orca' } + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { upstream: { owner: 'stablyai', repo: 'orca' } } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + upstream: { owner: 'stablyai', repo: 'orca' } + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', upstream: { owner: 'stablyai', repo: 'orca' } } } + }) + }) + it('routes project group mutations to the runtime server', async () => { const group = { id: 'group-1', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 83a2b6061..df95eb4df 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -44,6 +44,14 @@ const RepoBadgeColor = z value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) ) +const RepoUpstream = z + .object({ + owner: z.string().min(1), + repo: z.string().min(1) + }) + .nullable() + .optional() + const RepoUpdate = RepoSelector.extend({ updates: z.object({ displayName: OptionalString, @@ -52,6 +60,7 @@ const RepoUpdate = RepoSelector.extend({ .unknown() .transform((value) => sanitizeRepoIcon(value)) .optional(), + upstream: RepoUpstream, hookSettings: z.unknown().optional(), worktreeBaseRef: OptionalString, worktreeBasePath: OptionalString, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 61943ce93..198fd9c4f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -698,6 +698,7 @@ export type PreloadApi = { | 'displayName' | 'badgeColor' | 'repoIcon' + | 'upstream' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' @@ -953,6 +954,10 @@ export type PreloadApi = { repoPath: string repoId?: string }) => Promise<{ owner: string; repo: string } | null> + repoUpstream: (args: { + repoPath: string + repoId?: string + }) => Promise<{ owner: string; repo: string } | null> prForBranch: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index e48b686ce..0f9fb0a4b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -822,6 +822,9 @@ const api = { repoSlug: (args: { repoPath: string; repoId?: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), + repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoUpstream', args), + prForBranch: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/repo/repo-fork-indicator.tsx b/src/renderer/src/components/repo/repo-fork-indicator.tsx new file mode 100644 index 000000000..896a52177 --- /dev/null +++ b/src/renderer/src/components/repo/repo-fork-indicator.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { GitFork } from 'lucide-react' +import type { GitHubRepositoryIdentity } from '../../../../shared/types' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { cn } from '@/lib/utils' + +/** + * Small muted glyph marking a repo as a fork, with a "Fork of owner/repo" + * tooltip. Renders nothing when the repo has no resolved upstream. + */ +export function RepoForkIndicator({ + upstream, + className +}: { + upstream: GitHubRepositoryIdentity | null | undefined + className?: string +}): React.JSX.Element | null { + if (!upstream) { + return null + } + const label = `Fork of ${upstream.owner}/${upstream.repo}` + return ( + + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 31f059296..92a05619c 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -1,8 +1,12 @@ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Github, Image, Link2, RotateCcw } from 'lucide-react' -import type { Repo } from '../../../../shared/types' -import { faviconUrlFromWebsite, type RepoIcon } from '../../../../shared/repo-icon' +import type { GitHubRepositoryIdentity, Repo } from '../../../../shared/types' +import { + faviconUrlFromWebsite, + githubAvatarIcon, + type RepoIcon +} from '../../../../shared/repo-icon' import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { Button } from '../ui/button' @@ -28,17 +32,21 @@ export function RepositoryIconPicker({ }): React.JSX.Element { const [website, setWebsite] = useState('') const [loadingGitHub, setLoadingGitHub] = useState(false) + const [resetting, setResetting] = useState(false) const mountedRef = useMountedRef() const activeRuntimeEnvironmentId = useAppStore( (state) => state.settings?.activeRuntimeEnvironmentId ?? null ) - const selectedLucideName = - repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : repo.repoIcon == null ? 'Folder' : null + // Why: only highlight a lucide tile when one is explicitly chosen; a null icon + // means "default avatar", not the Folder fallback the glyph happens to render. + const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR const isPresetBadgeColor = REPO_COLORS.some((color) => color === selectedBadgeColor) + // Why: the GitHub avatar is the default icon, so open on the Avatar tab unless + // the repo already uses an explicit emoji or lucide icon. const initialTab = - repo.repoIcon?.type === 'image' ? 'image' : repo.repoIcon?.type === 'emoji' ? 'emoji' : 'icon' + repo.repoIcon?.type === 'emoji' ? 'emoji' : repo.repoIcon?.type === 'lucide' ? 'icon' : 'avatar' const runtimeTarget = useMemo( () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), [activeRuntimeEnvironmentId] @@ -46,15 +54,21 @@ export function RepositoryIconPicker({ const currentIconLabel = useMemo(() => { if (repo.repoIcon?.type === 'image') { + if (repo.repoIcon.source === 'github') { + return 'GitHub avatar' + } return repo.repoIcon.label ?? 'Custom image' } if (repo.repoIcon?.type === 'emoji') { return `${repo.repoIcon.emoji} emoji` } - const label = - REPO_LUCIDE_ICON_OPTIONS.find((option) => option.name === selectedLucideName)?.label ?? - 'Folder' - return `${label} icon with repo color` + if (repo.repoIcon?.type === 'lucide') { + const label = + REPO_LUCIDE_ICON_OPTIONS.find((option) => option.name === selectedLucideName)?.label ?? + 'Folder' + return `${label} icon with repo color` + } + return 'Default' }, [repo.repoIcon, selectedLucideName]) const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon }) @@ -86,35 +100,52 @@ export function RepositoryIconPicker({ setIcon({ type: 'image', src, source: 'favicon', label: 'Website favicon' }) } + // Why: SSH runtime repos only exist remotely, so resolve their git remotes + // through the active runtime instead of the local Electron main process. + const resolveUpstreamLive = useCallback(async (): Promise => { + return runtimeTarget.kind === 'environment' + ? await callRuntimeRpc( + runtimeTarget, + 'github.repoUpstream', + { repo: repo.id }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.repoUpstream({ repoPath: repo.path, repoId: repo.id }) + }, [runtimeTarget, repo.id, repo.path]) + + const resolveGitHubAvatarIcon = async (): Promise => { + // Why: a fork's default avatar is the upstream owner, not the personal fork + // that `origin` points at. Use the stored value when known, else resolve live + // (covers repos added before fork detection existed). + const upstream = + repo.upstream !== undefined ? repo.upstream : await resolveUpstreamLive().catch(() => null) + if (upstream) { + return githubAvatarIcon(upstream) + } + const slug = + runtimeTarget.kind === 'environment' + ? await callRuntimeRpc<{ owner: string; repo: string } | null>( + runtimeTarget, + 'github.repoSlug', + { repo: repo.id }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id }) + return slug ? githubAvatarIcon(slug) : null + } + const handleUseGitHubAvatar = async () => { setLoadingGitHub(true) try { - // Why: SSH runtime repos only exist remotely, so resolve their git remotes - // through the active runtime instead of the local Electron main process. - const slug = - runtimeTarget.kind === 'environment' - ? await callRuntimeRpc<{ owner: string; repo: string } | null>( - runtimeTarget, - 'github.repoSlug', - { repo: repo.id }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id }) - if (!slug) { - if (mountedRef.current) { - toast.error('No GitHub remote found for this repo.') - } - return - } + const icon = await resolveGitHubAvatarIcon() if (!mountedRef.current) { return } - setIcon({ - type: 'image', - src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`, - source: 'github', - label: `${slug.owner}/${slug.repo}` - }) + if (!icon) { + toast.error('No GitHub remote found for this repo.') + return + } + setIcon(icon) } catch { if (mountedRef.current) { toast.error('Failed to resolve the GitHub repo.') @@ -126,6 +157,58 @@ export function RepositoryIconPicker({ } } + // Why: the GitHub avatar is the default repo icon, so Reset restores it when a + // GitHub remote exists and otherwise clears to the Folder fallback (null). + const handleResetToDefault = async () => { + setResetting(true) + try { + const icon = await resolveGitHubAvatarIcon().catch(() => null) + if (!mountedRef.current) { + return + } + setIcon(icon) + } finally { + if (mountedRef.current) { + setResetting(false) + } + } + } + + // Why: repos added before fork detection existed have no stored upstream. + // Resolve it once when their settings open so existing forks pick up the + // upstream avatar and fork badge without a manual reset. + const upstreamBackfilledRef = useRef(null) + useEffect(() => { + // Why: the ref blocks a re-fire during the in-flight window, before the + // stored upstream propagates back through props on the next render. + if (repo.upstream !== undefined || upstreamBackfilledRef.current === repo.id) { + return + } + upstreamBackfilledRef.current = repo.id + let cancelled = false + void (async () => { + let upstream: GitHubRepositoryIdentity | null + try { + upstream = await resolveUpstreamLive() + } catch { + return + } + if (cancelled || !mountedRef.current) { + return + } + const updates: Partial = { upstream: upstream ?? null } + // Only migrate the auto-detected origin avatar; never override an icon the + // user explicitly chose. + if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') { + updates.repoIcon = githubAvatarIcon(upstream) + } + updateRepo(repo.id, updates) + })() + return () => { + cancelled = true + } + }, [repo.id, repo.upstream, repo.repoIcon, resolveUpstreamLive, updateRepo, mountedRef]) + return (
@@ -144,7 +227,8 @@ export function RepositoryIconPicker({ variant="outline" size="sm" className="gap-2" - onClick={() => setIcon(null)} + disabled={resetting} + onClick={() => void handleResetToDefault()} > Reset @@ -188,17 +272,63 @@ export function RepositoryIconPicker({ + + Avatar + Icon Emoji - - Image - + + +

+ Used by default — GitHub always provides one, even when the owner hasn't set a + custom image. +

+ +
+ setWebsite(event.target.value)} + placeholder="example.com" + className="h-9 text-sm" + /> + +
+

PNG uploads must be 256KB or smaller.

+
+
{REPO_LUCIDE_ICON_OPTIONS.map((option) => ( @@ -238,51 +368,6 @@ export function RepositoryIconPicker({ ))} - - -
- - -
-
- setWebsite(event.target.value)} - placeholder="example.com" - className="h-9 text-sm" - /> - -
-

PNG uploads must be 256KB or smaller.

-
) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4ae3c7e2a..3b0b839a9 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -866,7 +866,8 @@ function Settings(): React.JSX.Element { ...section, badgeColor: repo?.badgeColor, isRemote: !!repo?.connectionId, - repoIcon: repo?.repoIcon + repoIcon: repo?.repoIcon, + upstream: repo?.upstream } }) const isSectionMounted = (sectionId: string): boolean => neededSectionIds.has(sectionId) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index f572ffc49..b70868672 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -3,9 +3,11 @@ import { ArrowLeft, Search, Server } from 'lucide-react' import logo from '../../../../../resources/logo.svg' import type { RepoIcon } from '../../../../shared/repo-icon' import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types' +import type { GitHubRepositoryIdentity } from '../../../../shared/types' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { cn } from '@/lib/utils' import { RepoIconGlyph } from '../repo/repo-icon' +import { RepoForkIndicator } from '../repo/repo-fork-indicator' import { Button } from '../ui/button' import { Input } from '../ui/input' import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' @@ -30,6 +32,7 @@ type RepoNavSection = NavSection & { badgeColor?: string isRemote?: boolean repoIcon?: RepoIcon | null + upstream?: GitHubRepositoryIdentity | null } type SettingsSidebarProps = { @@ -273,6 +276,7 @@ export function SettingsSidebar({ iconClassName="size-3.5" /> {section.title} + {section.isRemote && ( diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 3d13edd7b..444b55594 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -20,6 +20,8 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ 'color', 'hex', 'badge', + 'avatar', + 'github', 'emoji', 'favicon' ] diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 9e6abeadd..e597201a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -184,6 +184,7 @@ import { isLegacyRepoForExternalWorktreeVisibility } from '../../../../shared/worktree-ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' import { @@ -2823,6 +2824,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.label}
+ type WebGitHubResult = Awaited> type WebGitHubRouteKey = | 'repoSlug' + | 'repoUpstream' | 'prForBranch' | 'issue' | 'workItem' @@ -209,6 +210,7 @@ type WebGitHubRouteKey = | 'updateIssueTypeBySlug' type WebGitHubRuntimeMethod = | 'github.repoSlug' + | 'github.repoUpstream' | 'github.prForBranch' | 'github.issue' | 'github.workItem' @@ -309,6 +311,7 @@ type WebKeybindingDocument = { export const GITHUB_WEB_RPC_METHODS = { repoSlug: 'github.repoSlug', + repoUpstream: 'github.repoUpstream', prForBranch: 'github.prForBranch', issue: 'github.issue', workItem: 'github.workItem', @@ -1394,6 +1397,8 @@ function createGitHubApi(): WebGitHubApi { const githubApi = { viewer: () => Promise.resolve(null), repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), + repoUpstream: (args) => + route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), prForBranch: (args) => route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), refreshPRNow: async ({ candidate }) => { diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 987de02f7..01ac83c13 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -29,6 +29,17 @@ export function faviconUrlFromWebsite(rawUrl: string): string | null { } } +// Why: the GitHub owner avatar is the default repo icon, built the same way in +// main (auto-detect) and renderer (picker); keep the URL and label in one place. +export function githubAvatarIcon(slug: { owner: string; repo: string }): RepoIcon { + return { + type: 'image', + src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`, + source: 'github', + label: `${slug.owner}/${slug.repo}` + } +} + function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { if (source === 'upload' || source === 'file') { return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) diff --git a/src/shared/types.ts b/src/shared/types.ts index 3496f3c9b..a47571b48 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -83,6 +83,10 @@ export type Repo = { displayName: string badgeColor: string repoIcon?: RepoIcon | null + /** Set when the repo is a fork: the upstream/parent owner/repo. Drives the + * default avatar (upstream owner, not the personal fork) and the fork + * indicator. Absent = not a fork, or fork status not yet resolved. */ + upstream?: GitHubRepositoryIdentity | null addedAt: number kind?: RepoKind gitUsername?: string