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..4a3b283 100644 --- a/packages/core/src/okf/knowledge-base.ts +++ b/packages/core/src/okf/knowledge-base.ts @@ -37,6 +37,47 @@ 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(); + // 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) { + // --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. + 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.