From 165ef4e8452a21eda395dfaf2f3cc56c630cebb0 Mon Sep 17 00:00:00 2001 From: Anirban Kar Date: Mon, 24 Aug 2026 21:56:27 +0530 Subject: [PATCH 1/2] fix: make GIT_AUTOCOMMIT actually work in the Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image shipped no git binary, so GIT_AUTOCOMMIT=true silently committed nothing while mutations kept reporting success — and git history is the only undo for a memory that dreams autonomously. - Dockerfile: install git, set a system-level committer identity and safe.directory for bind-mounted bundles (the three stacked failures from the report, covered at the image layer) - KnowledgeBase.ensureGitReady(): startup readiness check — friendly error when the binary is missing (simple-git reports installed:false rather than throwing), auto-init of non-repo bundles with an --allow-empty first commit (also proves committer identity works, independent of staging quirks), and an early rev-parse to surface ownership problems before the first mutation - server + stdio entry points fail loudly at startup instead of discovering the problem commit by commit - 2 new tests (60 total) --- .env.example | 5 +++- Dockerfile | 9 +++++++ packages/core/src/okf/knowledge-base.ts | 33 +++++++++++++++++++++++++ packages/core/test/okf.test.ts | 23 +++++++++++++++++ packages/server/src/index.ts | 10 ++++++++ packages/server/src/mcp/stdio.ts | 10 ++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 25009dd..ed80997 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,10 @@ LLM_FALLBACK_MODEL= # LOCAL_BASE_URL= # LOCAL_API_KEY= -# Commit bundle changes to git after each mutation +# Commit bundle changes to git after each mutation. Requires git in PATH +# (the official Docker image ships it); a bundle that is not yet a repo is +# initialized automatically, and the server refuses to start if autocommit +# is requested but cannot work. GIT_AUTOCOMMIT=false PORT=3800 diff --git a/Dockerfile b/Dockerfile index 68e44e6..1acab4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,15 @@ COPY packages packages RUN pnpm -r build && pnpm prune --prod FROM node:22-alpine +# GIT_AUTOCOMMIT shells out to git (issue #21). Alpine ships none, and a bare +# container also lacks a committer identity and trips git's dubious-ownership +# check on bind-mounted bundles — cover all three here. The wildcard +# safe.directory is acceptable in a single-purpose container whose only +# writable tree is the bundle. +RUN apk add --no-cache git \ + && git config --system --add safe.directory '*' \ + && git config --system user.name "understory" \ + && git config --system user.email "understory@localhost" WORKDIR /app COPY --from=build /app/node_modules node_modules COPY --from=build /app/packages/core/dist packages/core/dist diff --git a/packages/core/src/okf/knowledge-base.ts b/packages/core/src/okf/knowledge-base.ts index ce923c5..feacc43 100644 --- a/packages/core/src/okf/knowledge-base.ts +++ b/packages/core/src/okf/knowledge-base.ts @@ -37,6 +37,39 @@ export class KnowledgeBase { this.git = options.gitAutocommit ? simpleGit(this.bundle.root) : null; } + /** + * Verify autocommit can actually work (issue #21: a missing git binary made + * GIT_AUTOCOMMIT=true silently commit nothing). Call at startup and fail + * loudly on the result. A bundle that isn't a repo yet is initialized with + * a first commit of the current state — that's what the operator asked for. + */ + async ensureGitReady(): Promise<{ ok: boolean; reason?: string }> { + if (!this.git) return { ok: true }; // autocommit not requested + const version = await this.git.version().catch(() => ({ installed: false })); + if (!version.installed) { + return { + ok: false, + reason: + "git binary not found in PATH — autocommit cannot work (the official Docker image ships git as of this fix; rebuild/re-pull, or install git)", + }; + } + try { + const isRepo = await this.git.checkIsRepo(); + if (!isRepo) { + await this.git.init(); + // --allow-empty: always produces the commit and proves committer + // identity works, independent of staging quirks; the first real + // mutation's add+commit captures the existing content. + await this.git.raw(["commit", "--allow-empty", "-m", "chore: initialize memory history"]); + } + // Surface identity/ownership problems now, not on the first mutation. + await this.git.raw(["rev-parse", "--git-dir"]); + return { ok: true }; + } catch (err) { + return { ok: false, reason: (err as Error).message }; + } + } + // ── Reads (no queue) ──────────────────────────────────────────────── readConcept(conceptPath: string): Promise { diff --git a/packages/core/test/okf.test.ts b/packages/core/test/okf.test.ts index 670c90d..2b9bd77 100644 --- a/packages/core/test/okf.test.ts +++ b/packages/core/test/okf.test.ts @@ -312,3 +312,26 @@ describe("empty directory pruning (#10)", () => { await expect(fs.access(path.join(root, ".traces/t.json"))).resolves.toBeUndefined(); }); }); + +describe("git autocommit readiness (#21)", () => { + it("initializes a non-repo bundle with a first commit, then commits mutations", async () => { + const gkb = new KnowledgeBase(root, { gitAutocommit: true }); + const ready = await gkb.ensureGitReady(); + expect(ready.ok).toBe(true); + + await gkb.writeConcept("/facts/one.md", { type: "Fact", title: "One" }, "x", "Added one."); + const { execSync } = await import("node:child_process"); + const log = execSync("git log --oneline", { cwd: root }).toString().trim().split("\n"); + // init commit + mutation commit + expect(log.length).toBeGreaterThanOrEqual(2); + expect(log[0]).toContain("creation"); + expect(log[log.length - 1]).toContain("initialize memory history"); + }); + + it("is a no-op when autocommit is not requested", async () => { + const ready = await kb.ensureGitReady(); + expect(ready.ok).toBe(true); + const { existsSync } = await import("node:fs"); + expect(existsSync(path.join(root, ".git"))).toBe(false); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 75451f1..0faadd3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -22,6 +22,16 @@ const kb = new KnowledgeBase(bundleRoot, { startDreamer(kb); +if (process.env.GIT_AUTOCOMMIT === "true") { + const gitReady = await kb.ensureGitReady(); + if (!gitReady.ok) { + console.error(`[understory] GIT_AUTOCOMMIT=true but autocommit cannot work: ${gitReady.reason}`); + process.exit(1); + } + console.log("[understory] git autocommit: enabled (bundle history is being recorded)"); +} + + const app = express(); // Validate LLM config at startup — fail fast with a clear error. diff --git a/packages/server/src/mcp/stdio.ts b/packages/server/src/mcp/stdio.ts index 2829892..8888eac 100644 --- a/packages/server/src/mcp/stdio.ts +++ b/packages/server/src/mcp/stdio.ts @@ -37,6 +37,16 @@ try { const kb = new KnowledgeBase(bundleRoot, { gitAutocommit: process.env.GIT_AUTOCOMMIT === "true", }); + +if (process.env.GIT_AUTOCOMMIT === "true") { + const gitReady = await kb.ensureGitReady(); + if (!gitReady.ok) { + console.error(`[understory] GIT_AUTOCOMMIT=true but autocommit cannot work: ${gitReady.reason}`); + process.exit(1); + } + console.log("[understory] git autocommit: enabled (bundle history is being recorded)"); +} + const server = await buildMcpServer(kb); await server.connect(new StdioServerTransport()); // stdio transport keeps the process alive; logs must go to stderr only. From 1f50e7e9215eacf1f244a92ae0664478353f69c5 Mon Sep 17 00:00:00 2001 From: Anirban Kar Date: Mon, 24 Aug 2026 21:58:19 +0530 Subject: [PATCH 2/2] fix: repo-local committer identity fallback when none is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh containers and CI runners have no git identity, so the init commit failed there. When user.email doesn't resolve, set a repo-local understory identity — same as the image does at system scope. Verified with GIT_CONFIG_GLOBAL/SYSTEM nulled. --- packages/core/src/okf/knowledge-base.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/okf/knowledge-base.ts b/packages/core/src/okf/knowledge-base.ts index feacc43..4a3b283 100644 --- a/packages/core/src/okf/knowledge-base.ts +++ b/packages/core/src/okf/knowledge-base.ts @@ -55,11 +55,19 @@ export class KnowledgeBase { } try { const isRepo = await this.git.checkIsRepo(); + if (!isRepo) await this.git.init(); + // Environments without a global committer identity (fresh containers, + // CI) get a repo-local fallback — same identity the Docker image sets + // at system scope. + const email = await this.git.raw(["config", "--get", "user.email"]).catch(() => ""); + if (!email.trim()) { + await this.git.addConfig("user.name", "understory"); + await this.git.addConfig("user.email", "understory@localhost"); + } if (!isRepo) { - await this.git.init(); - // --allow-empty: always produces the commit and proves committer - // identity works, independent of staging quirks; the first real - // mutation's add+commit captures the existing content. + // --allow-empty: always produces the commit and proves committing + // works, independent of staging quirks; the first real mutation's + // add+commit captures the existing content. await this.git.raw(["commit", "--allow-empty", "-m", "chore: initialize memory history"]); } // Surface identity/ownership problems now, not on the first mutation.