项目文件夹

文件
2026-07-15 00:17:10 +00:00

227 行
9.1 KiB
JavaScript

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createDebug } from './debug.js';
const debug = createDebug('git');
const execFileAsync = promisify(execFile);
export async function getGitBranch(cwd) {
if (!cwd)
return null;
try {
return await resolveGitRef(cwd);
}
catch (err) {
debug('Failed to get git branch:', err instanceof Error ? err.message : err);
return null;
}
}
export async function getGitStatus(cwd) {
if (!cwd)
return null;
try {
// Get branch name
const branch = await resolveGitRef(cwd);
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', ['-c', 'core.quotePath=false', '--no-optional-locks', 'status', '--porcelain'], { cwd, timeout: 1000, encoding: 'utf8', windowsHide: true });
const trimmed = statusOut.trim();
isDirty = trimmed.length > 0;
if (isDirty) {
fileStats = parseFileStats(trimmed);
}
}
catch (err) {
debug('Failed to get git status:', err instanceof Error ? err.message : err);
}
// Get per-file and total line diffs
if (isDirty) {
try {
const { stdout: numstatOut } = await execFileAsync('git', ['-c', 'core.quotePath=false', 'diff', '--numstat', 'HEAD'], { cwd, timeout: 2000, encoding: 'utf8', windowsHide: true });
const trackedPaths = new Set(fileStats?.trackedFiles.map((file) => file.fullPath) ?? []);
const { totalDiff, perFileDiff } = parseNumstat(numstatOut, trackedPaths);
lineDiff = totalDiff;
if (fileStats) {
applyLineDiffsToFiles(fileStats.trackedFiles, perFileDiff);
}
}
catch (err) {
debug('Failed to get line diff:', err instanceof Error ? err.message : err);
}
}
// 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', windowsHide: true });
const parts = revOut.trim().split(/\s+/);
if (parts.length === 2) {
behind = parseInt(parts[0], 10) || 0;
ahead = parseInt(parts[1], 10) || 0;
}
}
catch (err) {
debug('Failed to get ahead/behind (no upstream?):', err instanceof Error ? err.message : err);
}
// Build GitHub branch URL from remote
let branchUrl;
try {
const { stdout: remoteOut } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 1000, encoding: 'utf8', windowsHide: true });
const remote = remoteOut.trim();
const httpsBase = remote
.replace(/^git@github\.com:/, 'https://github.com/')
.replace(/^ssh:\/\/git@github\.com\//, 'https://github.com/')
.replace(/\.git$/, '');
if (httpsBase.startsWith('https://github.com/')) {
branchUrl = buildGitHubRefUrl(httpsBase, branch);
}
}
catch (err) {
debug('Failed to get remote URL:', err instanceof Error ? err.message : err);
}
return { branch, isDirty, ahead, behind, fileStats, lineDiff, branchUrl };
}
catch (err) {
debug('getGitStatus failed:', err instanceof Error ? err.message : err);
return null;
}
}
async function resolveGitRef(cwd) {
const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8', windowsHide: true });
const branch = branchOut.trim();
if (branch && branch !== 'HEAD') {
return branch;
}
try {
const { stdout: tagOut } = await execFileAsync('git', ['describe', '--tags', '--exact-match', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8', windowsHide: true });
const tag = tagOut.trim();
if (tag)
return tag;
}
catch {
// Detached commits often are not tagged; fall back to a short commit id.
}
const { stdout: shortShaOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8', windowsHide: true });
const shortSha = shortShaOut.trim();
return shortSha ? `detached:${shortSha}` : null;
}
function encodeGitHubRef(ref) {
return ref.split('/').map(encodeURIComponent).join('/');
}
function buildGitHubRefUrl(httpsBase, ref) {
const detachedMatch = ref.match(/^detached:([0-9a-f]+)$/);
if (detachedMatch) {
return `${httpsBase}/commit/${detachedMatch[1]}`;
}
return `${httpsBase}/tree/${encodeGitHubRef(ref)}`;
}
/**
* 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 = parsePorcelainPath(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 = parsePorcelainPath(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 = parsePorcelainPath(line.slice(2).trimStart().split(' -> ').pop() ?? line.slice(2).trimStart());
stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'modified' });
}
}
return stats;
}
function parsePorcelainPath(pathField) {
if (pathField.startsWith('"') && pathField.endsWith('"')) {
try {
return JSON.parse(pathField);
}
catch {
return pathField.slice(1, -1);
}
}
return pathField;
}
/**
* Extract the destination path from a numstat path field.
*
* For renames, `git diff --numstat` emits the path as `old => new`
* (sometimes with a shared directory prefix like `pkg/{old.ts => new.ts}`).
* `git status --porcelain` reports the renamed file under its destination
* only, so we key `perFileDiff` by the destination to make lookups match.
*/
function extractNumstatDestination(filePath) {
const braceMatch = filePath.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceMatch) {
const [, prefix, , dest, suffix] = braceMatch;
return `${prefix}${dest}${suffix}`.replace(/\/{2,}/g, '/');
}
const arrowIndex = filePath.indexOf(' => ');
if (arrowIndex !== -1) {
return filePath.slice(arrowIndex + 4);
}
return filePath;
}
function resolveNumstatPath(filePath, trackedPaths) {
if (trackedPaths.has(filePath)) {
return filePath;
}
const destinationPath = extractNumstatDestination(filePath);
if (destinationPath !== filePath && trackedPaths.has(destinationPath)) {
return destinationPath;
}
return filePath;
}
/**
* Parse `git diff --numstat HEAD` output.
* Returns total line diff and a map of fullPath -> LineDiff.
*/
function parseNumstat(numstatOutput, trackedPaths) {
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 = resolveNumstatPath(parts[2], trackedPaths);
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