328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { readFile, rm } from 'node:fs/promises'
|
|
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { spawn } from 'node:child_process'
|
|
|
|
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
|
|
/** Map `fn` over `items` with at most `limit` running at once, preserving order.
|
|
* Keeps the per-file git/disk reads overlapping without spawning hundreds of
|
|
* subprocesses at once (macOS has a low default open-file limit). */
|
|
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
const out = new Array<R>(items.length)
|
|
let next = 0
|
|
const worker = async (): Promise<void> => {
|
|
while (next < items.length) {
|
|
const i = next++
|
|
out[i] = await fn(items[i], i)
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
return out
|
|
}
|
|
|
|
export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
|
|
|
|
export interface GitChange {
|
|
path: string
|
|
status: GitStatusLetter
|
|
staged: boolean
|
|
original: string
|
|
updated: string
|
|
}
|
|
|
|
export interface GitLoad {
|
|
branch: string
|
|
changes: GitChange[]
|
|
}
|
|
|
|
interface GitResult {
|
|
code: number
|
|
stdout: string
|
|
stderr: string
|
|
}
|
|
|
|
/**
|
|
* Run a git subcommand directly via child_process.
|
|
*
|
|
* We deliberately spawn `git` ourselves instead of going through simple-git:
|
|
* inside Electron's main process simple-git's spawn (which wires up a stdin
|
|
* pipe) trips `spawn EBADF` on macOS. Forcing stdin to 'ignore' — the child
|
|
* never reads input — sidesteps the bad-descriptor crash entirely.
|
|
*/
|
|
function runGit(root: string, args: string[]): Promise<GitResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('git', args, {
|
|
cwd: root,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: process.env,
|
|
windowsHide: true,
|
|
})
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', (d: string) => { stdout += d })
|
|
child.stderr.on('data', (d: string) => { stderr += d })
|
|
child.on('error', reject)
|
|
child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr }))
|
|
})
|
|
}
|
|
|
|
/** Run git, rejecting on a non-zero exit. */
|
|
async function git(root: string, args: string[]): Promise<string> {
|
|
const { code, stdout, stderr } = await runGit(root, args)
|
|
if (code !== 0) throw new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`)
|
|
return stdout
|
|
}
|
|
|
|
/** Map one porcelain status code to our display letter. */
|
|
function letterFor(code: string): GitStatusLetter {
|
|
switch (code) {
|
|
case 'A': case 'C': case '?': return 'A'
|
|
case 'D': return 'D'
|
|
case 'R': return 'R'
|
|
case 'U': return 'M'
|
|
default: return 'M'
|
|
}
|
|
}
|
|
|
|
/** A merge conflict. Git reports both sides, but neither half can be staged on
|
|
* its own, so a conflict stays one row. */
|
|
function isConflict(index: string, working: string): boolean {
|
|
return index === 'U' || working === 'U'
|
|
|| (index === 'A' && working === 'A')
|
|
|| (index === 'D' && working === 'D')
|
|
}
|
|
|
|
export interface GitRowSpec { letter: GitStatusLetter; staged: boolean }
|
|
|
|
/**
|
|
* Split a porcelain code pair into the rows the git panel shows.
|
|
*
|
|
* A file can be staged AND changed again on disk. Git reports that as "MM".
|
|
* That is two rows: one staged (HEAD vs index) and one unstaged (index vs
|
|
* disk). Folding it into a single row hid the newer edit completely.
|
|
*/
|
|
export function classify(index: string, working: string): GitRowSpec[] {
|
|
if (isConflict(index, working)) return [{ letter: 'M', staged: true }]
|
|
const rows: GitRowSpec[] = []
|
|
if (index !== ' ' && index !== '?') rows.push({ letter: letterFor(index), staged: true })
|
|
if (working !== ' ') rows.push({ letter: letterFor(working), staged: false })
|
|
// Should not happen (git does not report a clean file), but never drop an entry.
|
|
return rows.length ? rows : [{ letter: letterFor(index), staged: true }]
|
|
}
|
|
|
|
/** Parse a `## ...` porcelain branch header into a display branch name. */
|
|
function parseBranch(header: string): string {
|
|
// e.g. "main...origin/main [ahead 1]", "main", "No commits yet on main",
|
|
// "HEAD (no branch)".
|
|
const noCommits = header.match(/^No commits yet on (.+)$/)
|
|
if (noCommits) return noCommits[1].trim()
|
|
if (header.startsWith('HEAD ')) return 'HEAD'
|
|
const upstream = header.indexOf('...')
|
|
const head = upstream >= 0 ? header.slice(0, upstream) : header
|
|
return head.split(' ')[0].trim() || 'HEAD'
|
|
}
|
|
|
|
interface StatusEntry { index: string; working: string; path: string }
|
|
|
|
interface ParsedStatus { branch: string; files: StatusEntry[] }
|
|
|
|
/** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */
|
|
export function parseStatus(raw: string): ParsedStatus {
|
|
const parts = raw.split('\0')
|
|
let branch = 'HEAD'
|
|
const files: StatusEntry[] = []
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const p = parts[i]
|
|
if (!p) continue
|
|
if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue }
|
|
const index = p[0]
|
|
const working = p[1]
|
|
const path = p.slice(3) // skip "XY "
|
|
// For renames/copies the original path follows as its own NUL field; the
|
|
// destination (this entry's path) is what we display, so just skip it.
|
|
if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++
|
|
files.push({ index, working, path })
|
|
}
|
|
return { branch, files }
|
|
}
|
|
|
|
async function headText(root: string, path: string): Promise<string> {
|
|
try {
|
|
return await git(root, ['show', `HEAD:${path}`])
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** The staged copy of a file: the blob sitting in the index. */
|
|
async function indexText(root: string, path: string): Promise<string> {
|
|
try {
|
|
return await git(root, ['show', `:${path}`])
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
async function diskText(root: string, path: string): Promise<string> {
|
|
try {
|
|
const buf = await readFile(join(root, path))
|
|
// skip obvious binaries
|
|
for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return ''
|
|
return buf.toString('utf8')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export async function isRepo(root: string): Promise<boolean> {
|
|
try {
|
|
const { code, stdout } = await runGit(root, ['rev-parse', '--is-inside-work-tree'])
|
|
if (code === 0 && stdout.trim() === 'true') return true
|
|
} catch {
|
|
/* git missing / transient — fall back to disk. */
|
|
}
|
|
// An external branch switch briefly rewrites .git; trust its presence on
|
|
// disk rather than blanking the whole panel on a momentary probe failure.
|
|
return existsSync(join(root, '.git'))
|
|
}
|
|
|
|
// Coalesce overlapping loads per root: a branch switch or rapid edits fire
|
|
// several watcher pings, each triggering a reload. We keep at most one read in
|
|
// flight plus one trailing read (which captures whatever changed during the
|
|
// first), so the panel always settles on fresh state without a spawn pile-up.
|
|
const loadInFlight = new Map<string, Promise<GitLoad | null>>()
|
|
const loadPending = new Map<string, Promise<GitLoad | null>>()
|
|
|
|
export function load(root: string): Promise<GitLoad | null> {
|
|
const running = loadInFlight.get(root)
|
|
if (running) {
|
|
let pending = loadPending.get(root)
|
|
if (!pending) {
|
|
pending = running.catch(() => {}).then(() => {
|
|
loadPending.delete(root)
|
|
return startLoad(root)
|
|
})
|
|
loadPending.set(root, pending)
|
|
}
|
|
return pending
|
|
}
|
|
return startLoad(root)
|
|
}
|
|
|
|
function startLoad(root: string): Promise<GitLoad | null> {
|
|
const p = doLoad(root).finally(() => {
|
|
if (loadInFlight.get(root) === p) loadInFlight.delete(root)
|
|
})
|
|
loadInFlight.set(root, p)
|
|
return p
|
|
}
|
|
|
|
async function doLoad(root: string): Promise<GitLoad | null> {
|
|
if (!(await isRepo(root))) return null
|
|
|
|
// A branch switch / checkout from an external tool (Sublime Merge, the CLI)
|
|
// rewrites .git and briefly holds .git/index.lock; a status that lands in
|
|
// that window fails. Retry so we settle on the real new state instead of
|
|
// rejecting (which would leave the git column stale or blank).
|
|
let raw: string | null = null
|
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
try {
|
|
raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z'])
|
|
break
|
|
} catch (err) {
|
|
if (attempt === 5) throw err
|
|
await delay(120)
|
|
}
|
|
}
|
|
if (raw == null) return null
|
|
|
|
const { branch, files } = parseStatus(raw)
|
|
// Each changed file needs its HEAD blob (a `git show` spawn) + its disk text.
|
|
// Done serially this is O(files) subprocess spawns in a row — staging one file
|
|
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
|
|
// bounded concurrency instead so the spawns overlap (cap keeps us well under
|
|
// macOS's low default FD limit). Order is preserved by index.
|
|
const changes = (await mapLimit(files, 12, async (f) => {
|
|
const rows = classify(f.index, f.working)
|
|
const stagedRow = rows.find((r) => r.staged)
|
|
const workRow = rows.find((r) => !r.staged)
|
|
// The index blob is only needed when a file sits in BOTH groups. With one
|
|
// row the index copy equals HEAD (unstaged only) or the disk copy (staged
|
|
// only), so the common case still costs no extra `git show`.
|
|
const both = !!stagedRow && !!workRow
|
|
const idx = both ? await indexText(root, f.path) : ''
|
|
const out: GitChange[] = []
|
|
if (stagedRow) {
|
|
// Staged row: HEAD -> index.
|
|
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
|
|
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
|
|
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
|
|
}
|
|
if (workRow) {
|
|
// Unstaged row: index -> disk. An untracked file has no index copy.
|
|
const untracked = f.index === '?'
|
|
const original = untracked ? '' : both ? idx : await headText(root, f.path)
|
|
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
|
|
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
|
|
}
|
|
return out
|
|
})).flat()
|
|
|
|
return { branch, changes }
|
|
}
|
|
|
|
export async function stage(root: string, paths: string[]): Promise<void> {
|
|
// `git add` stages modifications, additions AND deletions of the given paths.
|
|
await git(root, ['add', '--', ...paths])
|
|
}
|
|
|
|
export async function unstage(root: string, paths: string[]): Promise<void> {
|
|
const { code } = await runGit(root, ['reset', '--', ...paths])
|
|
if (code !== 0) {
|
|
// empty repo (no HEAD yet): fall back to removing from the index.
|
|
await git(root, ['rm', '--cached', '-r', '--', ...paths])
|
|
}
|
|
}
|
|
|
|
export async function commit(root: string, message: string): Promise<void> {
|
|
await git(root, ['commit', '-m', message])
|
|
}
|
|
|
|
/**
|
|
* Push the current branch to its remote. If the branch has no upstream yet,
|
|
* retry with `-u origin <branch>` so the first push also sets tracking.
|
|
* Returns a concise one-line summary for the toast (git writes progress to
|
|
* stderr, so we pull the summary from there).
|
|
*/
|
|
export async function push(root: string): Promise<{ ok: boolean; message: string }> {
|
|
let res = await runGit(root, ['push'])
|
|
if (res.code !== 0 && /no upstream branch|set-upstream/i.test(res.stderr)) {
|
|
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
|
|
if (branch && branch !== 'HEAD') res = await runGit(root, ['push', '-u', 'origin', branch])
|
|
}
|
|
const lines = (res.stderr || res.stdout).trim().split('\n').map((l) => l.trim()).filter(Boolean)
|
|
if (res.code !== 0) return { ok: false, message: lines.pop() || 'push failed' }
|
|
const summary = lines.find((l) => /->|up-to-date|new branch/i.test(l)) || lines.pop() || 'Pushed'
|
|
return { ok: true, message: summary }
|
|
}
|
|
|
|
/**
|
|
* Discard working-tree changes for each path:
|
|
* - exists in HEAD → restore index + worktree to the last commit
|
|
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk
|
|
*/
|
|
export async function discard(root: string, paths: string[]): Promise<void> {
|
|
for (const p of paths) {
|
|
const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`])
|
|
if (code === 0) {
|
|
await git(root, ['checkout', 'HEAD', '--', p])
|
|
} else {
|
|
await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result
|
|
await rm(join(root, p), { force: true })
|
|
}
|
|
}
|
|
}
|