git-tweezers
Version:
Advanced git staging tool with hunk and line-level control
129 lines (128 loc) • 4.41 kB
JavaScript
import { createHash } from 'crypto';
/**
* Normalize a line for consistent hashing
* - Replace tabs with spaces
* - Trim trailing whitespace
* - Remove carriage returns
*/
function normalizeLine(line) {
return line
.replace(/\r/g, '') // Remove CR
.replace(/\t/g, ' ') // Replace tabs with spaces
.replace(/\s+$/, ''); // Trim trailing whitespace
}
/**
* Extract context lines before and after changes
*/
function extractContext(changes) {
const before = [];
const after = [];
const actualChanges = [];
let foundFirstChange = false;
let lastChangeIndex = -1;
// Find actual changes (not UnchangedLine)
changes.forEach((change, index) => {
if (change.type !== 'UnchangedLine') {
if (!foundFirstChange) {
// Collect up to 3 context lines before first change
for (let i = Math.max(0, index - 3); i < index; i++) {
if (changes[i].type === 'UnchangedLine') {
before.push(changes[i].content);
}
}
foundFirstChange = true;
}
actualChanges.push(change);
lastChangeIndex = index;
}
});
// Collect up to 3 context lines after last change
if (lastChangeIndex >= 0) {
for (let i = lastChangeIndex + 1; i < Math.min(changes.length, lastChangeIndex + 4); i++) {
if (changes[i].type === 'UnchangedLine') {
after.push(changes[i].content);
}
}
}
return { before, after, actualChanges };
}
/**
* Generate a stable content-based fingerprint for a hunk
* Based on O3's suggestion: normalize content to be independent of +/- prefixes
* This ensures the same content has the same ID whether staged or unstaged
*/
export function generateContentFingerprint(hunk, filePath) {
const hash = createHash('sha256');
// Include file path for uniqueness across files
hash.update(filePath + '\n');
// Extract context and changes
const { before, after, actualChanges } = extractContext(hunk.changes);
// Hash normalized context before (without prefixes)
before.forEach(line => {
hash.update(normalizeLine(line) + '\n');
});
// Hash actual changes WITHOUT their types (no +/- prefix)
// This makes the fingerprint identical whether the change is staged or unstaged
actualChanges.forEach(change => {
// Just use the content, not the type
hash.update(normalizeLine(change.content) + '\n');
});
// Hash normalized context after (without prefixes)
after.forEach(line => {
hash.update(normalizeLine(line) + '\n');
});
return hash.digest('hex');
}
/**
* Generate a stable ID for a hunk based on its content
* Uses content-based fingerprint to ensure stability across staging operations
*/
export function generateHunkId(hunk, filePath, existingIds) {
const fingerprint = generateContentFingerprint(hunk, filePath);
// Start with 4 characters, increase if collision
let length = 4;
let id = fingerprint.substring(0, length);
// Handle collisions by increasing length
while (existingIds && existingIds.has(id) && length < fingerprint.length) {
length++;
id = fingerprint.substring(0, length);
}
return id;
}
/**
* Extract a summary from hunk changes for better identification
*/
export function getHunkSummary(hunk) {
// Find the first meaningful change
const meaningfulChange = hunk.changes.find(change => {
if (change.type === 'UnchangedLine')
return false;
// Skip empty lines or lines with only whitespace
const trimmed = change.content.trim();
return trimmed.length > 0;
});
if (!meaningfulChange) {
return '';
}
const content = meaningfulChange.content.trim();
const maxLength = 50;
// Truncate if too long
if (content.length > maxLength) {
return content.substring(0, maxLength) + '...';
}
return content;
}
/**
* Get change statistics for a hunk
*/
export function getHunkStats(hunk) {
let additions = 0;
let deletions = 0;
hunk.changes.forEach(change => {
if (change.type === 'AddedLine')
additions++;
else if (change.type === 'DeletedLine')
deletions++;
});
return { additions, deletions };
}