Default repo avatars to the GitHub upstream owner and flag forks (#4522)

* Default repo avatars to the GitHub upstream owner and flag forks

Make the GitHub owner avatar the default repo icon and surface it as the
primary choice in the icon picker (the "Image" tab becomes "Avatar",
moves first, and opens by default). For forks, resolve the upstream/
parent owner so the avatar reflects the source repo instead of the
personal fork, and show a fork indicator (GitFork glyph + "Fork of
owner/repo" tooltip) next to the repo in the sidebar and settings.

Fork detection prefers the offline `upstream` remote, falling back to a
`gh repo view --json isFork,parent` lookup; the resolved upstream is
stored on the repo. Existing repos self-correct via a one-time startup
backfill (local repos) and a lazy backfill when their icon settings open
(SSH repos); new repos resolve at add-time. Reset now restores the
default (re-detecting the upstream) instead of clearing to the Folder
icon.

* Harden repo upstream avatar handling

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Trevin Chow 2026-06-02 20:55:35 -07:00 committed by GitHub
parent da3c359a74
commit abfe9bd329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 577 additions and 116 deletions

View File

@ -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: [

View File

@ -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<OwnerRepo | null> {
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()

View File

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

View File

@ -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',

View File

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

View File

@ -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<Pick<Repo, 'badgeColor' | 'repoIcon' | 'worktreeBasePath'>>
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>>
>(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: {

View File

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

View File

@ -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<RepoIcon | null> {
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<RepoIcon | undefined> {
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 } : {})
}
}

View File

@ -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<string, Set<TerminalWaiter>>()
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<void> {
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<Repo> = { 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,

View File

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

View File

@ -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',

View File

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

View File

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

View File

@ -822,6 +822,9 @@ const api = {
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:repoSlug', args),
repoUpstream: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:repoUpstream', args),
prForBranch: (args: {
repoPath: string
repoId?: string

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn('inline-flex shrink-0 items-center text-muted-foreground', className)}
aria-label={label}
>
<GitFork className="size-3" aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}

View File

@ -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<GitHubRepositoryIdentity | null> => {
return runtimeTarget.kind === 'environment'
? await callRuntimeRpc<GitHubRepositoryIdentity | null>(
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<RepoIcon | null> => {
// 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<string | null>(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<Repo> = { 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 (
<div className="space-y-3">
<div className="flex items-center gap-3">
@ -144,7 +227,8 @@ export function RepositoryIconPicker({
variant="outline"
size="sm"
className="gap-2"
onClick={() => setIcon(null)}
disabled={resetting}
onClick={() => void handleResetToDefault()}
>
<RotateCcw className="size-3.5" />
Reset
@ -188,17 +272,63 @@ export function RepositoryIconPicker({
<Tabs defaultValue={initialTab} className="gap-3">
<TabsList variant="line" className="h-8">
<TabsTrigger value="avatar" className="h-7 text-xs">
Avatar
</TabsTrigger>
<TabsTrigger value="icon" className="h-7 text-xs">
Icon
</TabsTrigger>
<TabsTrigger value="emoji" className="h-7 text-xs">
Emoji
</TabsTrigger>
<TabsTrigger value="image" className="h-7 text-xs">
Image
</TabsTrigger>
</TabsList>
<TabsContent value="avatar" className="space-y-3">
<Button
type="button"
variant="default"
className="w-full gap-2"
disabled={loadingGitHub}
onClick={() => void handleUseGitHubAvatar()}
>
<Github className="size-3.5" />
Use GitHub Avatar
</Button>
<p className="text-xs text-muted-foreground">
Used by default GitHub always provides one, even when the owner hasn&apos;t set a
custom image.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={handleUploadImage}
>
<Image className="size-3.5" />
Upload PNG
</Button>
<div className="flex gap-2">
<Input
value={website}
onChange={(event) => setWebsite(event.target.value)}
placeholder="example.com"
className="h-9 text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={handleUseWebsiteFavicon}
>
<Link2 className="size-3.5" />
Favicon
</Button>
</div>
<p className="text-xs text-muted-foreground">PNG uploads must be 256KB or smaller.</p>
</TabsContent>
<TabsContent value="icon" className="space-y-3">
<div className="grid grid-cols-10 gap-1.5">
{REPO_LUCIDE_ICON_OPTIONS.map((option) => (
@ -238,51 +368,6 @@ export function RepositoryIconPicker({
</Button>
))}
</TabsContent>
<TabsContent value="image" className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={handleUploadImage}
>
<Image className="size-3.5" />
Upload PNG
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={loadingGitHub}
onClick={() => void handleUseGitHubAvatar()}
>
<Github className="size-3.5" />
GitHub Avatar
</Button>
</div>
<div className="flex gap-2">
<Input
value={website}
onChange={(event) => setWebsite(event.target.value)}
placeholder="example.com"
className="h-9 text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={handleUseWebsiteFavicon}
>
<Link2 className="size-3.5" />
Favicon
</Button>
</div>
<p className="text-xs text-muted-foreground">PNG uploads must be 256KB or smaller.</p>
</TabsContent>
</Tabs>
</div>
)

View File

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

View File

@ -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"
/>
<span className="truncate">{section.title}</span>
<RepoForkIndicator upstream={section.upstream} />
{section.isRemote && (
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Server className="size-3" />

View File

@ -20,6 +20,8 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
'color',
'hex',
'badge',
'avatar',
'github',
'emoji',
'favicon'
]

View File

@ -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
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
<RepoForkIndicator upstream={row.repo?.upstream} />
<SectionMetricsBadge
count={row.count}
summary={sectionActivity}

View File

@ -28,6 +28,7 @@ type RepoUpdate = Partial<
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'upstream'
| 'hookSettings'
| 'worktreeBaseRef'
| 'worktreeBasePath'

View File

@ -162,6 +162,7 @@ type WebGitHubApi = NonNullable<PreloadApi['gh']>
type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
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<WebGitHubResult<'repoSlug'>>(GITHUB_WEB_RPC_METHODS.repoSlug, args),
repoUpstream: (args) =>
route<WebGitHubResult<'repoUpstream'>>(GITHUB_WEB_RPC_METHODS.repoUpstream, args),
prForBranch: (args) =>
route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, args),
refreshPRNow: async ({ candidate }) => {

View File

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

View File

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