Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/okf/knowledge-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Concept> {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/test/okf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/mcp/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading