123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
import { readFile, rm } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { simpleGit, type SimpleGit } from 'simple-git'
|
|
|
|
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[]
|
|
}
|
|
|
|
function git(root: string): SimpleGit {
|
|
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 })
|
|
}
|
|
|
|
/** Map a porcelain code pair to our display letter + staged flag. */
|
|
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
|
const staged = index !== ' ' && index !== '?'
|
|
const code = staged ? index : working
|
|
let letter: GitStatusLetter
|
|
switch (code) {
|
|
case 'A': case 'C': case '?': letter = 'A'; break
|
|
case 'D': letter = 'D'; break
|
|
case 'R': letter = 'R'; break
|
|
case 'U': letter = 'M'; break
|
|
case 'M': default: letter = 'M'; break
|
|
}
|
|
return { letter, staged }
|
|
}
|
|
|
|
async function headText(g: SimpleGit, path: string): Promise<string> {
|
|
try {
|
|
return await g.show([`HEAD:${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 {
|
|
return await git(root).checkIsRepo()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function load(root: string): Promise<GitLoad | null> {
|
|
const g = git(root)
|
|
if (!(await isRepo(root))) return null
|
|
|
|
const status = await g.status()
|
|
const branch = status.current || 'HEAD'
|
|
|
|
const changes: GitChange[] = []
|
|
for (const f of status.files) {
|
|
// simple-git uses path "from -> to" for renames; take the destination.
|
|
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path
|
|
const { letter, staged } = classify(f.index, f.working_dir)
|
|
const isNew = f.index === '?' || f.index === 'A'
|
|
const isDeleted = letter === 'D'
|
|
const original = isNew ? '' : await headText(g, path)
|
|
const updated = isDeleted ? '' : await diskText(root, path)
|
|
changes.push({ path, status: letter, staged, original, updated })
|
|
}
|
|
|
|
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> {
|
|
try {
|
|
await git(root).reset(['--', ...paths])
|
|
} catch {
|
|
// empty repo (no HEAD yet): fall back to removing from the index.
|
|
await git(root).raw(['rm', '--cached', '-r', '--', ...paths])
|
|
}
|
|
}
|
|
|
|
export async function commit(root: string, message: string): Promise<void> {
|
|
await git(root).commit(message)
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const g = git(root)
|
|
for (const p of paths) {
|
|
const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false)
|
|
if (inHead) {
|
|
await g.checkout(['HEAD', '--', p])
|
|
} else {
|
|
try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ }
|
|
await rm(join(root, p), { force: true })
|
|
}
|
|
}
|
|
}
|