UNPKG

git-tweezers

Version:

Advanced git staging tool with hunk and line-level control

176 lines (175 loc) 6.61 kB
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'); } } }