var path = require("path"); var execFileSync = require("child_process").execFileSync; var wrapSpawnAsUser = require("./os-users").wrapSpawnAsUser; function attachFileHistory(ctx, access) { var cwd = ctx.cwd; var sm = ctx.sm; var sendTo = ctx.sendTo; function runGit(ws, args, options) { options = Object.assign({}, options, { stdio: ["ignore", "pipe", "pipe"] }); var identity = access.osIdentity(ws); if (identity) options = Object.assign({}, options, { uid: identity.uid, gid: identity.gid }); var command = wrapSpawnAsUser("git", args, options); return execFileSync(command.command, command.args, command.options); } function handleFileHistory(ws, msg) { function reply(message) { sendTo(ws, Object.assign({ instanceId: msg.instanceId, requestId: msg.requestId }, message)); } if (!/^fs_(file_history|git_diff|file_at)$/.test(msg.type)) return false; if (typeof msg.path !== "string" || !msg.path || path.isAbsolute(msg.path) || path.relative(cwd, path.resolve(cwd, msg.path)).split(path.sep).indexOf("..") !== -1 || (msg.hash && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash)) || (msg.hash2 && !/^[a-fA-F0-9]{4,64}$/.test(msg.hash2))) { reply({ type: msg.type + "_result", path: msg.path, error: "Invalid file history request" }); return true; } // --- File edit history --- if (msg.type === "fs_file_history") { var histPath = msg.path; if (!histPath) { reply({ type: "fs_file_history_result", path: histPath, entries: [] }); return true; } var absHistPath = path.resolve(cwd, histPath); var entries = []; // Collect session edits sm.sessions.forEach(function (session) { if (!access.canReadSession(ws, session)) return; var sessionLocalId = session.localId; var sessionTitle = session.title || "Untitled"; var histLen = session.history.length || 1; for (var hi = 0; hi < session.history.length; hi++) { var entry = session.history[hi]; if (entry.type !== "tool_executing") continue; if (entry.name !== "Edit" && entry.name !== "Write") continue; if (!entry.input || !entry.input.file_path) continue; if (entry.input.file_path !== absHistPath) continue; // Find parent assistant UUID + message snippet by scanning backwards var assistantUuid = null; var uuidIndex = -1; for (var hj = hi - 1; hj >= 0; hj--) { if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") { assistantUuid = session.history[hj].uuid; uuidIndex = hj; break; } } // Find user prompt by scanning backwards from the assistant uuid var messageSnippet = ""; var searchFrom = uuidIndex >= 0 ? uuidIndex : hi; for (var hk = searchFrom - 1; hk >= 0; hk--) { if (session.history[hk].type === "user_message" && session.history[hk].text) { messageSnippet = session.history[hk].text.trim().substring(0, 100); break; } } // Collect Claude's explanation: scan backwards from tool_executing // to find the nearest delta text block (skipping tool_start). // If no delta found immediately before this tool, scan past // intervening tool blocks to find the last delta text within // the same assistant turn. var assistantSnippet = ""; var deltaChunks = []; for (var hd = hi - 1; hd >= 0; hd--) { var hEntry = session.history[hd]; if (hEntry.type === "tool_start") continue; if (hEntry.type === "delta" && hEntry.text) { deltaChunks.unshift(hEntry.text); } else { break; } } if (deltaChunks.length === 0) { // No delta immediately before; scan past tool blocks // to find the nearest preceding delta in the same turn for (var hd2 = hi - 1; hd2 >= 0; hd2--) { var hEntry2 = session.history[hd2]; if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue; if (hEntry2.type === "delta" && hEntry2.text) { // Found a delta before an earlier tool in the same turn. // Collect this contiguous block of deltas. for (var hd3 = hd2; hd3 >= 0; hd3--) { var hEntry3 = session.history[hd3]; if (hEntry3.type === "tool_start") continue; if (hEntry3.type === "delta" && hEntry3.text) { deltaChunks.unshift(hEntry3.text); } else { break; } } break; } else { // Hit message_uuid, user_message, etc. Stop. break; } } } assistantSnippet = deltaChunks.join("").trim().substring(0, 150); // Approximate timestamp: interpolate between session creation and last activity var tStart = session.createdAt || 0; var tEnd = session.lastActivity || tStart; var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart)); var editRecord = { source: "session", timestamp: ts, sessionLocalId: sessionLocalId, sessionTitle: sessionTitle, assistantUuid: assistantUuid, toolId: entry.id, messageSnippet: messageSnippet, assistantSnippet: assistantSnippet, toolName: entry.name, }; if (entry.name === "Edit") { editRecord.old_string = entry.input.old_string || ""; editRecord.new_string = entry.input.new_string || ""; } else { editRecord.isFullWrite = true; if (typeof entry.input.content === "string") editRecord.content = entry.input.content; } entries.push(editRecord); } }); // Collect git commits try { var gitLog = runGit(ws, ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath], { cwd: cwd, encoding: "utf8", timeout: 5000 } ); var gitLines = gitLog.trim().split("\n"); for (var gi = 0; gi < gitLines.length; gi++) { if (!gitLines[gi]) continue; var parts = gitLines[gi].split("|"); if (parts.length < 4) continue; entries.push({ source: "git", hash: parts[0], timestamp: parseInt(parts[1], 10) * 1000, author: parts[2], message: parts.slice(3).join("|"), }); } } catch (e) { // Not a git repo or file not tracked, that is fine } // Sort by timestamp descending (newest first) entries.sort(function (a, b) { return b.timestamp - a.timestamp; }); reply({ type: "fs_file_history_result", path: histPath, entries: entries }); return true; } // --- Git diff for file history --- if (msg.type === "fs_git_diff") { var diffPath = msg.path; var hash = msg.hash; var hash2 = msg.hash2 || null; if (!diffPath || !hash) { reply({ type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" }); return true; } try { var diff; if (hash2) { diff = runGit(ws, ["diff", hash, hash2, "--", diffPath], { cwd: cwd, encoding: "utf8", timeout: 5000 }); } else { diff = runGit(ws, ["show", hash, "--format=", "--", diffPath], { cwd: cwd, encoding: "utf8", timeout: 5000 }); } reply({ type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" }); } catch (e) { reply({ type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message }); } return true; } // --- File content at a git commit --- if (msg.type === "fs_file_at") { var atPath = msg.path; var atHash = msg.hash; if (!atPath || !atHash) { reply({ type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" }); return true; } try { // Convert to repo-relative path (git show requires hash:relative/path) var atAbsPath = path.resolve(cwd, atPath); var atRelPath = path.relative(cwd, atAbsPath); var content = runGit(ws, ["show", atHash + ":" + atRelPath], { cwd: cwd, encoding: "utf8", timeout: 5000 }); reply({ type: "fs_file_at_result", hash: atHash, path: atPath, content: content }); } catch (e) { reply({ type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message }); } return true; } return false; } return { handleFileHistory: handleFileHistory }; } module.exports = { attachFileHistory: attachFileHistory };