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:
parent
f04badf9f4
commit
dae0e3a3be
4 changed files with 131 additions and 0 deletions
|
|
@ -623,6 +623,7 @@ export type {
|
|||
ChatFileReference,
|
||||
ChatFileUploadResponse,
|
||||
ChatFileListResponse,
|
||||
ChatFileHistoryEntry,
|
||||
ChatPlaceholderEntry,
|
||||
} from "./types/chat.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
101
server/src/services/git-file-service.ts
Normal file
101
server/src/services/git-file-service.ts
Normal 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 [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue