git-tweezers
Version:
Advanced git staging tool with hunk and line-level control
187 lines (186 loc) • 6.47 kB
JavaScript
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { GitWrapper } from '../core/git-wrapper.js';
import { generateContentFingerprint } from '../core/hunk-id.js';
export class HunkCacheService {
cachePath;
git;
cacheData;
constructor(cwd) {
this.git = new GitWrapper(cwd);
const gitDir = this.git.getGitDir();
this.cachePath = join(gitDir, 'tweezers-cache.json');
// Debug logging for worktree issues
if (process.env.DEBUG) {
console.error(`[HunkCacheService] cwd: ${cwd}`);
console.error(`[HunkCacheService] gitDir: ${gitDir}`);
console.error(`[HunkCacheService] cachePath: ${this.cachePath}`);
}
this.cacheData = this.loadCache();
}
loadCache() {
if (!existsSync(this.cachePath)) {
return { version: 2, fingerprints: {}, usedIds: {}, history: [] };
}
try {
const content = readFileSync(this.cachePath, 'utf8');
const data = JSON.parse(content);
// Migrate from version 1 to version 2
if (data.version === 1) {
return { version: 2, fingerprints: {}, usedIds: {}, history: data.history || [] };
}
// Ensure history array exists
if (!data.history) {
data.history = [];
}
// Ensure required fields exist
if (!data.fingerprints)
data.fingerprints = {};
if (!data.usedIds)
data.usedIds = {};
return data;
}
catch {
return { version: 2, fingerprints: {}, usedIds: {}, history: [] };
}
}
saveCache() {
const dir = dirname(this.cachePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Clean up old entries (older than 7 days)
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
Object.keys(this.cacheData.usedIds).forEach(id => {
const entry = this.cacheData.usedIds[id];
if (entry.lastSeen < weekAgo) {
// Remove from both maps
delete this.cacheData.usedIds[id];
delete this.cacheData.fingerprints[entry.fingerprint];
}
});
writeFileSync(this.cachePath, JSON.stringify(this.cacheData, null, 2));
}
/**
* Get or create stable ID mapping for hunks
*/
mapHunks(filePath, hunks) {
const now = Date.now();
const existingIds = new Set(Object.keys(this.cacheData.usedIds));
const updatedHunks = hunks.map(hunk => {
// Generate content-based fingerprint
const parsedHunk = {
index: hunk.index,
header: hunk.header,
oldStart: hunk.oldStart,
oldLines: hunk.oldLines,
newStart: hunk.newStart,
newLines: hunk.newLines,
changes: hunk.changes,
};
const fingerprint = generateContentFingerprint(parsedHunk, filePath);
// Check if we already have an ID for this fingerprint
if (this.cacheData.fingerprints[fingerprint]) {
const cachedId = this.cacheData.fingerprints[fingerprint];
// Update last seen time
if (this.cacheData.usedIds[cachedId]) {
this.cacheData.usedIds[cachedId].lastSeen = now;
}
return { ...hunk, id: cachedId };
}
// Generate new ID
let length = 4;
let id = fingerprint.substring(0, length);
// Handle collisions by increasing length
while (existingIds.has(id) && length < fingerprint.length) {
length++;
id = fingerprint.substring(0, length);
}
// Store the mapping
this.cacheData.fingerprints[fingerprint] = id;
this.cacheData.usedIds[id] = {
id,
fingerprint,
lastSeen: now,
};
existingIds.add(id);
return { ...hunk, id };
});
this.saveCache();
return updatedHunks;
}
/**
* Find hunk by ID or index
*/
findHunk(hunks, selector) {
if (typeof selector === 'number') {
// Find by index (1-based)
return hunks.find(h => h.index === selector);
}
// selector is a string
const selectorStr = String(selector).trim();
// First try to find by ID
const byId = hunks.find(h => h.id === selectorStr);
if (byId) {
return byId;
}
// If not found by ID, try to parse as number for index lookup
const num = parseInt(selectorStr, 10);
if (!isNaN(num) && String(num) === selectorStr) {
// It's a pure number, find by index
return hunks.find(h => h.index === num);
}
// Not found
return undefined;
}
/**
* Clear the cache
*/
clearCache() {
this.cacheData = { version: 2, fingerprints: {}, usedIds: {}, history: [] };
this.saveCache();
}
/**
* Add a staging history entry
*/
addHistory(entry) {
const historyEntry = {
id: new Date().toISOString(),
timestamp: Date.now(),
...entry,
};
if (!this.cacheData.history) {
this.cacheData.history = [];
}
// Add to beginning of array (newest first)
this.cacheData.history.unshift(historyEntry);
// Keep only last 20 entries
if (this.cacheData.history.length > 20) {
this.cacheData.history = this.cacheData.history.slice(0, 20);
}
this.saveCache();
}
/**
* Get history entries
*/
getHistory() {
return this.cacheData.history || [];
}
/**
* Get specific history entry by index (0 = most recent)
*/
getHistoryEntry(index) {
const history = this.getHistory();
return history[index];
}
/**
* Remove a history entry
*/
removeHistoryEntry(index) {
const history = this.getHistory();
if (index >= 0 && index < history.length) {
history.splice(index, 1);
this.saveCache();
}
}
}