232 lines
6.0 KiB
TypeScript
232 lines
6.0 KiB
TypeScript
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<string | null> {
|
|
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<string | null> {
|
|
if (!ref || ref.startsWith('-')) {
|
|
return null
|
|
}
|
|
try {
|
|
const { stdout } = await git(
|
|
['rev-parse', '--symbolic-full-name', '--end-of-options', ref],
|
|
cwd
|
|
)
|
|
return stdout.trim().split(/\r?\n/).find(Boolean) ?? 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<GitHistoryItemRef | undefined> {
|
|
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<GitHistoryItemRef | undefined> {
|
|
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<GitHistoryResult> {
|
|
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
|
|
}
|
|
}
|