--- phase: 25-file-system plan: 06 type: execute wave: 1 depends_on: ["25-01"] files_modified: - server/src/services/git-file-service.ts - server/src/routes/chat-files.ts - server/src/services/chat-files.ts - packages/shared/src/types/chat.ts - .planning/REQUIREMENTS.md autonomous: true gap_closure: true requirements: [FILE-09, FILE-10] must_haves: truths: - "Every file upload creates a git commit in the storage directory" - "User can view the git log (version history) for any file" artifacts: - path: "server/src/services/git-file-service.ts" provides: "Git operations: init, commit, log for file versioning" min_lines: 50 - path: "server/src/routes/chat-files.ts" provides: "GET /files/:fileId/history endpoint" - path: "packages/shared/src/types/chat.ts" provides: "ChatFileHistoryEntry type" key_links: - from: "server/src/routes/chat-files.ts" to: "server/src/services/git-file-service.ts" via: "gitFileService.commitFile and gitFileService.getLog" pattern: "gitFileService" - from: "server/src/routes/chat-files.ts" to: "server/src/services/chat-files.ts" via: "fileSvc.getById for file lookup" pattern: "fileSvc.getById" --- Add git versioning for file operations and version history viewing. Purpose: FILE-09 requires every file operation to produce a git commit; FILE-10 requires users to view git log for any file. The current StorageService stores files as object-keyed blobs with no git tracking. This plan creates a gitFileService that wraps git CLI commands (init, add, commit, log) operating on the storage directory, and hooks it into the upload flow. A new GET /files/:fileId/history endpoint returns the git log for a file's object key. Output: gitFileService, updated upload route with git commit, history endpoint, ChatFileHistoryEntry type @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/25-file-system/25-01-SUMMARY.md From server/src/storage/types.ts: ```typescript export interface StorageService { provider: StorageProviderId; putFile(input: PutFileInput): Promise; getObject(companyId: string, objectKey: string): Promise; } ``` From server/src/routes/chat-files.ts: ```typescript export function chatFileRoutes(db: Db, storage: StorageService) { // POST /conversations/:id/files -- upload // GET /files/:fileId/content -- download } ``` Task 1: Create gitFileService and ChatFileHistoryEntry type server/src/services/git-file-service.ts, packages/shared/src/types/chat.ts, packages/shared/src/index.ts - server/src/storage/local-disk-provider.ts - server/src/home-paths.ts - packages/shared/src/types/chat.ts - packages/shared/src/index.ts 1. Add `ChatFileHistoryEntry` type to `packages/shared/src/types/chat.ts`: ```typescript export interface ChatFileHistoryEntry { hash: string; date: string; message: string; author: string; } ``` Export it from `packages/shared/src/index.ts` alongside other chat types. 2. Create `server/src/services/git-file-service.ts`: Use `node:child_process` `execFile` (NOT `exec`) to prevent shell injection. All arguments are passed as array elements, never interpolated into a shell string. The `objectKey` is always a StorageService-generated path (companyId/namespace/date/uuid-filename) so it contains no shell metacharacters, but using execFile provides defense-in-depth. ```typescript 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 []; } }, }; } ``` cd /opt/nexus && npx tsc --noEmit -p server/tsconfig.json 2>&1 | head -10 && grep "ChatFileHistoryEntry" packages/shared/src/types/chat.ts packages/shared/src/index.ts - File `server/src/services/git-file-service.ts` exists - Contains `export function gitFileService(): GitFileService` - Contains `ensureRepo`, `commitFile`, `getLog` methods - Uses `execFile` from `node:child_process` (NOT `exec`) -- promisified via `node:util` - Does NOT use template string interpolation for shell commands - `packages/shared/src/types/chat.ts` contains `export interface ChatFileHistoryEntry` - `packages/shared/src/index.ts` exports `ChatFileHistoryEntry` gitFileService created with init/commit/log operations using safe execFile; ChatFileHistoryEntry type exported from shared Task 2: Wire git commits into upload flow and add history endpoint server/src/routes/chat-files.ts, .planning/REQUIREMENTS.md - server/src/routes/chat-files.ts - server/src/services/git-file-service.ts - server/src/storage/local-disk-provider.ts - server/src/home-paths.ts - .planning/REQUIREMENTS.md 1. Update `server/src/routes/chat-files.ts`: a. Import gitFileService: ```typescript import { gitFileService } from "../services/git-file-service.js"; ``` Also import `path` from `node:path`. b. Determine the storage directory. Read `server/src/storage/local-disk-provider.ts` to find how it resolves the base directory. Read `server/src/home-paths.ts` to understand how the root is resolved. Inside `chatFileRoutes` function body, compute the storage root path that matches where LocalDiskProvider writes files. For example: ```typescript import { getHomePath } from "../home-paths.js"; // Adapt to match local-disk-provider.ts directory construction const storageDir = path.join(getHomePath(), "data", "storage"); ``` **IMPORTANT**: Read local-disk-provider.ts first and use the EXACT same path construction it uses. Do not guess. Inside `chatFileRoutes`: ```typescript const gitSvc = gitFileService(); ``` c. After the `storage.putFile()` call and DB record creation in POST /conversations/:id/files, add a git commit (fire-and-forget to not block the response): ```typescript // Git-track the uploaded file (non-blocking) gitSvc.commitFile(storageDir, stored.objectKey, `Upload: ${file.originalname ?? "file"}`).catch(() => {}); ``` d. Add a new route for version history, placed near the GET /files/:fileId/content route: ```typescript // 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 }); }); ``` Place this BEFORE the `/files/:fileId/content` route so Express matches `/files/:fileId/history` before any catch-all. 2. Update `.planning/REQUIREMENTS.md`: - Change FILE-09 from `- [ ] **FILE-09**` to `- [x] **FILE-09**` - Change FILE-10 from `- [ ] **FILE-10**` to `- [x] **FILE-10**` - In Traceability table, change FILE-09 and FILE-10 status from `Pending` to `Complete` cd /opt/nexus && grep -n "gitFileService\|gitSvc\|commitFile\|getLog\|/history" server/src/routes/chat-files.ts && grep "FILE-09\|FILE-10" .planning/REQUIREMENTS.md | head -6 - `server/src/routes/chat-files.ts` imports `gitFileService` - Contains `gitSvc.commitFile(storageDir` call after `storage.putFile` - Contains `router.get("/files/:fileId/history"` route - History route calls `gitSvc.getLog` and returns `{ items: entries }` - `.planning/REQUIREMENTS.md` contains `- [x] **FILE-09**` - `.planning/REQUIREMENTS.md` contains `- [x] **FILE-10**` File uploads create git commits; version history available via GET /files/:fileId/history; FILE-09 and FILE-10 marked Complete - `npx tsc --noEmit -p server/tsconfig.json` passes - `grep "commitFile" server/src/routes/chat-files.ts` matches - `grep "/history" server/src/routes/chat-files.ts` matches - `grep "\[x\].*FILE-09" .planning/REQUIREMENTS.md` matches - `grep "\[x\].*FILE-10" .planning/REQUIREMENTS.md` matches - File upload creates a git commit in the storage directory - GET /files/:fileId/history returns git log entries for a file - TypeScript compiles without errors - FILE-09 and FILE-10 marked Complete in REQUIREMENTS.md After completion, create `.planning/phases/25-file-system/25-06-SUMMARY.md`