Blame · Line-by-line history
index-existing-repo.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 5d13cf5 | 1 | /** |
| 2 | * Repositories that arrive without a push must still be indexed. | |
| 3 | * | |
| 4 | * indexChangedFiles() is called from exactly ONE place — the git post-receive | |
| 5 | * hook. That is correct for pushes and wrong for every other way a repository | |
| 6 | * comes into existence: | |
| 7 | * | |
| 8 | * - bulk import CLONES (git clone fires no post-receive hook) | |
| 9 | * - POST /new + first-repo-scaffold writes via the git plumbing API | |
| 10 | * - forks copy the bare repo directly | |
| 11 | * | |
| 12 | * So both onboarding paths built in this session produced repositories where | |
| 13 | * gluecron_semantic_search, repo chat and "ask this repo" returned nothing. | |
| 14 | * Silently — an empty index is indistinguishable from "no matches", which is | |
| 15 | * why it survived. It was found while wiring the agent-first path, which | |
| 16 | * depends entirely on that index being populated. | |
| 17 | * | |
| 18 | * Deps are injected rather than mock.module()'d: ../git/repository is imported | |
| 19 | * by dozens of test files and a module mock leaks across the whole run. | |
| 20 | */ | |
| 21 | ||
| 22 | import { describe, expect, it } from "bun:test"; | |
| 23 | import { readFileSync } from "fs"; | |
| 24 | ||
| 25 | const SRC = readFileSync("src/lib/index-existing-repo.ts", "utf8"); | |
| 26 | ||
| 27 | type Entry = { name: string; type: string; path?: string }; | |
| 28 | ||
| 29 | function treeFrom(map: Record<string, Entry[]>) { | |
| 30 | return async (_o: string, _r: string, _ref: string, path?: string) => | |
| 31 | map[path ?? ""] ?? []; | |
| 32 | } | |
| 33 | ||
| 34 | describe("tree walking", () => { | |
| 35 | it("collects blobs across nested directories", async () => { | |
| 36 | const captured: string[][] = []; | |
| 37 | const { indexExistingRepo } = await import("../lib/index-existing-repo"); | |
| 38 | const r = await indexExistingRepo( | |
| 39 | { repositoryId: "r1", owner: "a", repoName: "b", ref: "main", commitSha: "sha" }, | |
| 40 | { | |
| 41 | getTree: treeFrom({ | |
| 42 | "": [ | |
| 43 | { name: "index.ts", type: "blob", path: "index.ts" }, | |
| 44 | { name: "src", type: "tree", path: "src" }, | |
| 45 | ], | |
| 46 | src: [{ name: "app.ts", type: "blob", path: "src/app.ts" }], | |
| 47 | }), | |
| 48 | indexChangedFiles: async (a) => { | |
| 49 | captured.push(a.changedPaths); | |
| 50 | return { indexed: a.changedPaths.length, skipped: 0, model: "test" }; | |
| 51 | }, | |
| 52 | } | |
| 53 | ); | |
| 54 | expect(r.indexed).toBe(2); | |
| 55 | expect(captured[0].sort()).toEqual(["index.ts", "src/app.ts"]); | |
| 56 | }); | |
| 57 | ||
| 58 | it("skips vendor and build directories", async () => { | |
| 59 | let seen: string[] = []; | |
| 60 | const { indexExistingRepo } = await import("../lib/index-existing-repo"); | |
| 61 | await indexExistingRepo( | |
| 62 | { repositoryId: "r1", owner: "a", repoName: "b", ref: "main", commitSha: "sha" }, | |
| 63 | { | |
| 64 | getTree: treeFrom({ | |
| 65 | "": [ | |
| 66 | { name: "node_modules", type: "tree", path: "node_modules" }, | |
| 67 | { name: "dist", type: "tree", path: "dist" }, | |
| 68 | { name: "keep.ts", type: "blob", path: "keep.ts" }, | |
| 69 | ], | |
| 70 | node_modules: [{ name: "junk.js", type: "blob", path: "node_modules/junk.js" }], | |
| 71 | dist: [{ name: "out.js", type: "blob", path: "dist/out.js" }], | |
| 72 | }), | |
| 73 | indexChangedFiles: async (a) => { | |
| 74 | seen = a.changedPaths; | |
| 75 | return { indexed: a.changedPaths.length, skipped: 0, model: "test" }; | |
| 76 | }, | |
| 77 | } | |
| 78 | ); | |
| 79 | expect(seen).toEqual(["keep.ts"]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("delegates filtering to indexChangedFiles rather than duplicating it", () => { | |
| 83 | // isCodeFile, the per-push cap and the embedding model all live in | |
| 84 | // semantic-index.ts. Re-implementing them here would let scaffolded and | |
| 85 | // pushed repos drift apart in what they index. | |
| 86 | // | |
| 87 | // Comments stripped: the source explains this delegation in prose that | |
| 88 | // names the very symbol being asserted absent. | |
| 89 | const code = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); | |
| 90 | expect(code).toContain("indexChangedFiles"); | |
| 91 | expect(code).not.toContain("isCodeFile"); | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | describe("fault isolation", () => { | |
| 96 | it("never throws when the tree read fails", async () => { | |
| 97 | const { indexExistingRepo } = await import("../lib/index-existing-repo"); | |
| 98 | const r = await indexExistingRepo( | |
| 99 | { repositoryId: "r1", owner: "a", repoName: "b", ref: "main", commitSha: "sha" }, | |
| 100 | { | |
| 101 | getTree: async () => { | |
| 102 | throw new Error("disk on fire"); | |
| 103 | }, | |
| 104 | indexChangedFiles: async () => ({ indexed: 0, skipped: 0, model: "test" }), | |
| 105 | } | |
| 106 | ); | |
| 107 | // A single unreadable directory must not abandon the index, and an | |
| 108 | // unindexed repo must never fail an import or a repo creation. | |
| 109 | expect(r.indexed).toBe(0); | |
| 110 | expect(r.error).toBeUndefined(); | |
| 111 | }); | |
| 112 | ||
| 113 | it("reports an embedding failure instead of throwing", async () => { | |
| 114 | const { indexExistingRepo } = await import("../lib/index-existing-repo"); | |
| 115 | const r = await indexExistingRepo( | |
| 116 | { repositoryId: "r1", owner: "a", repoName: "b", ref: "main", commitSha: "sha" }, | |
| 117 | { | |
| 118 | getTree: treeFrom({ "": [{ name: "a.ts", type: "blob", path: "a.ts" }] }), | |
| 119 | indexChangedFiles: async () => { | |
| 120 | throw new Error("embedder down"); | |
| 121 | }, | |
| 122 | } | |
| 123 | ); | |
| 124 | expect(r.indexed).toBe(0); | |
| 125 | expect(r.error).toContain("embedder down"); | |
| 126 | }); | |
| 127 | ||
| 128 | it("returns zero for an empty repository without calling the embedder", async () => { | |
| 129 | let called = false; | |
| 130 | const { indexExistingRepo } = await import("../lib/index-existing-repo"); | |
| 131 | const r = await indexExistingRepo( | |
| 132 | { repositoryId: "r1", owner: "a", repoName: "b", ref: "main", commitSha: "sha" }, | |
| 133 | { | |
| 134 | getTree: treeFrom({}), | |
| 135 | indexChangedFiles: async () => { | |
| 136 | called = true; | |
| 137 | return { indexed: 0, skipped: 0, model: "test" }; | |
| 138 | }, | |
| 139 | } | |
| 140 | ); | |
| 141 | expect(r.indexed).toBe(0); | |
| 142 | expect(called).toBe(false); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("both onboarding paths index", () => { | |
| 147 | it("bulk import indexes each repo", () => { | |
| 148 | const mig = readFileSync("src/lib/migration-onboarding.ts", "utf8"); | |
| 149 | expect(mig).toContain("indexExistingRepo"); | |
| 150 | expect(mig).toContain("filesIndexed"); | |
| 151 | }); | |
| 152 | ||
| 153 | it("new-repo scaffold indexes after committing", () => { | |
| 154 | const scaffold = readFileSync("src/lib/first-repo-scaffold.ts", "utf8"); | |
| 155 | expect(scaffold).toContain("indexExistingRepo"); | |
| 156 | // Must come after the commits — there is nothing to index before them. | |
| 157 | expect(scaffold.indexOf("createOrUpdateFileOnBranch")).toBeLessThan( | |
| 158 | scaffold.lastIndexOf("indexExistingRepo") | |
| 159 | ); | |
| 160 | }); | |
| 161 | }); |