UNPKG

git-tweezers

Version:

Advanced git staging tool with hunk and line-level control

279 lines (278 loc) 11 kB
import { GitWrapper } from '../core/git-wrapper.js'; import { DiffParser } from '../core/diff-parser.js'; import { PatchBuilder } from '../core/patch-builder.js'; import { LineMapper } from '../core/line-mapper.js'; import { HunkCacheService } from './hunk-cache-service.js'; import { StagingError } from '../utils/staging-error.js'; export class StagingService { git; parser; builder; cache; constructor(cwd) { this.git = new GitWrapper(cwd); this.parser = new DiffParser(); this.builder = new PatchBuilder(); this.cache = new HunkCacheService(cwd); } /** * List all hunks in a file */ async listHunks(filePath, options) { const hunks = await this.listHunksWithInfo(filePath, options); return hunks.map((hunk) => `Hunk ${hunk.index}: ${hunk.header}`); } /** * List all hunks with full information */ async listHunksWithInfo(filePath, options) { // Check if file is binary const isBinary = await this.git.isBinary(filePath); if (isBinary) { throw new Error(`Cannot list hunks for binary file: ${filePath}`); } // Check if file is untracked and handle it const isUntracked = await this.git.isUntracked(filePath); if (isUntracked) { await this.git.addIntentToAdd(filePath); } const context = options?.precise ? 0 : 3; const diff = await this.git.diff(filePath, context); if (!diff) { return []; } const files = this.parser.parseFilesWithInfo(diff); const file = files.find(f => f.newPath === filePath || f.oldPath === filePath); if (!file) { return []; } // Map hunks with cache to maintain stable IDs return this.cache.mapHunks(filePath, file.hunks); } /** * Stage a specific hunk by index (1-based) or ID */ async stageHunk(filePath, hunkSelector, options) { // Check if file is binary const isBinary = await this.git.isBinary(filePath); if (isBinary) { throw new Error(`Cannot stage hunks for binary file: ${filePath}`); } // Check if file is untracked and handle it const isUntracked = await this.git.isUntracked(filePath); if (isUntracked) { await this.git.addIntentToAdd(filePath); } const context = options?.precise ? 0 : 3; const diff = await this.git.diff(filePath, context); if (!diff) { throw new Error(`No changes found for file: ${filePath}`); } const files = this.parser.parseFilesWithInfo(diff); const file = files.find(f => f.newPath === filePath || f.oldPath === filePath); if (!file) { throw new Error(`File not found in diff: ${filePath}`); } // Map hunks with cache const hunks = this.cache.mapHunks(filePath, file.hunks); if (process.env.DEBUG === '1') { console.log(`Looking for hunk selector: "${hunkSelector}"`); console.log(`Available hunks:`, hunks.map(h => ({ index: h.index, id: h.id }))); } // Find the hunk by selector const hunk = this.cache.findHunk(hunks, hunkSelector); if (!hunk) { throw new StagingError(`Hunk '${hunkSelector}' not found. File has ${hunks.length} hunks.`, hunks); } const fileData = { oldPath: file.oldPath, newPath: file.newPath, hunks: [{ header: hunk.header, changes: hunk.changes, }], }; const patch = this.builder.buildPatch([fileData]); if (process.env.DEBUG === '1' || options?.dryRun) { console.log('Generated patch:'); console.log(patch); } // In dry-run mode, skip applying the patch if (options?.dryRun) { console.log('\n[DRY RUN] The above patch would be applied to the staging area.'); return; } // Apply the patch const applyOptions = options?.precise ? ['--cached', '--unidiff-zero'] : ['--cached']; await this.git.applyWithOptions(patch, applyOptions); // Record in history this.cache.addHistory({ patch, files: [filePath], selectors: [hunkSelector], description: `Stage hunk ${hunkSelector} from ${filePath}`, }); } /** * Stage specific lines in a file */ async stageLines(filePath, startLine, endLine, _options) { // Check if file is binary const isBinary = await this.git.isBinary(filePath); if (isBinary) { throw new Error(`Cannot stage lines for binary file: ${filePath}`); } // Check if file is untracked and handle it const isUntracked = await this.git.isUntracked(filePath); if (isUntracked) { await this.git.addIntentToAdd(filePath); } // For line-level staging, use U1 for better reliability const diff = await this.git.diff(filePath, 1); if (!diff) { throw new Error(`No changes found for file: ${filePath}`); } const files = this.parser.parseFiles(diff); const file = files.find(f => f.newPath === filePath || f.oldPath === filePath); if (!file) { throw new Error(`File not found in diff: ${filePath}`); } // Collect target line numbers const targetLines = new Set(); for (let line = startLine; line <= endLine; line++) { targetLines.add(line); } // Collect all required changes from all hunks const allSelectedChanges = []; for (const hunk of file.hunks) { const requiredChanges = LineMapper.getRequiredChanges(hunk, targetLines); if (requiredChanges.length > 0) { if (process.env.DEBUG === '1') { console.log(`Hunk ${hunk.header}: Selected ${requiredChanges.length} changes`); requiredChanges.forEach(c => console.log(` ${c.type}: "${c.content}" (eol: ${c.eol})`)); } allSelectedChanges.push(...requiredChanges); } } if (allSelectedChanges.length === 0) { throw new Error(`No changes found in lines ${startLine}-${endLine}`); } // Build a single patch with all selected changes // Group changes back by hunk for proper patch generation const hunkGroups = new Map(); for (const hunk of file.hunks) { const hunkChanges = allSelectedChanges.filter(change => hunk.changes.includes(change)); if (hunkChanges.length > 0) { hunkGroups.set(hunk, hunkChanges); } } // Build hunks for the patch const rebuiltHunks = []; for (const [hunk, changes] of hunkGroups) { const rebuiltHunk = this.builder.rebuildHunk(hunk, changes); rebuiltHunks.push(rebuiltHunk); } // Create final patch const fileData = { oldPath: file.oldPath, newPath: file.newPath, hunks: rebuiltHunks, }; const patch = this.builder.buildPatch([fileData]); if (process.env.DEBUG === '1' || _options?.dryRun) { console.log('Generated patch:'); console.log(patch); } // In dry-run mode, skip applying the patch if (_options?.dryRun) { console.log('\n[DRY RUN] The above patch would be applied to the staging area.'); return; } // Apply with recount option for better reliability await this.git.applyWithOptions(patch, ['--cached', '--recount']); // Record in history this.cache.addHistory({ patch, files: [filePath], selectors: [`${startLine}-${endLine}`], description: `Stage lines ${startLine}-${endLine} from ${filePath}`, }); } /** * Stage multiple hunks at once */ async stageHunks(filePath, hunkSelectors, options) { const context = options?.precise ? 0 : 3; const diff = await this.git.diff(filePath, context); if (!diff) { throw new Error(`No changes found for file: ${filePath}`); } const files = this.parser.parseFilesWithInfo(diff); const file = files.find(f => f.newPath === filePath || f.oldPath === filePath); if (!file) { throw new Error(`File not found in diff: ${filePath}`); } // Map hunks with cache const hunks = this.cache.mapHunks(filePath, file.hunks); // Find all selected hunks const selectedHunks = []; const notFoundSelectors = []; for (const selector of hunkSelectors) { const hunk = this.cache.findHunk(hunks, selector); if (hunk) { selectedHunks.push(hunk); } else { notFoundSelectors.push(selector); } } if (notFoundSelectors.length > 0) { throw new StagingError(`Hunks not found: ${notFoundSelectors.join(', ')}. File has ${hunks.length} hunks.`, hunks); } // Build patch with selected hunks const fileData = { oldPath: file.oldPath, newPath: file.newPath, hunks: selectedHunks.map(hunk => ({ header: hunk.header, changes: hunk.changes, })), }; const patch = this.builder.buildPatch([fileData]); if (process.env.DEBUG === '1' || options?.dryRun) { console.log('Generated patch:'); console.log(patch); } // In dry-run mode, skip applying the patch if (options?.dryRun) { console.log('\n[DRY RUN] The above patch would be applied to the staging area.'); return; } // Apply the patch const applyOptions = options?.precise ? ['--cached', '--unidiff-zero'] : ['--cached']; await this.git.applyWithOptions(patch, applyOptions); // Record in history this.cache.addHistory({ patch, files: [filePath], selectors: hunkSelectors, description: `Stage hunks ${hunkSelectors.join(', ')} from ${filePath}`, }); } /** * Get the count of hunks for a file */ async getHunkCount(filePath, options) { // Check if file is untracked and handle it const isUntracked = await this.git.isUntracked(filePath); if (isUntracked) { await this.git.addIntentToAdd(filePath); } const context = options?.precise ? 0 : 3; const diff = await this.git.diff(filePath, context); if (!diff) { return 0; } return this.parser.getFileHunkCount(diff, filePath); } }