UNPKG

git-tweezers

Version:

Advanced git staging tool with hunk and line-level control

198 lines (197 loc) 7.37 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(options) { const files = new Set(); if (!options?.stagedOnly) { // Get modified files const modifiedOutput = await this.execute(['diff', '--name-only']); const modifiedFiles = modifiedOutput.split('\n').filter(line => line.trim()); modifiedFiles.forEach(f => files.add(f)); } // Get staged files const cachedOutput = await this.execute(['diff', '--cached', '--name-only']); const cachedFiles = cachedOutput.split('\n').filter(line => line.trim()); cachedFiles.forEach(f => files.add(f)); if (!options?.trackedOnly && !options?.stagedOnly) { // Get untracked files const untrackedArgs = ['ls-files', '--others']; if (options?.respectGitignore !== false) { // By default, exclude standard (respect .gitignore) untrackedArgs.push('--exclude-standard'); } const untrackedOutput = await this.execute(untrackedArgs); const untrackedFiles = untrackedOutput.split('\n').filter(line => line.trim()); untrackedFiles.forEach(f => files.add(f)); } return Array.from(files).sort(); } async diffCached(file, context = 3) { const args = ['diff', '--cached', `-U${context}`]; if (file) args.push('--', file); return this.execute(args); } /** * Get both staged and unstaged diffs for a file * Returns an object with both diffs for dual-layer tracking */ async getDualLayerDiff(file, context = 3) { const [staged, unstaged] = await Promise.all([ this.diffCached(file, context), this.diff(file, context) ]); return { staged, unstaged }; } 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'); } } }