Files
apes-sidebar/src/git.ts
T
HuanLinOTO 751fb954a2 fix: address Copilot review (9 comments)
#1 GitView: hide/disable 'open in editor' for external linked worktrees
   (path outside session workspace is rejected by the fence); add
   isWithinWorkspace client helper for the gate.
#2 DiffTab: untracked diff read resolves against diff.repoRoot (child-repo
   paths are repo-root-relative, not cwd-relative); fs.read route threads
   selectedRepoOf into resolveGitPath so the host resolves the right base.
#3 GitView: chooseRepo handler mirrors chooseWorktree — clears status/
   history/log and bumps generation before async refresh, preventing stale
   rows from acting on the newly selected repository.
#4 git.ts: repoRoots/repoRoot compare roots via pathIdentity() (platform-
   aware) instead of raw includes(), fixing win32 forward/backslash
   mismatch that silently fell back to roots[0].
#5 markdown-images: mask fenced code blocks and inline code spans before
   rewriting; only rewrite reference definitions whose label is actually
   referenced by image syntax (not plain links). +3 tests.
#6 package.json: downgrade unrun ^0.3.1 → ^0.2.39 (0.3.x requires Node
   ^22.13, incompatible with declared engines >=20).
#7 market-manifest.spec: shell:true on win32 so execFileSync resolves the
   pnpm .cmd shim.
#8 smoke.spec: build html-route URL via encodeHtmlUrl() (production
   encoder) so the Windows drive separator is correct.
#9 index.ts git.worktrees route: thread selectedRepoOf so a workspace
   container's selected child repo's linked worktrees appear.
2026-08-23 22:02:47 +08:00

387 lines
15 KiB
TypeScript

/**
* Git operations for the sidebar source-control panel. Everything goes
* through the system `git` binary spawned per request (no library, no state),
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
* so parsing never depends on locale or color config. All commands run with
* `-C <cwd>` on the session's working directory and `--no-pager` /
* `-c color.ui=false` so output stays machine-readable.
*
* Commits use the user's git global identity untouched (never sets
* user.name/user.email).
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
path: string
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
xy: string
}
/** The source-control panel snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
/** Selected repository root, or the discovered roots when the cwd is a container. */
root?: string
repositories?: string[]
}
/** One linked checkout returned by `git worktree list --porcelain`. */
export interface GitWorktree {
/** Absolute checkout root. */
path: string
/** Branch name without `refs/heads/`, or `HEAD` when detached. */
branch: string
/** Whether this checkout contains the session cwd. */
current: boolean
/** Number of staged + unstaged status rows (a file changed on both sides counts once). */
changes: number
}
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
date: string
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** One git failure (stderr text as the message). */
export class GitCommandError extends Error {
constructor(
message: string,
readonly code = 'git-error',
readonly command: string,
) {
super(message)
}
}
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
export function parsePorcelainZ(output: string): GitStatusEntry[] {
const tokens = output.split('\0')
const entries: GitStatusEntry[] = []
let index = 0
while (index < tokens.length) {
const token = tokens[index]!
index += 1
if (token === '') continue
const xy = token.slice(0, 2)
const rest = token.slice(3)
entries.push({ path: rest, xy })
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
// new path (the file as it exists now) is the display path.
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
index += 1
}
}
return entries
}
/** One raw porcelain worktree record. Prunable checkouts are retained by
* Git's administrative metadata after their directory disappears and must not
* become selectable command targets. Locked checkouts remain usable. */
export interface GitWorktreeRecord {
path: string
branch: string
locked: boolean
prunable: boolean
}
/** Parse `git worktree list --porcelain` records. Production requests use
* `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
* newline framing remains accepted for small fixtures and older Git output. */
export function parseWorktreeList(output: string): GitWorktreeRecord[] {
const rows: GitWorktreeRecord[] = []
let path: string | undefined
let branch = 'HEAD'
let locked = false
let prunable = false
const flush = (): void => {
if (path !== undefined) rows.push({ path, branch, locked, prunable })
path = undefined
branch = 'HEAD'
locked = false
prunable = false
}
const sep = output.includes('\0') ? '\0' : '\n'
const framed = output.endsWith(sep) ? output : `${output}${sep}`
for (const line of framed.split(sep)) {
if (line === '') {
flush()
} else if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('branch refs/heads/')) {
branch = line.slice('branch refs/heads/'.length)
} else if (line === 'locked' || line.startsWith('locked ')) {
locked = true
} else if (line === 'prunable' || line.startsWith('prunable ')) {
prunable = true
}
}
return rows
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
for (const line of output.split('\n')) {
if (line === '') continue
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({
hash,
subject,
author: author ?? '',
date: date ?? '',
hashFull: hashFull ?? hash,
refs: refs ?? '',
})
}
return rows
}
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
return new Promise<string>((resolvePromise, reject) => {
const child = spawn('git', full, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
child.on('error', (error) => {
clearTimeout(timer)
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
})
child.on('close', (code) => {
clearTimeout(timer)
if (code === 0) {
resolvePromise(stdout)
} else {
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
}
})
})
}
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
export async function isGitRepo(cwd: string): Promise<boolean> {
try {
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
return out.trim() === 'true'
} catch {
return false
}
}
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
async function directRepoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
return out.trim()
}
/** Discover the current repository or direct child repositories. */
export async function repoRoots(cwd: string): Promise<string[]> {
try {
return [await directRepoRoot(cwd)]
} catch {
const entries = await readdir(cwd, { withFileTypes: true }).catch(() => [])
const roots: string[] = []
for (const entry of entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
.sort((left, right) => left.name.localeCompare(right.name))) {
try {
const root = await directRepoRoot(join(cwd, entry.name))
if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
} catch {
// Ordinary child directory; keep discovering sibling repositories.
}
}
return roots
}
}
/** Resolve the selected repository, defaulting to the first discovered root. */
export async function repoRoot(cwd: string, selected?: string): Promise<string> {
const roots = await repoRoots(cwd)
if (roots.length === 0) throw new GitCommandError('not a git repository', 'not-repo', 'rev-parse')
// Git for Windows may return forward-slash roots while callers pass
// backslashes (or vice-versa); compare via the platform-aware identity.
if (selected !== undefined) {
const identity = pathIdentity(selected)
const match = roots.find(root => pathIdentity(root) === identity)
if (match !== undefined) return match
}
return roots[0]!
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
return out.trim()
}
/**
* Working-tree status (untracked included). `--untracked-files=all` lists
* the contents of new directories as individual entries, while preserving
* repository discovery and explicit repository selection for workspace roots.
*/
export async function status(cwd: string, selected?: string): Promise<GitStatusResult> {
const repositories = await repoRoots(cwd)
if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] }
const root = await repoRoot(cwd, selected)
const [branch, raw] = await Promise.all([
currentBranch(root).catch(() => 'HEAD'),
runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw), root, repositories }
}
/** Platform-aware identity used only for comparing absolute checkout roots. */
function pathIdentity(path: string): string {
const absolute = resolve(path).replace(/[\\/]+$/, '')
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
}
/** Raw usable checkout records, shared by inventory and target validation.
* Prunable records point at missing paths and are deliberately excluded from
* both the selector and the command-target allowlist. */
async function listedWorktrees(cwd: string): Promise<GitWorktreeRecord[]> {
const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
return parseWorktreeList(raw).filter(entry => !entry.prunable)
}
/** All linked checkouts of the repository containing `cwd`, enriched with a
* live change count. The current checkout is first so a single-worktree repo
* preserves the old UI ordering. */
export async function worktrees(cwd: string): Promise<GitWorktree[]> {
if (!await isGitRepo(cwd)) return []
const currentRoot = await repoRoot(cwd)
const listed = await listedWorktrees(cwd)
const rows = await Promise.all(listed.map(async (entry): Promise<GitWorktree> => ({
path: entry.path,
branch: entry.branch,
current: pathIdentity(entry.path) === pathIdentity(currentRoot),
// One stale/permission-raced linked checkout must not hide the valid
// current repository from the panel. Targeted operations still fail loud.
changes: await status(entry.path).then(result => result.entries.length, () => 0),
})))
return rows.sort((left, right) => Number(right.current) - Number(left.current))
}
/** Resolve an optional client-selected linked checkout. A caller may never use
* this seam to point Git operations at an unrelated repository: the target
* must occur in the authoritative session repository's worktree list. */
export async function resolveWorktree(cwd: string, requested?: string): Promise<string> {
if (requested === undefined || requested === '') return cwd
const identity = pathIdentity(requested)
const match = (await listedWorktrees(cwd)).find(entry => pathIdentity(entry.path) === identity)
if (match === undefined) {
throw new GitCommandError(`unknown linked worktree: ${requested}`, 'git-worktree', 'worktree list')
}
return match.path
}
/** Diff text of the worktree (unstaged) or the index (staged). */
export async function diff(cwd: string, path: string | undefined, staged: boolean, selected?: string): Promise<string> {
const root = await repoRoot(cwd, selected)
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
if (staged) args.push('--cached')
if (path !== undefined) args.push('--', path)
return runGit(root, args)
}
/** Stage paths (all when path is undefined). */
export async function stage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
}
/** Unstage paths (all when path is undefined). */
export async function unstage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
}
/** Commit the staged changes with a message (global identity untouched). */
export async function commit(cwd: string, message: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['commit', '-m', message])
}
/** Branch names (current first). */
export async function branches(cwd: string, selected?: string): Promise<{ current: string; names: string[] }> {
const root = await repoRoot(cwd, selected)
const [current, raw] = await Promise.all([
currentBranch(root).catch(() => 'HEAD'),
runGit(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
])
const names = raw.split('\n').filter(line => line !== '')
return { current, names: names.includes(current) ? names : [current, ...names] }
}
/** Switch to an existing branch. */
export async function checkout(cwd: string, branch: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['checkout', branch])
}
/** Recent commit history (newest first), lazily pageable via skip/count. */
export async function log(cwd: string, count = 30, skip = 0, selected?: string): Promise<GitLogEntry[]> {
const raw = await runGit(await repoRoot(cwd, selected), [
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
])
return parseLogLines(raw)
}
/**
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
* revision has no such path (a new/untracked file has no HEAD side).
*/
export async function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null> {
try {
return await runGit(await repoRoot(cwd, selected), ['show', `${rev}:${path}`])
} catch {
return null
}
}
/** Full patch text of one commit (`git show` with the commit header suppressed).
* Merge commits show their diff against the first parent (`-m --first-parent`
* is a no-op for regular commits), so a history click always has content. */
export async function commitDiff(cwd: string, hash: string, selected?: string): Promise<string> {
return runGit(await repoRoot(cwd, selected), ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
}
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
export async function discard(cwd: string, path: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['checkout', '--', path])
}
/** Revert one commit onto the current branch with an auto-generated message. */
export async function revert(cwd: string, hash: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['revert', '--no-edit', hash])
}
/** Cherry-pick one commit onto the current branch. */
export async function cherryPick(cwd: string, hash: string, selected?: string): Promise<void> {
await runGit(await repoRoot(cwd, selected), ['cherry-pick', hash])
}