Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 67 additions & 20 deletions GitWeb/src/main/gitProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,25 +443,42 @@ export async function getStatus(cwd: string): Promise<RepoStatus> {
const header = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw
const rest = headerEnd >= 0 ? raw.slice(headerEnd + 1) : ''

const { branch, upstream, ahead, behind } = parseStatusBranchHeader(header)

const mergeHead = await readGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], cwd)
return { entries: parseStatusEntries(rest), branch, ahead, behind, upstream, mergeInProgress: mergeHead.exitCode === 0 }
}

/**
* The `## ` record from `git status -b`. Branch names may contain dots
* (`fix/1.0.0`); the upstream separator is the literal `...`, not the first dot.
* Detached HEAD is `## HEAD (no branch)`. The first space must not become the
* branch name — `HEAD` is not a branch, and publishing it would commit onto
* nothing and then try to push a branch called HEAD.
*/
export function parseStatusBranchHeader(header: string): {
branch: string | null
upstream: string | null
ahead: number
behind: number
} {
let branch: string | null = null
let upstream: string | null = null
let ahead = 0
let behind = 0

// Branch names may contain dots (e.g. fix/1.0.0). The upstream separator is
// the literal "..." sequence, not the first "." in the branch name.
const headerMatch = header.match(/^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?:\s|$)/)
if (headerMatch) {
branch = (headerMatch[1] ?? null)?.trim() || null
upstream = headerMatch[2] ?? null
if (!/^## HEAD \(no branch\)/.test(header)) {
const headerMatch = header.match(/^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?:\s|$)/)
if (headerMatch) {
branch = headerMatch[1]?.trim() || null
upstream = headerMatch[2] ?? null
}
}
const aheadMatch = header.match(/ahead (\d+)/)
const behindMatch = header.match(/behind (\d+)/)
if (aheadMatch) ahead = Number(aheadMatch[1])
if (behindMatch) behind = Number(behindMatch[1])

const mergeHead = await readGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], cwd)
return { entries: parseStatusEntries(rest), branch, ahead, behind, upstream, mergeInProgress: mergeHead.exitCode === 0 }
return {
branch,
upstream,
ahead: aheadMatch ? Number(aheadMatch[1]) : 0,
behind: behindMatch ? Number(behindMatch[1]) : 0
}
}

export async function stageFiles(paths: string[], cwd: string): Promise<void> {
Expand Down Expand Up @@ -655,9 +672,26 @@ export async function getRemoteBranches(cwd: string): Promise<string[]> {
.map(([name]) => name)
}

/** Creates the local branch tracking `origin/<name>` and switches to it. */
/**
* Creates the local branch tracking `remoteBranch` (`origin/feature`) and
* switches to it. `git checkout --track` names the local branch after the
* part following the first slash, so `upstream/main` would collide with an
* existing `main` and be refused. In that case the local branch keeps the
* full remote name (`upstream/main`), which is a different ref. `origin/*`
* keeps the old refusal: a local `feature` already is that branch.
*/
export async function checkoutRemoteBranch(remoteBranch: string, cwd: string): Promise<SwitchBranchResult> {
assertNotOption(remoteBranch, tMain().git.optionLabel.remoteBranch)
const slash = remoteBranch.indexOf('/')
const remoteName = slash === -1 ? '' : remoteBranch.slice(0, slash)
const short = slash === -1 ? remoteBranch : remoteBranch.slice(slash + 1)
const shortTaken = short !== remoteBranch && (await refExists(cwd, `refs/heads/${short}`))
if (shortTaken && remoteName !== 'origin') {
if (await refExists(cwd, `refs/heads/${remoteBranch}`)) return runCheckout(['checkout', remoteBranch], cwd)
// The start point is the full remote-tracking ref, so a local name equal
// to `upstream/main` is not read as the remote ref itself.
return runCheckout(['checkout', '-b', remoteBranch, '--track', `refs/remotes/${remoteBranch}`], cwd)
}
return runCheckout(['checkout', '--track', remoteBranch], cwd)
}

Expand Down Expand Up @@ -1184,14 +1218,24 @@ export async function getBranchIntegration(
})
const localNames = new Set(refs.filter((r) => r.ref.startsWith('refs/heads/')).map((r) => r.name))

const localTipByName = new Map(refs.filter((r) => r.ref.startsWith('refs/heads/')).map((r) => [r.name, r.tip]))
const candidates = refs.filter((r) => {
if (onlyLocal !== undefined) return r.ref === `refs/heads/${onlyLocal}`
if (r.symref || r.ref === baseRef.ref) return false
if (r.ref.startsWith('refs/heads/')) return r.name !== baseShort
// Remote: skip the remote's copy of the base and anything already covered
// by a local branch of the same name.
const branchName = r.name.slice(r.name.indexOf('/') + 1)
return branchName !== baseShort && !localNames.has(branchName)
// Remote: `origin/feature` is the local `feature` looked at from the other
// side, so it is not a second branch. `upstream/feature` is, unless it
// points at the same commit — a fork's main can be ahead of origin/main
// while sharing the short name.
const slash = r.name.indexOf('/')
const remoteName = slash === -1 ? '' : r.name.slice(0, slash)
const branchName = slash === -1 ? r.name : r.name.slice(slash + 1)
if (localNames.has(r.name)) return false
if (refs.some((local) => local.ref.startsWith('refs/heads/') && local.upstream === r.name)) return false
if (remoteName === 'origin' && (branchName === baseShort || localNames.has(branchName))) return false
const localTip = localTipByName.get(branchName)
if (localTip && localTip === r.tip) return false
return true
})

// Several branches usually fork from the same base commit — scan it once.
Expand Down Expand Up @@ -1665,7 +1709,10 @@ async function resolveRealWithinRepo(cwd: string, relativePath: string): Promise
}

export async function readRepoFile(cwd: string, relativePath: string): Promise<string> {
return readFile(resolveWithinRepo(cwd, relativePath), 'utf-8')
// Same real-path check as writeRepoFile. A lexical join follows a symlink
// (`notes.txt` → `~/.ssh/id_rsa`) out of the repo, and this reader feeds the
// diff, conflict resolution and the fix proposal that goes to the model.
return readFile(await resolveRealWithinRepo(cwd, relativePath), 'utf-8')
}

// --- Files the AI agent is allowed to see -----------------------------------
Expand Down
67 changes: 44 additions & 23 deletions GitWeb/src/main/githubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,25 @@ async function send<T>(
return { data: (await response.json()) as T, next }
}

/** GraphQL answers 200 even on failure, with the reasons in `errors`. */
async function graphql<T>(host: string, token: string, query: string, variables: Record<string, unknown>): Promise<T> {
/**
* GraphQL answers 200 even on failure, with the reasons in `errors`.
* `partial` keeps a payload that arrived next to those errors. Stars need it:
* one repository the token cannot see comes back as a null node plus an error,
* and throwing there used to drop every star and fail the whole inbox refresh.
* Callers that require a complete answer leave it off.
*/
async function graphql<T>(
host: string,
token: string,
query: string,
variables: Record<string, unknown>,
partial = false
): Promise<T> {
const { data } = await send<{ data?: T; errors?: { message: string }[] }>(graphqlUrlForHost(host), token, {
method: 'POST',
body: JSON.stringify({ query, variables })
})
if (data.errors?.length || !data.data) {
if (!data.data || (data.errors?.length && !partial)) {
throw new GitHubApiError(data.errors?.map((e) => e.message).join(' — ') || tMain().github.requestRejected(200), 200)
}
return data.data
Expand Down Expand Up @@ -954,35 +966,44 @@ const RECENT_STARS_LIMIT = 20
* themselves: the latest few of each is enough to catch up between checks.
* Costs a single GraphQL request however many repositories there are.
*/
interface ApiStarRepository {
nameWithOwner?: string
stargazers?: {
edges?: ({ starredAt?: string; node?: { login?: string; avatarUrl?: string; url?: string } | null } | null)[] | null
} | null
}

export function getRecentStars(host: string): Promise<RepositoryStar[] | null> {
return whenSignedIn(host, async (token) => {
const { viewer } = await graphql<{
viewer: {
login: string
repositories: {
nodes: {
nameWithOwner: string
stargazers: { edges: { starredAt: string; node: { login: string; avatarUrl: string; url: string } }[] }
}[]
}
}
viewer: { login?: string; repositories?: { nodes?: (ApiStarRepository | null)[] | null } | null } | null
}>(
host,
token,
'query { viewer { login repositories(first: 100, ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], orderBy: { field: PUSHED_AT, direction: DESC }) { nodes { nameWithOwner stargazers(first: 5, orderBy: { field: STARRED_AT, direction: DESC }) { edges { starredAt node { login avatarUrl url } } } } } } }',
{}
{},
true
)
if (!viewer?.login) throw new GitHubApiError(tMain().github.requestRejected(200), 200)
const since = Date.now() - RECENT_STARS_DAYS * 24 * 60 * 60 * 1000
return viewer.repositories.nodes
.flatMap((repository) =>
repository.stargazers.edges.map((edge) => ({
repoFullName: repository.nameWithOwner,
login: edge.node.login,
avatarUrl: edge.node.avatarUrl,
profileUrl: edge.node.url,
starredAt: edge.starredAt
}))
)
return (viewer.repositories?.nodes ?? [])
.flatMap((repository) => {
const repoFullName = repository?.nameWithOwner
if (!repoFullName) return []
return (repository.stargazers?.edges ?? []).flatMap((edge) => {
const login = edge?.node?.login
if (!edge?.starredAt || !login) return []
return [
{
repoFullName,
login,
avatarUrl: edge.node?.avatarUrl ?? '',
profileUrl: edge.node?.url ?? '',
starredAt: edge.starredAt
}
]
})
})
.filter((star) => star.login !== viewer.login && Date.parse(star.starredAt) >= since)
.sort((a, b) => Date.parse(b.starredAt) - Date.parse(a.starredAt))
.slice(0, RECENT_STARS_LIMIT)
Expand Down
8 changes: 6 additions & 2 deletions GitWeb/src/renderer/src/store/inboxStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,18 @@ export const useInboxStore = create<InboxStoreState>((set, get) => {
if (generation !== loadGeneration) return
const signedOut = invitations.status === 'fulfilled' && invitations.value === null
const failure = [invitations, notifications, stars].find((result) => result.status === 'rejected')
// A star query that GitHub only half answers must not freeze "last
// checked". The timestamp moves when anything came back; it stays put
// only when every source failed, so an offline startup retries from the
// same clock. The error for the source that failed is still shown.
const delivered = [invitations, notifications, stars].some((result) => result.status === 'fulfilled')
set((state) => ({
invitations: invitations.status === 'fulfilled' ? (invitations.value ?? []) : state.invitations,
notifications: signedOut ? [] : notifications.status === 'fulfilled' ? (notifications.value ?? []) : state.notifications,
stars: signedOut ? [] : stars.status === 'fulfilled' ? (stars.value ?? []) : state.stars,
signedIn: invitations.status === 'fulfilled' ? !signedOut : state.signedIn,
// Offline at startup, say: what was on screen stays, and the next check tries again.
error: failure ? describeError(failure.reason) : null,
checkedAt: failure ? state.checkedAt : Date.now(),
checkedAt: delivered ? Date.now() : state.checkedAt,
isLoading: false
}))
announce(get())
Expand Down
41 changes: 31 additions & 10 deletions GitWeb/src/renderer/src/store/repoStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { PublishRepositoryInput, PublishRepositoryResult } from '../../../shared/github'
import type { ReorganizeResult, RepoMove } from '../../../shared/repoOrganize'
import { isConflicted, isHistoryRewrittenMessage, sameRepoStatus, stashBranchName } from '../../../shared/git'
import { isConflicted, isHistoryRewrittenMessage, remoteBranchesToOffer, sameRepoStatus, stashBranchName } from '../../../shared/git'
import type {
BranchInfo,
CommitFile,
Expand Down Expand Up @@ -249,6 +249,11 @@ let repoOpenGeneration = 0
// last one applied may land.
let statusRequestSeq = 0
let statusAppliedSeq = 0
// A diff or commit-file read started for an earlier click can resolve after
// the next one. Only the latest click may write the pane.
let selectFileSeq = 0
let selectCommitSeq = 0
let selectCommitFileSeq = 0

export const useRepoStore = create<RepoState>((set, get) => ({
currentRepoPath: null,
Expand Down Expand Up @@ -608,7 +613,7 @@ export const useRepoStore = create<RepoState>((set, get) => ({
])
if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return
const local = new Set(branches.map((b) => b.name))
set({ branches, remoteBranches: remote.filter((r) => !local.has(r.slice(r.indexOf('/') + 1))) })
set({ branches, remoteBranches: remoteBranchesToOffer(remote, branches.map((b) => b.name)) })
} catch (err) {
if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return
set({ error: describeError(err) })
Expand Down Expand Up @@ -660,15 +665,27 @@ export const useRepoStore = create<RepoState>((set, get) => ({
if (!currentRepoPath) return
const repoPath = currentRepoPath
const generation = repoOpenGeneration
const request = ++selectFileSeq

const entry = status?.entries.find((e) => e.path === path)
const conflicted = entry ? isConflicted(entry) : false
set({ selectedFile: { path, staged, conflicted }, diffText: '', conflictContent: null })

const stillCurrent = (): boolean => {
const selected = get().selectedFile
return (
request === selectFileSeq &&
generation === repoOpenGeneration &&
get().currentRepoPath === repoPath &&
selected?.path === path &&
selected.staged === staged
)
}

try {
if (conflicted) {
const content = await window.api.fs.readRepoFile(repoPath, path)
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (!stillCurrent()) return
set({ conflictContent: content })
return
}
Expand All @@ -677,6 +694,7 @@ export const useRepoStore = create<RepoState>((set, get) => ({
// "/" (it doesn't recurse into it) — that's not a file, so reading it
// would throw EISDIR.
if (path.endsWith('/')) {
if (!stillCurrent()) return
set({ diffText: getT().stores.untrackedFolder })
return
}
Expand All @@ -685,16 +703,16 @@ export const useRepoStore = create<RepoState>((set, get) => ({
if (isNewUntracked) {
// `git diff` shows nothing for untracked files; read the raw content instead.
const content = await window.api.fs.readRepoFile(repoPath, path)
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (!stillCurrent()) return
const preview = content.length > 200_000 ? `${content.slice(0, 200_000)}\n${getT().stores.fileTruncated}` : content
set({ diffText: preview || getT().stores.emptyNewFile })
} else {
const diffText = await window.api.git.diff(path, repoPath, staged)
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (!stillCurrent()) return
set({ diffText })
}
} catch (err) {
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (!stillCurrent()) return
set({ error: describeError(err) })
}
},
Expand Down Expand Up @@ -1346,17 +1364,18 @@ export const useRepoStore = create<RepoState>((set, get) => ({
if (!currentRepoPath) return
const path = currentRepoPath
const generation = repoOpenGeneration
const request = ++selectCommitSeq
try {
const files = await window.api.git.commitFiles(hash, path)
if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return
if (request !== selectCommitSeq || generation !== repoOpenGeneration || get().currentRepoPath !== path) return
set({ selectedCommit: { hash, files, selectedPath: null }, commitDiffText: '' })
// Open straight on the first file, as a PR does, instead of an empty
// pane asking for a click. With a file filter active, that file.
const logPath = get().logPath
const first = files.find((f) => logPath && f.path.split('\t').pop() === logPath) ?? files[0]
if (first) void get().selectCommitFile(first.path)
} catch (err) {
if (generation !== repoOpenGeneration || get().currentRepoPath !== path) return
if (request !== selectCommitSeq || generation !== repoOpenGeneration || get().currentRepoPath !== path) return
set({ error: describeError(err) })
}
},
Expand All @@ -1371,14 +1390,16 @@ export const useRepoStore = create<RepoState>((set, get) => ({
const repoPath = currentRepoPath
const generation = repoOpenGeneration
const commitHash = selectedCommit.hash
const request = ++selectCommitFileSeq
set({ selectedCommit: { ...selectedCommit, selectedPath: path }, commitDiffText: '' })
try {
const commitDiffText = await window.api.git.commitDiff(commitHash, path, repoPath)
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (request !== selectCommitFileSeq || generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
const current = get().selectedCommit
if (!current || current.hash !== commitHash) return
set({ selectedCommit: { ...current, selectedPath: path }, commitDiffText })
} catch (err) {
if (generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
if (request !== selectCommitFileSeq || generation !== repoOpenGeneration || get().currentRepoPath !== repoPath) return
set({ error: describeError(err) })
}
},
Expand Down
18 changes: 18 additions & 0 deletions GitWeb/src/shared/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,24 @@ export function isHistoryRewrittenMessage(message: string): boolean {
* local name (main tracking origin/master) or even live on origin (a fork's
* remote); with none yet, the app publishes the branch to origin as is.
*/
/**
* Remote-tracking names worth offering for checkout. `origin/feature` is hidden
* once a local `feature` exists — that checkout would only recreate it.
* `upstream/feature` stays: a local `feature` does not contain the other
* remote's commits. A local branch already named `upstream/feature` covers it.
*/
export function remoteBranchesToOffer(remoteBranches: readonly string[], localNames: readonly string[]): string[] {
const local = new Set(localNames)
return remoteBranches.filter((remote) => {
if (local.has(remote)) return false
const slash = remote.indexOf('/')
if (slash <= 0) return true
const remoteName = remote.slice(0, slash)
const short = remote.slice(slash + 1)
return !(remoteName === 'origin' && local.has(short))
})
}

export function pushTargetBranch(status: Pick<RepoStatus, 'branch' | 'upstream'>): string | null {
if (!status.branch) return null
if (!status.upstream) return status.branch
Expand Down
Loading
Loading