diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 60426490..3da4e36b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -623,6 +623,7 @@ export type { ChatFileReference, ChatFileUploadResponse, ChatFileListResponse, + ChatFileHistoryEntry, ChatPlaceholderEntry, } from "./types/chat.js"; diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 622b48c5..36ff15e3 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -118,6 +118,13 @@ export interface ChatBookmarkToggleResponse { bookmarked: boolean; } +export interface ChatFileHistoryEntry { + hash: string; + date: string; + message: string; + author: string; +} + export interface ChatPlaceholderEntry { fileId: string; filename: string; diff --git a/server/src/routes/chat-files.ts b/server/src/routes/chat-files.ts index d7d26daa..5c7f993f 100644 --- a/server/src/routes/chat-files.ts +++ b/server/src/routes/chat-files.ts @@ -9,6 +9,7 @@ import { chatService } from "../services/chat.js"; import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { placeholderService } from "../services/placeholder-service.js"; +import { gitFileService } from "../services/git-file-service.js"; import { resolveDefaultStorageDir } from "../home-paths.js"; const fileUpload = multer({ @@ -34,6 +35,7 @@ export function chatFileRoutes(db: Db, storage: StorageService) { const fileSvc = chatFileService(db); const convSvc = chatService(db); const phSvc = placeholderService(); + const gitSvc = gitFileService(); const storageDir = resolveDefaultStorageDir(); // POST /conversations/:id/files — Upload a file to a conversation @@ -100,6 +102,9 @@ export function chatFileRoutes(db: Db, storage: StorageService) { projectId: parsedMeta.data.projectId ?? null, }); + // Git-track the uploaded file (non-blocking) + gitSvc.commitFile(storageDir, stored.objectKey, `Upload: ${file.originalname ?? "file"}`).catch(() => {}); + // Track placeholder if agent-generated and project-scoped if (parsedMeta.data.source === "agent_generated" && chatFile.projectId) { const projectDir = path.join(storageDir, "..", "..", "projects", chatFile.projectId); @@ -128,6 +133,23 @@ export function chatFileRoutes(db: Db, storage: StorageService) { res.json({ items: files }); }); + // GET /files/:fileId/history — Git version history for a file + router.get("/files/:fileId/history", async (req, res) => { + assertBoard(req); + + const fileId = req.params.fileId as string; + const chatFile = await fileSvc.getById(fileId); + if (!chatFile) { + res.status(404).json({ error: "File not found" }); + return; + } + assertCompanyAccess(req, chatFile.companyId); + + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const entries = await gitSvc.getLog(storageDir, chatFile.objectKey, limit); + res.json({ items: entries }); + }); + // GET /files/:fileId/content — Serve file content (download/preview) router.get("/files/:fileId/content", async (req, res, next) => { assertBoard(req); diff --git a/server/src/services/git-file-service.ts b/server/src/services/git-file-service.ts new file mode 100644 index 00000000..a017f36b --- /dev/null +++ b/server/src/services/git-file-service.ts @@ -0,0 +1,101 @@ +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +const execFile = promisify(execFileCb); + +export interface GitFileService { + ensureRepo(storageDir: string): Promise; + commitFile(storageDir: string, objectKey: string, message: string): Promise; + getLog( + storageDir: string, + objectKey: string, + limit?: number, + ): Promise>; +} + +export function gitFileService(): GitFileService { + async function git( + cwd: string, + args: string[], + ): Promise<{ stdout: string; stderr: string }> { + return execFile("git", args, { cwd, timeout: 10000 }); + } + + return { + async ensureRepo(storageDir: string) { + const gitDir = path.join(storageDir, ".git"); + if (existsSync(gitDir)) return; + await git(storageDir, ["init"]); + await git(storageDir, ["config", "user.email", "nexus@local"]); + await git(storageDir, ["config", "user.name", "Nexus File System"]); + }, + + async commitFile( + storageDir: string, + objectKey: string, + message: string, + ): Promise { + const filePath = path.join(storageDir, objectKey); + if (!existsSync(filePath)) return null; + + await this.ensureRepo(storageDir); + + try { + await git(storageDir, ["add", "--", objectKey]); + const { stdout } = await git(storageDir, [ + "commit", + "-m", + message, + "--", + objectKey, + ]); + const match = /\[[\w\s]+\s([a-f0-9]+)\]/.exec(stdout); + return match?.[1] ?? null; + } catch (err) { + const errMsg = String( + (err as { stderr?: string }).stderr ?? + (err as Error).message ?? + "", + ); + if ( + errMsg.includes("nothing to commit") || + errMsg.includes("no changes added") + ) { + return null; + } + throw err; + } + }, + + async getLog(storageDir: string, objectKey: string, limit = 50) { + try { + await this.ensureRepo(storageDir); + const { stdout } = await git(storageDir, [ + "log", + `--max-count=${limit}`, + "--format=%H|%aI|%s|%an", + "--", + objectKey, + ]); + + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [hash, date, message, author] = line.split("|"); + return { + hash: hash!, + date: date!, + message: message!, + author: author!, + }; + }); + } catch { + return []; + } + }, + }; +}