nexus/.planning/phases/25-file-system/25-06-PLAN.md

11 KiB

phase plan type wave depends_on files_modified autonomous gap_closure requirements must_haves
25-file-system 06 execute 1
25-01
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
true true
FILE-09
FILE-10
truths artifacts key_links
Every file upload creates a git commit in the storage directory
User can view the git log (version history) for any file
path provides min_lines
server/src/services/git-file-service.ts Git operations: init, commit, log for file versioning 50
path provides
server/src/routes/chat-files.ts GET /files/:fileId/history endpoint
path provides
packages/shared/src/types/chat.ts ChatFileHistoryEntry type
from to via pattern
server/src/routes/chat-files.ts server/src/services/git-file-service.ts gitFileService.commitFile and gitFileService.getLog gitFileService
from to via pattern
server/src/routes/chat-files.ts server/src/services/chat-files.ts fileSvc.getById for file lookup 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

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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.
  1. 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.

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 [];
      }
    },
  };
}
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:

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:

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:

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):

// 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:

// 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.

  1. 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 <acceptance_criteria>
    • 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** </acceptance_criteria> 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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/25-file-system/25-06-SUMMARY.md`