import { GIT_HISTORY_COMMIT_FORMAT, gitHistoryRefFromFullName, parseGitHistoryLog, shortGitHash } from './git-history-log-parser' import { GIT_HISTORY_DEFAULT_LIMIT, GIT_HISTORY_MAX_LIMIT, type GitHistoryExecutor, type GitHistoryItemRef, type GitHistoryOptions, type GitHistoryResult } from './git-history-types' export type { GitHistoryExecutor, GitHistoryGraphColorId, GitHistoryItem, GitHistoryItemRef, GitHistoryItemStatistics, GitHistoryOptions, GitHistoryRefCategory, GitHistoryResult } from './git-history-types' export { GIT_HISTORY_BASE_REF_COLOR, GIT_HISTORY_DEFAULT_LIMIT, GIT_HISTORY_LANE_COLORS, GIT_HISTORY_MAX_LIMIT, GIT_HISTORY_REF_COLOR, GIT_HISTORY_REMOTE_REF_COLOR } from './git-history-types' export { compareGitHistoryItemRefsByCategory, parseGitHistoryLog } from './git-history-log-parser' function clampHistoryLimit(limit: number | undefined): number { if (!Number.isFinite(limit)) { return GIT_HISTORY_DEFAULT_LIMIT } return Math.min( GIT_HISTORY_MAX_LIMIT, Math.max(1, Math.trunc(limit ?? GIT_HISTORY_DEFAULT_LIMIT)) ) } async function resolveCommit( git: GitHistoryExecutor, cwd: string, ref: string ): Promise { if (!ref || ref.startsWith('-')) { return null } try { const { stdout } = await git( ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], cwd ) const oid = stdout.trim() return oid || null } catch { return null } } async function resolveSymbolicFullName( git: GitHistoryExecutor, cwd: string, ref: string ): Promise { if (!ref || ref.startsWith('-')) { return null } try { const { stdout } = await git( ['rev-parse', '--symbolic-full-name', '--end-of-options', ref], cwd ) // Why skip the marker: --verify swallows --end-of-options, but // --symbolic-full-name deliberately echoes it, so the first line is the marker // rather than the ref. Taking it made every branch and tag fall through // gitHistoryRefFromFullName's prefix checks into the "commits" category. return ( stdout .trim() .split(/\r?\n/) .find((line) => line && line !== '--end-of-options') ?? null ) } catch { return null } } async function resolveCurrentRef( git: GitHistoryExecutor, cwd: string, headOid: string ): Promise<{ currentRef: GitHistoryItemRef; branchName: string | null }> { try { const { stdout } = await git(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd) const branchName = stdout.trim() if (branchName) { return { branchName, currentRef: { id: `refs/heads/${branchName}`, name: branchName, revision: headOid, category: 'branches' } } } } catch { // Detached HEAD. } return { branchName: null, currentRef: { id: headOid, name: shortGitHash(headOid), revision: headOid, category: 'commits' } } } async function resolveUpstreamRef( git: GitHistoryExecutor, cwd: string, branchName: string | null ): Promise { if (!branchName) { return undefined } try { const { stdout } = await git( ['for-each-ref', '--format=%(upstream)%00%(upstream:short)', `refs/heads/${branchName}`], cwd ) const [fullName, shortName] = stdout.split('\0') const upstreamRef = fullName?.trim() const upstreamShortName = shortName?.trim() if (!upstreamRef || !upstreamShortName) { return undefined } // Why: %(upstream:objectname) is not portable across Git versions; resolve // the upstream name first, then ask rev-parse for the commit object. const oid = await resolveCommit(git, cwd, upstreamRef) return oid ? gitHistoryRefFromFullName(upstreamRef, upstreamShortName, oid) : undefined } catch { return undefined } } async function resolveNamedRef( git: GitHistoryExecutor, cwd: string, ref: string | null | undefined ): Promise { const normalized = ref?.trim() if (!normalized || normalized.startsWith('-')) { return undefined } const [revision, fullName] = await Promise.all([ resolveCommit(git, cwd, normalized), resolveSymbolicFullName(git, cwd, normalized) ]) return revision ? gitHistoryRefFromFullName(fullName, normalized, revision) : undefined } export async function loadGitHistoryFromExecutor( git: GitHistoryExecutor, cwd: string, options: GitHistoryOptions = {} ): Promise { const limit = clampHistoryLimit(options.limit) const headOid = await resolveCommit(git, cwd, 'HEAD') if (!headOid) { return { items: [], hasIncomingChanges: false, hasOutgoingChanges: false, hasMore: false, limit } } const { currentRef, branchName } = await resolveCurrentRef(git, cwd, headOid) const [remoteRef, rawBaseRef] = await Promise.all([ resolveUpstreamRef(git, cwd, branchName), resolveNamedRef(git, cwd, options.baseRef) ]) const baseRef = rawBaseRef && rawBaseRef.id !== remoteRef?.id && rawBaseRef.id !== currentRef.id ? rawBaseRef : undefined // Why: this panel is scoped to the active workspace. Upstream and base refs // stay as comparison metadata so old workspaces do not list newly fetched upstream/base commits. const historyRevisions = [headOid] let mergeBase: string | undefined if (remoteRef?.revision && currentRef.revision && remoteRef.revision !== currentRef.revision) { try { const { stdout } = await git(['merge-base', currentRef.revision, remoteRef.revision], cwd) mergeBase = stdout.trim() || undefined } catch { mergeBase = undefined } } const { stdout } = await git( [ 'log', `--format=${GIT_HISTORY_COMMIT_FORMAT}`, '-z', '--topo-order', '--decorate=full', `-n${limit + 1}`, ...historyRevisions ], cwd ) const parsed = parseGitHistoryLog(stdout) const items = parsed.slice(0, limit) const hasIncomingChanges = Boolean(remoteRef?.revision && mergeBase) && remoteRef?.revision !== mergeBase const hasOutgoingChanges = Boolean(currentRef.revision && remoteRef?.revision && mergeBase) && currentRef.revision !== mergeBase return { items, currentRef, remoteRef, baseRef, mergeBase, hasIncomingChanges, hasOutgoingChanges, hasMore: parsed.length > limit, limit } }