mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-04-20 18:02:39 +00:00
153 lines
5.8 KiB
JavaScript
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
|