Files
claude-hud/dist/git.js
2026-04-04 06:26:09 +00:00

153 lines
5.8 KiB
JavaScript

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export async function getGitBranch(cwd) {
if (!cwd)
return null;
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8' });
return stdout.trim() || null;
}
catch {
return null;
}
}
export async function getGitStatus(cwd) {
if (!cwd)
return null;
try {
// Get branch name
const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8' });
const branch = branchOut.trim();
if (!branch)
return null;
// Check for dirty state and parse file stats
let isDirty = false;
let fileStats;
let lineDiff;
try {
const { stdout: statusOut } = await execFileAsync('git', ['--no-optional-locks', 'status', '--porcelain'], { cwd, timeout: 1000, encoding: 'utf8' });
const trimmed = statusOut.trim();
isDirty = trimmed.length > 0;
if (isDirty) {
fileStats = parseFileStats(trimmed);
}
}
catch {
// Ignore errors, assume clean
}
// Get per-file and total line diffs
if (isDirty) {
try {
const { stdout: numstatOut } = await execFileAsync('git', ['diff', '--numstat', 'HEAD'], { cwd, timeout: 2000, encoding: 'utf8' });
const { totalDiff, perFileDiff } = parseNumstat(numstatOut);
lineDiff = totalDiff;
if (fileStats) {
applyLineDiffsToFiles(fileStats.trackedFiles, perFileDiff);
}
}
catch {
// Ignore errors
}
}
// Get ahead/behind counts
let ahead = 0;
let behind = 0;
try {
const { stdout: revOut } = await execFileAsync('git', ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], { cwd, timeout: 1000, encoding: 'utf8' });
const parts = revOut.trim().split(/\s+/);
if (parts.length === 2) {
behind = parseInt(parts[0], 10) || 0;
ahead = parseInt(parts[1], 10) || 0;
}
}
catch {
// No upstream or error, keep 0/0
}
// Build GitHub branch URL from remote
let branchUrl;
try {
const { stdout: remoteOut } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 1000, encoding: 'utf8' });
const remote = remoteOut.trim();
const httpsBase = remote
.replace(/^git@([^:]+):/, 'https://$1/')
.replace(/\.git$/, '');
if (httpsBase.startsWith('https://')) {
branchUrl = `${httpsBase}/tree/${branch}`;
}
}
catch {
// No remote or not GitHub
}
return { branch, isDirty, ahead, behind, fileStats, lineDiff, branchUrl };
}
catch {
return null;
}
}
/**
* Parse git status --porcelain output and count file stats (Starship-compatible format)
* Status codes: M=modified, A=added, D=deleted, ??=untracked
*/
function parseFileStats(porcelainOutput) {
const stats = { modified: 0, added: 0, deleted: 0, untracked: 0, trackedFiles: [] };
const lines = porcelainOutput.split('\n').filter(Boolean);
for (const line of lines) {
if (line.length < 2)
continue;
const index = line[0]; // staged status
const worktree = line[1]; // unstaged status
if (line.startsWith('??')) {
stats.untracked++;
}
else if (index === 'A') {
stats.added++;
const fullPath = line.slice(2).trimStart();
stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'added' });
}
else if (index === 'D' || worktree === 'D') {
stats.deleted++;
const fullPath = line.slice(2).trimStart();
stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'deleted' });
}
else if (index === 'M' || worktree === 'M' || index === 'R' || index === 'C') {
// M=modified, R=renamed (counts as modified), C=copied (counts as modified)
stats.modified++;
// For renames, git porcelain shows "old -> new"; take the destination path
const fullPath = line.slice(2).trimStart().split(' -> ').pop() ?? line.slice(2).trimStart();
stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'modified' });
}
}
return stats;
}
/**
* Parse `git diff --numstat HEAD` output.
* Returns total line diff and a map of fullPath -> LineDiff.
*/
function parseNumstat(numstatOutput) {
const totalDiff = { added: 0, deleted: 0 };
const perFileDiff = new Map();
for (const line of numstatOutput.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length < 3)
continue;
const added = parseInt(parts[0], 10);
const deleted = parseInt(parts[1], 10);
const filePath = parts[2];
if (Number.isNaN(added) || Number.isNaN(deleted))
continue; // binary file
totalDiff.added += added;
totalDiff.deleted += deleted;
perFileDiff.set(filePath, { added, deleted });
}
return { totalDiff, perFileDiff };
}
function applyLineDiffsToFiles(files, perFileDiff) {
for (const file of files) {
const diff = perFileDiff.get(file.fullPath);
if (diff) {
file.lineDiff = diff;
}
}
}
//# sourceMappingURL=git.js.map