From b1ee32ad86627ec0f6b974ddfd0520f1f6968b69 Mon Sep 17 00:00:00 2001 From: Gawie van Blerk Date: Thu, 13 Aug 2026 20:34:34 +0200 Subject: [PATCH] fix(memory-ingest): pass --include-gitignored to gbrain import gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`, headed "Do not edit") at the root of ~/.gstack. The memory ingest stages pages into ~/.gstack/.staging-ingest--/, which is inside that repo, and gbrain's markdown collector honours .gitignore. The collector therefore matches every staged file against `*` and collects zero. The failure is silent. gbrain import exits 0 having imported nothing while the ingest prints `written: N` from the STAGED count rather than the imported count, so a run that indexes nothing looks identical to a healthy one and the memory corpus quietly stops growing. Reproduction, using git's own ignore machinery (no gbrain needed): git init . mkdir -p .staging-ingest-12345/learnings echo x > .staging-ingest-12345/learnings/page.md printf '*\n' > .gitignore git ls-files --others --exclude-standard # -> empty Passing --include-gitignored makes the import independent of whatever .gitignore sits above the staging directory. Adding a negation to the generated .gitignore is the alternative, but that file is gstack-owned and marked "Do not edit", so any regeneration silently reintroduces the bug. Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts, plus a behavioural test for the collision itself. Both source pins fail against the unpatched file. --- bin/gstack-memory-ingest.ts | 15 +++- test/memory-ingest-include-gitignored.test.ts | 79 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/memory-ingest-include-gitignored.test.ts diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 532aee4a9..eadfa854b 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -1417,7 +1417,20 @@ function runGbrainImport( // inside Next.js / Prisma / Rails projects with their own // .env.local (codex review #7 — defense in depth on top of the // parent gstack-gbrain-sync seeding the bun grandchild's env). - const child = spawnGbrainAsync(["import", stagingDir, "--no-embed", "--json"]); + // --include-gitignored is load-bearing, not a convenience. Pages are + // staged into ~/.gstack/.staging-ingest--/, and ~/.gstack is a + // git repo whose .gitignore is `*`. `gbrain import` honours .gitignore, + // so without this flag it collects files=0 and imports NOTHING, while + // still reporting `written: N` from the staged count. Silent data loss + // on every run. A working run logs `import.collect_files done ... files=N` + // with N > 0 and takes minutes, not seconds. + const child = spawnGbrainAsync([ + "import", + stagingDir, + "--no-embed", + "--include-gitignored", + "--json", + ]); _activeImportChild = child; let stdout = ""; let stderr = ""; diff --git a/test/memory-ingest-include-gitignored.test.ts b/test/memory-ingest-include-gitignored.test.ts new file mode 100644 index 000000000..89245530d --- /dev/null +++ b/test/memory-ingest-include-gitignored.test.ts @@ -0,0 +1,79 @@ +/** + * Regression pin: `gstack-memory-ingest` must pass `--include-gitignored` to + * `gbrain import`. + * + * gstack-artifacts-init writes an ignore-everything `.gitignore` (a bare `*`, + * headed "Do not edit") at the root of `~/.gstack`. The memory ingest stages + * pages into `~/.gstack/.staging-ingest--/`, which is INSIDE that + * repo, and gbrain's markdown collector honours .gitignore. So the collector + * walks the staging dir, matches every file against `*`, and collects zero. + * + * The failure is silent: `gbrain import` exits 0 having imported nothing, + * while the ingest still prints `written: N` from the STAGED count rather + * than the imported count. A run that indexes nothing is indistinguishable + * from a healthy one, and the memory corpus quietly stops growing. + * + * Two tests here: + * 1. Source pin (same shape as memory-ingest-no-put_page.test.ts): the flag + * is present in active code, so removing it trips the build. + * 2. Behavioural proof of the underlying collision, using git's own ignore + * machinery. No gbrain and no network required. + */ + +import { describe, it, expect } from "bun:test"; +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readFileSync } from "fs"; + +const SOURCE_PATH = join(import.meta.dir, "..", "bin", "gstack-memory-ingest.ts"); + +/** Strip comments so the pin only inspects executable code. */ +function stripComments(src: string): string { + const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, ""); + return noBlock.replace(/\/\/[^\n]*/g, ""); +} + +describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore", () => { + it("passes --include-gitignored in active code", () => { + const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8")); + expect(stripped).toContain("--include-gitignored"); + }); + + it("keeps the flag on the same import invocation as the staging dir", () => { + const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8")); + // Match the spawn call's argument array and assert both the subcommand + // and the flag live in it, so the flag can't drift onto another call. + const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]/s); + expect(call).not.toBeNull(); + expect(call![0]).toContain("--include-gitignored"); + }); + + it("demonstrates the collision: an ignore-everything root hides staged pages", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-")); + try { + const git = (...args: string[]) => + execFileSync("git", args, { cwd: dir, encoding: "utf-8" }); + git("init", "-q", "."); + + const staging = join(dir, ".staging-ingest-12345-1700000000000", "learnings"); + mkdirSync(staging, { recursive: true }); + writeFileSync(join(staging, "page.md"), "# a staged page\n", "utf-8"); + + // Exactly what gstack-artifacts-init writes at the root of ~/.gstack. + writeFileSync(join(dir, ".gitignore"), "*\n", "utf-8"); + + // `git ls-files --others --exclude-standard` is the same view a + // gitignore-honouring collector takes: untracked and not ignored. + const collectable = git("ls-files", "--others", "--exclude-standard") + .split("\n") + .filter(Boolean); + + // The staged page is invisible. This is the silent data loss. + expect(collectable).toHaveLength(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});