1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | /**
* The web editor must not destroy directory structure.
*
* Both editor write paths built their commit by piping `git ls-tree -r` into
* `git mktree`:
*
* ls-tree -r emits a FLAT, recursive listing whose names are full paths
* ("100644 blob <sha>\tsrc/lib/foo.ts")
* mktree builds ONE tree object and expects bare filenames for a
* single directory level
*
* So any repository with a subdirectory produced a rejected or malformed
* tree. Editing or creating a file in a nested repo silently lost the
* structure — data loss on a core feature, and invisible because the handler
* never checked mktree's exit status before running commit-tree.
*
* Both handlers now delegate to createOrUpdateFileOnBranch(), which uses
* read-tree + update-index --cacheinfo + write-tree against an isolated
* temporary index. That handles arbitrary nesting and cannot be stomped by a
* concurrent write.
*
* These are structural: a functional test needs a real bare repo with nested
* directories on disk, and git/repository.ts already owns that behaviour.
* What must not regress is that the editor no longer hand-rolls it.
*/
import { describe, expect, it } from "bun:test";
import { readFileSync } from "fs";
const SRC = readFileSync("src/routes/editor.tsx", "utf8");
// Comments explain the old bug and necessarily contain the strings being
// asserted absent — assert on code, not prose.
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
describe("the editor no longer hand-rolls tree construction", () => {
it("calls git mktree nowhere", () => {
expect(CODE).not.toContain('["git", "mktree"]');
expect(CODE).not.toMatch(/"mktree"/);
});
it("does not feed a recursive ls-tree into a tree builder", () => {
// The specific combination that caused the loss.
expect(CODE).not.toMatch(/"ls-tree",\s*"-r"/);
});
it("does not hand-roll commit-tree either", () => {
// commit-tree on a malformed tree is how this stayed silent: the handler
// never checked mktree's exit code before committing.
expect(CODE).not.toContain("commit-tree");
});
});
describe("both write paths use the shared helper", () => {
it("create-file and edit-file both call createOrUpdateFileOnBranch", () => {
const calls = CODE.match(/createOrUpdateFileOnBranch\(\{/g) ?? [];
expect(calls.length).toBe(2);
});
it("both surface a write failure instead of redirecting to a broken blob", () => {
// Previously a failed write still redirected to the file view, which then
// 404'd or showed stale content — the user had no idea it had not saved.
const guards = CODE.match(/if \("error" in written\)/g) ?? [];
expect(guards.length).toBe(2);
});
it("distinguishes a concurrent edit from a write failure", () => {
expect(CODE).toContain('written.error === "sha-mismatch"');
});
});
describe("the helper it delegates to handles nesting", () => {
const REPO = readFileSync("src/git/repository.ts", "utf8");
const start = REPO.indexOf("export async function createOrUpdateFileOnBranch");
// To the next top-level export, not a fixed char count — 3000 chars stopped
// short of write-tree and the assertion failed on a correct implementation.
const nextExport = REPO.indexOf("\nexport ", start + 10);
const fn = REPO.slice(start, nextExport > -1 ? nextExport : REPO.length);
it("seeds an index from the parent tree rather than rebuilding one flat tree", () => {
expect(fn).toContain('"git", "read-tree"');
expect(fn).toContain("update-index");
expect(fn).toContain("write-tree");
});
it("uses an isolated index so concurrent writes cannot stomp each other", () => {
expect(fn).toContain("GIT_INDEX_FILE");
});
});
|