@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
568 lines • 16 kB
JavaScript
/**
* Git Utilities
*
* Shared utilities for git operations including command execution,
* status parsing, and availability checks.
*/
import { execSync, spawn } from 'node:child_process';
// ============================================================================
// Availability Checks (Synchronous - for conditional tool registration)
// ============================================================================
/**
* Check if git is available on the system (synchronous)
*/
export function isGitAvailable() {
try {
execSync('git --version', { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
/**
* Check if the current directory is inside a git repository (synchronous)
*/
export function isInsideGitRepo() {
try {
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
/**
* Check if gh CLI is available on the system (synchronous)
*/
export function isGhAvailable() {
try {
execSync('gh --version', { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
// ============================================================================
// Git Command Execution
// ============================================================================
/**
* Execute a git command and return the output
*/
export async function execGit(args) {
return new Promise((resolve, reject) => {
const proc = spawn('git', args);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(stdout.trimEnd());
}
else {
const errorMessage = stderr.trim() || `Git command failed with exit code ${code}`;
reject(new Error(errorMessage));
}
});
proc.on('error', error => {
reject(new Error(`Failed to execute git: ${error.message}`));
});
});
}
/**
* Execute a gh CLI command and return the output
*/
export async function execGh(args) {
return new Promise((resolve, reject) => {
const proc = spawn('gh', args);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(stdout.trimEnd());
}
else {
const errorMessage = stderr.trim() || `gh command failed with exit code ${code}`;
reject(new Error(errorMessage));
}
});
proc.on('error', error => {
reject(new Error(`Failed to execute gh: ${error.message}`));
});
});
}
// ============================================================================
// Repository State Checks
// ============================================================================
/**
* Check if there are uncommitted changes (staged or unstaged)
*/
export async function hasUncommittedChanges() {
try {
const status = await execGit(['status', '--porcelain']);
return status.trim().length > 0;
}
catch {
return false;
}
}
/**
* Check if there are staged changes
*/
export async function hasStagedChanges() {
try {
const diff = await execGit(['diff', '--cached', '--name-only']);
return diff.trim().length > 0;
}
catch {
return false;
}
}
/**
* Check if a rebase is in progress
*/
export async function isRebaseInProgress() {
try {
await execGit(['rev-parse', '--git-path', 'rebase-merge']);
const result = await execGit([
'rev-parse',
'--verify',
'--quiet',
'REBASE_HEAD',
]);
return result !== '';
}
catch {
return false;
}
}
/**
* Check if a merge is in progress
*/
export async function isMergeInProgress() {
try {
await execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']);
return true;
}
catch {
return false;
}
}
// ============================================================================
// Branch Operations
// ============================================================================
/**
* Get the current branch name
*/
export async function getCurrentBranch() {
try {
return await execGit(['rev-parse', '--abbrev-ref', 'HEAD']);
}
catch {
return 'HEAD'; // Detached HEAD state
}
}
/**
* Get the default branch (main or master)
*/
export async function getDefaultBranch() {
try {
const remoteBranch = await execGit([
'symbolic-ref',
'refs/remotes/origin/HEAD',
'--short',
]);
return remoteBranch.replace('origin/', '');
}
catch {
// Fall back to checking if main or master exists
try {
await execGit(['rev-parse', '--verify', 'main']);
return 'main';
}
catch {
try {
await execGit(['rev-parse', '--verify', 'master']);
return 'master';
}
catch {
return 'main';
}
}
}
}
/**
* Check if a branch exists
*/
export async function branchExists(name) {
try {
await execGit(['rev-parse', '--verify', `refs/heads/${name}`]);
return true;
}
catch {
return false;
}
}
/**
* Get the upstream branch for the current branch
*/
export async function getUpstreamBranch() {
try {
return await execGit(['rev-parse', '--abbrev-ref', '@{upstream}']);
}
catch {
return null;
}
}
/**
* Get ahead/behind counts relative to upstream
*/
export async function getAheadBehind() {
try {
const upstream = await getUpstreamBranch();
if (!upstream)
return { ahead: 0, behind: 0 };
const result = await execGit([
'rev-list',
'--left-right',
'--count',
`${upstream}...HEAD`,
]);
const [behind, ahead] = result.split('\t').map(n => parseInt(n, 10) || 0);
return { ahead, behind };
}
catch {
return { ahead: 0, behind: 0 };
}
}
/**
* Get list of local branches
*/
export async function getLocalBranches() {
try {
const output = await execGit([
'branch',
'--format=%(refname:short)|%(upstream:short)|%(upstream:track,nobracket)|%(HEAD)',
]);
return output
.split('\n')
.filter(line => line.trim())
.map(line => {
const [name, upstream, track, head] = line.split('|');
let ahead = 0;
let behind = 0;
if (track) {
const aheadMatch = track.match(/ahead (\d+)/);
const behindMatch = track.match(/behind (\d+)/);
if (aheadMatch)
ahead = parseInt(aheadMatch[1], 10);
if (behindMatch)
behind = parseInt(behindMatch[1], 10);
}
return {
name: name || '',
current: head === '*',
upstream: upstream || undefined,
ahead,
behind,
};
});
}
catch {
return [];
}
}
/**
* Get list of remote branches
*/
export async function getRemoteBranches() {
try {
const output = await execGit(['branch', '-r', '--format=%(refname:short)']);
return output
.split('\n')
.filter(line => line.trim() && !line.includes('HEAD'));
}
catch {
return [];
}
}
// ============================================================================
// Commit Operations
// ============================================================================
/**
* Get unpushed commits (commits ahead of upstream)
*/
export async function getUnpushedCommits() {
try {
const upstream = await getUpstreamBranch();
if (!upstream)
return [];
return await getCommits({ range: `${upstream}..HEAD` });
}
catch {
return [];
}
}
/**
* Check if the last commit has been pushed
*/
export async function isLastCommitPushed() {
try {
const upstream = await getUpstreamBranch();
if (!upstream)
return false;
const { ahead } = await getAheadBehind();
return ahead === 0;
}
catch {
return false;
}
}
/**
* Get commits with various filters
*/
export async function getCommits(options) {
try {
const args = ['log', '--format=%H|%h|%an|%ae|%ad|%ar|%s', '--date=short'];
if (options.count)
args.push(`-n`, options.count.toString());
if (options.range)
args.push(options.range);
if (options.author)
args.push(`--author=${options.author}`);
if (options.since)
args.push(`--since=${options.since}`);
if (options.grep)
args.push(`--grep=${options.grep}`);
if (options.file)
args.push('--', options.file);
const output = await execGit(args);
if (!output.trim())
return [];
return output.split('\n').map(line => {
const [hash, shortHash, author, email, date, relativeDate, subject] = line.split('|');
return {
hash: hash || '',
shortHash: shortHash || '',
author: author || '',
email: email || '',
date: date || '',
relativeDate: relativeDate || '',
subject: subject || '',
body: '',
};
});
}
catch {
return [];
}
}
// ============================================================================
// Status Parsing
// ============================================================================
/**
* Map git status character to FileChangeStatus
*/
function mapStatusChar(char) {
switch (char) {
case 'A':
return 'added';
case 'D':
return 'deleted';
case 'R':
return 'renamed';
case 'C':
return 'copied';
case 'M':
default:
return 'modified';
}
}
/**
* Parse git status --porcelain output
*/
export function parseGitStatus(statusOutput) {
const staged = [];
const unstaged = [];
const untracked = [];
const conflicts = [];
const lines = statusOutput.split('\n').filter(line => line);
for (const line of lines) {
if (line.length < 3)
continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const path = line.slice(3);
// Detect conflicts
if (indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')) {
conflicts.push(path);
continue;
}
// Untracked files
if (indexStatus === '?' && workTreeStatus === '?') {
untracked.push(path);
continue;
}
// Staged changes
if (indexStatus !== ' ' && indexStatus !== '?') {
staged.push({
path,
status: mapStatusChar(indexStatus),
additions: 0,
deletions: 0,
isBinary: false,
});
}
// Unstaged changes
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
unstaged.push({
path,
status: mapStatusChar(workTreeStatus),
additions: 0,
deletions: 0,
isBinary: false,
});
}
}
return { staged, unstaged, untracked, conflicts };
}
/**
* Get diff stats for files (additions/deletions)
*/
export async function getDiffStats(staged = false) {
const stats = new Map();
try {
const args = ['diff', '--numstat'];
if (staged)
args.push('--cached');
const output = await execGit(args);
if (!output.trim())
return stats;
for (const line of output.split('\n')) {
const parts = line.split('\t');
if (parts.length >= 3) {
const additions = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
const path = parts[2];
stats.set(path, { additions, deletions });
}
}
}
catch {
// Ignore errors
}
return stats;
}
// ============================================================================
// Stash Operations
// ============================================================================
/**
* Get list of stashes
*/
export async function getStashList() {
try {
const output = await execGit(['stash', 'list', '--format=%gd|%gs|%cr']);
if (!output.trim())
return [];
return output.split('\n').map((line, index) => {
const [ref, message, date] = line.split('|');
const branchMatch = message?.match(/WIP on ([^:]+):/);
return {
index,
message: message || ref || `stash@{${index}}`,
branch: branchMatch?.[1] || 'unknown',
date: date || '',
};
});
}
catch {
return [];
}
}
/**
* Get stash count
*/
export async function getStashCount() {
try {
const output = await execGit(['stash', 'list']);
if (!output.trim())
return 0;
return output.split('\n').length;
}
catch {
return 0;
}
}
// ============================================================================
// Remote Operations
// ============================================================================
/**
* Check if a remote exists
*/
export async function remoteExists(name) {
try {
const remotes = await execGit(['remote']);
return remotes.split('\n').includes(name);
}
catch {
return false;
}
}
// ============================================================================
// Diff Output Helpers
// ============================================================================
/**
* Truncate diff output if too long
*/
export function truncateDiff(diff, maxLines = 500) {
const lines = diff.split('\n');
const totalLines = lines.length;
if (totalLines <= maxLines) {
return { content: diff, truncated: false, totalLines };
}
const truncatedContent = lines.slice(0, maxLines).join('\n');
return {
content: truncatedContent +
`\n\n... [Diff truncated: showing ${maxLines} of ${totalLines} lines]`,
truncated: true,
totalLines,
};
}
// ============================================================================
// Formatting Helpers
// ============================================================================
/**
* Format file status character for display
*/
export function formatStatusChar(status) {
switch (status) {
case 'added':
return 'A';
case 'modified':
return 'M';
case 'deleted':
return 'D';
case 'renamed':
return 'R';
case 'copied':
return 'C';
default:
return '?';
}
}
//# sourceMappingURL=utils.js.map