feat(25-06): merge git file service and history endpoint from worktree

Adds gitFileService with commitFile/getLog, wires git commits into
upload flow, adds GET /files/:fileId/history endpoint, and exports
ChatFileHistoryEntry type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-02 00:24:30 +00:00
parent f04badf9f4
commit dae0e3a3be
4 changed files with 131 additions and 0 deletions

View file

@ -623,6 +623,7 @@ export type {
ChatFileReference,
ChatFileUploadResponse,
ChatFileListResponse,
ChatFileHistoryEntry,
ChatPlaceholderEntry,
} from "./types/chat.js";

View file

@ -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;

View file

@ -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);

View file

@ -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<void>;
commitFile(storageDir: string, objectKey: string, message: string): Promise<string | null>;
getLog(
storageDir: string,
objectKey: string,
limit?: number,
): Promise<Array<{ hash: string; date: string; message: string; author: string }>>;
}
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<string | null> {
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 [];
}
},
};
}