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,
|
ChatFileReference,
|
||||||
ChatFileUploadResponse,
|
ChatFileUploadResponse,
|
||||||
ChatFileListResponse,
|
ChatFileListResponse,
|
||||||
|
ChatFileHistoryEntry,
|
||||||
ChatPlaceholderEntry,
|
ChatPlaceholderEntry,
|
||||||
} from "./types/chat.js";
|
} from "./types/chat.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,13 @@ export interface ChatBookmarkToggleResponse {
|
||||||
bookmarked: boolean;
|
bookmarked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatFileHistoryEntry {
|
||||||
|
hash: string;
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatPlaceholderEntry {
|
export interface ChatPlaceholderEntry {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { chatService } from "../services/chat.js";
|
||||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
import { placeholderService } from "../services/placeholder-service.js";
|
import { placeholderService } from "../services/placeholder-service.js";
|
||||||
|
import { gitFileService } from "../services/git-file-service.js";
|
||||||
import { resolveDefaultStorageDir } from "../home-paths.js";
|
import { resolveDefaultStorageDir } from "../home-paths.js";
|
||||||
|
|
||||||
const fileUpload = multer({
|
const fileUpload = multer({
|
||||||
|
|
@ -34,6 +35,7 @@ export function chatFileRoutes(db: Db, storage: StorageService) {
|
||||||
const fileSvc = chatFileService(db);
|
const fileSvc = chatFileService(db);
|
||||||
const convSvc = chatService(db);
|
const convSvc = chatService(db);
|
||||||
const phSvc = placeholderService();
|
const phSvc = placeholderService();
|
||||||
|
const gitSvc = gitFileService();
|
||||||
const storageDir = resolveDefaultStorageDir();
|
const storageDir = resolveDefaultStorageDir();
|
||||||
|
|
||||||
// POST /conversations/:id/files — Upload a file to a conversation
|
// 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,
|
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
|
// Track placeholder if agent-generated and project-scoped
|
||||||
if (parsedMeta.data.source === "agent_generated" && chatFile.projectId) {
|
if (parsedMeta.data.source === "agent_generated" && chatFile.projectId) {
|
||||||
const projectDir = path.join(storageDir, "..", "..", "projects", 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 });
|
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)
|
// GET /files/:fileId/content — Serve file content (download/preview)
|
||||||
router.get("/files/:fileId/content", async (req, res, next) => {
|
router.get("/files/:fileId/content", async (req, res, next) => {
|
||||||
assertBoard(req);
|
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