git-tweezers
Version:
Advanced git staging tool with hunk and line-level control
176 lines (175 loc) • 6.61 kB
JavaScript
import { execa, execaSync } from 'execa';
import { join } from 'path';
export class GitWrapper {
repoRoot;
constructor(cwd = process.cwd()) {
this.repoRoot = this.getGitRootStatic(cwd);
this.cwd = this.repoRoot;
}
cwd;
async execute(args, options) {
const result = await execa('git', args, {
cwd: this.cwd || options?.cwd,
...options,
});
return typeof result.stdout === 'string' ? result.stdout : '';
}
async executeWithInput(args, input, options) {
const result = await execa('git', args, {
input,
cwd: this.cwd || options?.cwd,
...options,
});
return typeof result.stdout === 'string' ? result.stdout : '';
}
async diff(file, context = 3) {
return this.execute(['diff', `-U${context}`, '--', file]);
}
async diffAll(context = 3) {
return this.execute(['diff', `-U${context}`]);
}
async getChangedFiles() {
// Get modified/staged files
const modifiedOutput = await this.execute(['diff', '--name-only']);
const modifiedFiles = modifiedOutput.split('\n').filter(line => line.trim());
// Get staged files (in case they're staged but not modified)
const cachedOutput = await this.execute(['diff', '--cached', '--name-only']);
const cachedFiles = cachedOutput.split('\n').filter(line => line.trim());
// Get untracked files
const untrackedOutput = await this.execute(['ls-files', '--others', '--exclude-standard']);
const untrackedFiles = untrackedOutput.split('\n').filter(line => line.trim());
// Combine and deduplicate
const allFiles = new Set([...modifiedFiles, ...cachedFiles, ...untrackedFiles]);
return Array.from(allFiles).sort();
}
async diffCached(file, context = 3) {
const args = ['diff', '--cached', `-U${context}`];
if (file)
args.push('--', file);
return this.execute(args);
}
async apply(patch, cached = true) {
const args = ['apply'];
if (cached)
args.push('--cached');
args.push('-');
await this.executeWithInput(args, patch);
}
async applyWithOptions(patch, options) {
const args = ['apply', ...options, '-'];
await this.executeWithInput(args, patch);
}
async reverseApplyCached(patch, options = []) {
const args = ['apply', '-R', '--cached', ...options, '-'];
await this.executeWithInput(args, patch);
}
async status(short = false) {
const args = ['status'];
if (short)
args.push('--short');
return this.execute(args);
}
async add(files) {
const fileList = Array.isArray(files) ? files : [files];
await this.execute(['add', ...fileList]);
}
async reset(files) {
const args = ['reset'];
if (files) {
const fileList = Array.isArray(files) ? files : [files];
args.push(...fileList);
}
await this.execute(args);
}
async isUntracked(file) {
try {
const output = await this.execute(['status', '--porcelain', '--', file]);
// If output starts with '??', the file is untracked
return output.trim().startsWith('??');
}
catch {
return false; // If status fails, assume file doesn't exist or is tracked
}
}
async addIntentToAdd(file) {
await this.execute(['add', '-N', '--', file]);
}
async isBinary(file) {
try {
// Check if file is tracked
const isUntracked = await this.isUntracked(file);
if (isUntracked) {
// For untracked files, check the working tree version
// git diff --no-index --numstat /dev/null <file>
const output = await this.execute(['diff', '--no-index', '--numstat', '/dev/null', file]);
// Binary files show as "- - filename"
return output.trim().startsWith('-\t-');
}
else {
// For tracked files, check if git considers it binary
// git diff --numstat shows binary files as "- - filename"
const output = await this.execute(['diff', '--numstat', '--', file]);
if (output.trim() === '') {
// No changes, check the cached version
const cachedOutput = await this.execute(['diff', '--cached', '--numstat', '--', file]);
return cachedOutput.trim().startsWith('-\t-');
}
return output.trim().startsWith('-\t-');
}
}
catch {
// If commands fail, assume it's not binary
return false;
}
}
getGitRoot() {
return this.repoRoot;
}
get gitRoot() {
return this.repoRoot;
}
static getGitRootStatic(cwd) {
try {
const result = execaSync('git', ['rev-parse', '--show-toplevel'], {
cwd: cwd,
});
return result.stdout.trim();
}
catch {
// Fall back to current directory if git command fails
return cwd;
}
}
getGitRootStatic(cwd) {
return GitWrapper.getGitRootStatic(cwd);
}
getGitDir() {
try {
const result = execaSync('git', ['rev-parse', '--git-dir'], {
cwd: this.cwd,
});
const gitDir = result.stdout.trim();
// Debug logging
if (process.env.DEBUG) {
console.error(`[GitWrapper.getGitDir] cwd: ${this.cwd}`);
console.error(`[GitWrapper.getGitDir] gitDir from git: ${gitDir}`);
}
// If relative path, make it absolute
if (!gitDir.startsWith('/') && !gitDir.startsWith('\\') && !gitDir.match(/^[A-Z]:/)) {
const absolutePath = join(this.cwd || process.cwd(), gitDir);
if (process.env.DEBUG) {
console.error(`[GitWrapper.getGitDir] made absolute: ${absolutePath}`);
}
return absolutePath;
}
return gitDir;
}
catch (error) {
// Fall back to .git in current directory if git command fails
if (process.env.DEBUG) {
console.error(`[GitWrapper.getGitDir] git command failed:`, error);
}
return join(this.cwd || process.cwd(), '.git');
}
}
}