lib/git-session-attribution.js
Rama main · réplica de lectura del repositorio original.
// Connect working-tree changes to the Clay sessions that were active while they changed.
var crypto = require("crypto");
var fs = require("fs");
var path = require("path");
var config = require("./config");
var gitCli = require("./git-cli");
var utils = require("./utils");
var MAX_BASELINE_BYTES = 2 * 1024 * 1024;
var MAX_DIGEST_BYTES = 8 * 1024 * 1024;
var MAX_SESSION_RECORDS = 80;
function attachGitSessionAttribution(options) {
var cwd = options.cwd;
var getOsUserInfoForSession = options.getOsUserInfoForSession || function () { return null; };
var storageDir = options.storageDir || path.join(config.CONFIG_DIR, "git-attribution");
var storagePath = path.join(storageDir, utils.encodeCwd(cwd) + ".json");
var state = loadState();
function loadState() {
try {
var parsed = JSON.parse(fs.readFileSync(storagePath, "utf8"));
if (parsed && parsed.version === 1 && parsed.sessions) return parsed;
} catch (e) {}
return { version: 1, sessions: {} };
}
function saveState() {
try {
fs.mkdirSync(storageDir, { recursive: true });
var keys = Object.keys(state.sessions).sort(function (left, right) {
return (state.sessions[right].lastChangedAt || 0) - (state.sessions[left].lastChangedAt || 0);
});
for (var i = MAX_SESSION_RECORDS; i < keys.length; i++) delete state.sessions[keys[i]];
var temporaryPath = storagePath + ".tmp." + process.pid;
fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + "\n");
if (process.platform !== "win32") {
try { fs.chmodSync(temporaryPath, 0o600); } catch (chmodError) {}
}
fs.renameSync(temporaryPath, storagePath);
} catch (e) {
console.error("[git-attribution] Unable to save session change data:", e.message);
}
}
function sessionKey(session) {
return session.cliSessionId || ("local:" + session.localId);
}
function digestPath(root, file) {
var absolutePath = path.resolve(root, file.path);
var rootPrefix = path.resolve(root) + path.sep;
if (absolutePath.indexOf(rootPrefix) !== 0) return "invalid:" + file.code;
try {
var stat = fs.lstatSync(absolutePath);
var hash = crypto.createHash("sha256");
hash.update(file.code || "");
if (stat.isSymbolicLink()) hash.update(fs.readlinkSync(absolutePath));
else if (stat.isFile() && stat.size <= MAX_DIGEST_BYTES) hash.update(fs.readFileSync(absolutePath));
else if (stat.isFile()) hash.update("large:" + stat.size + ":" + stat.mtimeMs);
else hash.update("non-file");
return hash.digest("hex");
} catch (e) {
return "missing:" + (file.code || "");
}
}
function readBaseline(root, file) {
var absolutePath = path.resolve(root, file.path);
var rootPrefix = path.resolve(root) + path.sep;
if (absolutePath.indexOf(rootPrefix) !== 0) return null;
try {
var stat = fs.lstatSync(absolutePath);
var buffer;
if (stat.isSymbolicLink()) buffer = Buffer.from(fs.readlinkSync(absolutePath), "utf8");
else if (stat.isFile() && stat.size <= MAX_BASELINE_BYTES) buffer = fs.readFileSync(absolutePath);
else return null;
return { content: buffer.toString("base64"), binary: buffer.indexOf(0) !== -1 };
} catch (e) {
return { missing: true };
}
}
function captureSnapshot(includeBaseline, osUserInfo) {
var status = gitCli.getStatus(cwd, osUserInfo);
if (!status.isRepository) return null;
var files = {};
for (var i = 0; i < status.files.length; i++) {
var file = status.files[i];
files[file.path] = {
fingerprint: digestPath(status.root, file),
baseline: includeBaseline ? readBaseline(status.root, file) : undefined,
};
}
return { root: status.root, head: status.oid || null, files: files, capturedAt: Date.now() };
}
function beginTurn(session) {
if (!session || session._gitAttributionTurn) return;
try {
var osUserInfo = getOsUserInfoForSession(session);
var snapshot = captureSnapshot(false, osUserInfo);
if (!snapshot) return;
var key = sessionKey(session);
var oldLocalKey = "local:" + session.localId;
if (key !== oldLocalKey && state.sessions[oldLocalKey] && !state.sessions[key]) {
state.sessions[key] = state.sessions[oldLocalKey];
delete state.sessions[oldLocalKey];
}
if (!state.sessions[key]) {
var baseline = captureSnapshot(true, osUserInfo);
state.sessions[key] = {
key: key,
title: session.title || "Untitled session",
vendor: session.vendor || null,
startedAt: Date.now(),
baseline: baseline,
changedPaths: {},
};
saveState();
}
session._gitAttributionTurn = snapshot;
session._gitAttributionOsUserInfo = osUserInfo;
} catch (e) {
console.error("[git-attribution] Unable to capture turn start:", e.message);
}
}
function finishTurn(session) {
if (!session || !session._gitAttributionTurn) return;
var before = session._gitAttributionTurn;
session._gitAttributionTurn = null;
try {
var after = captureSnapshot(false, session._gitAttributionOsUserInfo);
session._gitAttributionOsUserInfo = null;
if (!after) return;
var key = sessionKey(session);
var record = state.sessions[key] || state.sessions["local:" + session.localId];
if (!record) return;
record.title = session.title || record.title;
record.vendor = session.vendor || record.vendor;
var paths = {};
Object.keys(before.files).forEach(function (filePath) { paths[filePath] = true; });
Object.keys(after.files).forEach(function (filePath) { paths[filePath] = true; });
var changedAt = Date.now();
Object.keys(paths).forEach(function (filePath) {
var oldFingerprint = before.files[filePath] ? before.files[filePath].fingerprint : null;
var newFingerprint = after.files[filePath] ? after.files[filePath].fingerprint : null;
if (oldFingerprint !== newFingerprint) record.changedPaths[filePath] = changedAt;
});
record.lastChangedAt = changedAt;
saveState();
} catch (e) {
console.error("[git-attribution] Unable to capture turn result:", e.message);
}
}
function decorateStatus(status, sessions) {
if (!status || !status.isRepository) return status;
var liveByKey = {};
sessions.forEach(function (session) {
liveByKey[sessionKey(session)] = session;
liveByKey["local:" + session.localId] = session;
});
for (var i = 0; i < status.files.length; i++) {
var file = status.files[i];
var matches = [];
Object.keys(state.sessions).forEach(function (key) {
var record = state.sessions[key];
if (!record.changedPaths || !record.changedPaths[file.path]) return;
var live = liveByKey[key];
if (!live) return;
matches.push({
key: key,
sessionId: live ? live.localId : null,
title: live ? (live.title || record.title) : record.title,
vendor: record.vendor || null,
changedAt: record.changedPaths[file.path],
preExisting: !!(record.baseline && record.baseline.files && record.baseline.files[file.path]),
});
});
matches.sort(function (left, right) { return right.changedAt - left.changedAt; });
file.sessions = matches.slice(0, 4);
}
return status;
}
function getSessionBaselineDiff(key, filePath, osUserInfo) {
var record = state.sessions[key];
if (!record || !record.baseline) throw new Error("Session baseline is unavailable");
var current = gitCli.readWorkingTreeFile(record.baseline.root || cwd, filePath);
var saved = record.baseline.files && record.baseline.files[filePath];
var oldFile;
if (saved && saved.baseline) {
oldFile = saved.baseline.missing
? { content: "", binary: false }
: { content: Buffer.from(saved.baseline.content || "", "base64").toString("utf8"), binary: !!saved.baseline.binary };
} else {
oldFile = gitCli.getFileAtCommit(record.baseline.root || cwd, record.baseline.head, filePath, osUserInfo);
}
return {
path: filePath,
oldContent: oldFile.binary ? "" : oldFile.content,
newContent: current.binary ? "" : current.content,
binary: oldFile.binary || current.binary,
sessionTitle: record.title,
};
}
return {
beginTurn: beginTurn,
decorateStatus: decorateStatus,
finishTurn: finishTurn,
getSessionBaselineDiff: getSessionBaselineDiff,
};
}
module.exports = { attachGitSessionAttribution: attachGitSessionAttribution };