feat: implement Git backend support for wiki storage and commands

This commit is contained in:
doum1004
2026-04-11 00:23:33 -04:00
parent 2a591b6f5d
commit 9e8f913bc1
15 changed files with 213 additions and 68 deletions

View File

@@ -1,7 +1,7 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { LogManager } from "../lib/log-manager.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makeCommitCommand(): Command {
@@ -10,7 +10,7 @@ export function makeCommitCommand(): Command {
.argument("[message]", "commit message")
.action(async function (this: Command, message: string | undefined) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "commit");
requireGit(ctx, "commit");
// Auto-generate message from last log entry if not provided
if (!message) {

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makeDiffCommand(): Command {
@@ -9,7 +9,7 @@ export function makeDiffCommand(): Command {
.argument("[ref]", "commit ref to show (default: uncommitted changes)")
.action(async function (this: Command, ref: string | undefined) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "diff");
requireGit(ctx, "diff");
const result = await git.diff(ctx.root, ref);
if (!result.ok) {

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makeHistoryCommand(): Command {
@@ -14,7 +14,7 @@ export function makeHistoryCommand(): Command {
options: { last: string },
) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "history");
requireGit(ctx, "history");
const limit = parseInt(options.last, 10);
let result;

View File

@@ -10,7 +10,7 @@ import {
getDefaultIndex,
getDefaultLog,
} from "../lib/templates.ts";
import type { RegistryEntry } from "../types.ts";
import type { BackendType, RegistryEntry } from "../types.ts";
export function makeInitCommand(): Command {
return new Command("init")
@@ -18,11 +18,13 @@ export function makeInitCommand(): Command {
.argument("[dir]", "directory to initialize (defaults to cwd)")
.option("-n, --name <name>", "wiki name")
.option("-d, --domain <domain>", "knowledge domain", "general")
.option("-b, --backend <type>", "storage backend (filesystem, git, supabase)", "filesystem")
.action(
async (
dir: string | undefined,
options: { name?: string; domain: string },
options: { name?: string; domain: string; backend: string },
) => {
const backend = options.backend as BackendType;
const targetDir = resolve(dir ?? ".");
const name = options.name ?? basename(targetDir);
const domain = options.domain;
@@ -41,7 +43,7 @@ export function makeInitCommand(): Command {
await Promise.all(dirs.map((d) => mkdir(d, { recursive: true })));
// Write config
const config = getDefaultConfig(name, domain);
const config = getDefaultConfig(name, domain, backend);
await saveConfig(targetDir, config);
// Write template files
@@ -51,20 +53,22 @@ export function makeInitCommand(): Command {
writeFile(resolve(targetDir, "wiki/log.md"), getDefaultLog(), "utf-8"),
]);
// Git init + initial commit
const initResult = await git.init(targetDir);
if (!initResult.ok) {
console.error(`Warning: git init failed: ${initResult.output}`);
} else {
await git.addAll(targetDir);
const commitResult = await git.commit(
targetDir,
"Initialize wiki",
);
if (!commitResult.ok) {
console.error(
`Warning: initial commit failed: ${commitResult.output}`,
// Git init + initial commit (git backend only)
if (backend === "git") {
const initResult = await git.init(targetDir);
if (!initResult.ok) {
console.error(`Warning: git init failed: ${initResult.output}`);
} else {
await git.addAll(targetDir);
const commitResult = await git.commit(
targetDir,
"Initialize wiki",
);
if (!commitResult.ok) {
console.error(
`Warning: initial commit failed: ${commitResult.output}`,
);
}
}
}

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makePullCommand(): Command {
@@ -8,7 +8,7 @@ export function makePullCommand(): Command {
.description("Pull wiki changes from remote")
.action(async function (this: Command) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "pull");
requireGit(ctx, "pull");
if (!(await git.hasRemote(ctx.root))) {
console.error('No remote configured. Use "wiki repo connect" to add one.');

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makePushCommand(): Command {
@@ -8,7 +8,7 @@ export function makePushCommand(): Command {
.description("Push wiki changes to remote")
.action(async function (this: Command) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "push");
requireGit(ctx, "push");
if (!(await git.hasRemote(ctx.root))) {
console.error('No remote configured. Use "wiki repo connect" to add one.');

View File

@@ -49,7 +49,7 @@ export function makeStatusCommand(): Command {
// Git info (filesystem backend only)
let gitInfo = { commits: "N/A", hasRemote: false };
if ((ctx.config.backend ?? "filesystem") === "filesystem") {
if (ctx.config.backend === "git") {
const gitLog = await git.log(ctx.root, 1);
const commitCount = gitLog.ok ? (await git.log(ctx.root, 99999)).output.split("\n").filter(Boolean).length.toString() : "0";
const remote = await git.hasRemote(ctx.root);

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import * as git from "../lib/git.ts";
import { requireFilesystem } from "../lib/storage.ts";
import { requireGit } from "../lib/storage.ts";
import type { WikiContext } from "../types.ts";
export function makeSyncCommand(): Command {
@@ -8,7 +8,7 @@ export function makeSyncCommand(): Command {
.description("Pull then push (sync with remote)")
.action(async function (this: Command) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
requireFilesystem(ctx, "sync");
requireGit(ctx, "sync");
if (!(await git.hasRemote(ctx.root))) {
console.error('No remote configured. Use "wiki repo connect" to add one.');

43
src/lib/git-provider.ts Normal file
View File

@@ -0,0 +1,43 @@
import { WikiManager } from "./wiki.ts";
import * as git from "./git.ts";
import type { StorageProvider } from "../types.ts";
export class GitProvider implements StorageProvider {
private wiki: WikiManager;
public readonly root: string;
constructor(root: string) {
this.root = root;
this.wiki = new WikiManager(root);
}
async readPage(relativePath: string): Promise<string | null> {
return this.wiki.readPage(relativePath);
}
async writePage(relativePath: string, content: string): Promise<void> {
await this.wiki.writePage(relativePath, content);
await this.autoCommit(`update ${relativePath}`);
}
async appendPage(relativePath: string, content: string): Promise<boolean> {
const ok = await this.wiki.appendPage(relativePath, content);
if (ok) {
await this.autoCommit(`append to ${relativePath}`);
}
return ok;
}
async pageExists(relativePath: string): Promise<boolean> {
return this.wiki.pageExists(relativePath);
}
async listPages(dir?: string): Promise<string[]> {
return this.wiki.listPages(dir);
}
private async autoCommit(message: string): Promise<void> {
await git.addAll(this.root);
await git.commit(this.root, message);
}
}

View File

@@ -1,24 +1,30 @@
import { WikiManager } from "./wiki.ts";
import { GitProvider } from "./git-provider.ts";
import type { BackendType, StorageProvider, WikiContext } from "../types.ts";
export function createProvider(
backend: BackendType,
root: string,
): StorageProvider {
if (backend === "filesystem") return new WikiManager(root);
throw new Error(
`Unknown storage backend: "${backend}". Supported: filesystem`,
);
switch (backend) {
case "filesystem":
return new WikiManager(root);
case "git":
return new GitProvider(root);
case "supabase":
throw new Error("Supabase backend not yet implemented.");
default:
throw new Error(
`Unknown storage backend: "${backend}". Supported: filesystem, git`,
);
}
}
export function requireFilesystem(
ctx: WikiContext,
commandName: string,
): void {
export function requireGit(ctx: WikiContext, commandName: string): void {
const backend = ctx.config.backend ?? "filesystem";
if (backend !== "filesystem") {
if (backend !== "git") {
console.error(
`"wiki ${commandName}" requires filesystem backend. This wiki uses "${backend}".`,
`"wiki ${commandName}" requires git backend. This wiki uses "${backend}".`,
);
process.exit(1);
}

View File

@@ -1,11 +1,15 @@
import type { WikiConfig } from "../types.ts";
import type { WikiConfig, BackendType } from "../types.ts";
export function getDefaultConfig(name: string, domain: string): WikiConfig {
export function getDefaultConfig(
name: string,
domain: string,
backend: BackendType = "filesystem",
): WikiConfig {
return {
name,
domain,
created: new Date().toISOString(),
backend: "filesystem" as const,
backend,
paths: {
raw: "raw",
wiki: "wiki",

View File

@@ -1,4 +1,4 @@
export type BackendType = "filesystem";
export type BackendType = "filesystem" | "git" | "supabase";
export interface StorageProvider {
readPage(relativePath: string): Promise<string | null>;

View File

@@ -32,8 +32,8 @@ async function runWiki(args: string[], input?: string): Promise<{ stdout: string
return { stdout, stderr, exitCode };
}
async function initWiki(name = "testwiki"): Promise<void> {
const result = await runWiki(["init", wikiDir, "--name", name, "--domain", "test"]);
async function initWiki(name = "testwiki", backend = "filesystem"): Promise<void> {
const result = await runWiki(["init", wikiDir, "--name", name, "--domain", "test", "--backend", backend]);
if (result.exitCode !== 0) {
throw new Error(`Init failed: ${result.stderr}`);
}
@@ -225,33 +225,31 @@ describe("log command", () => {
describe("commit command", () => {
it("commits changes with provided message", async () => {
await initWiki();
await runWiki(
["-w", "testwiki", "write", "wiki/concepts/new.md"],
"New page content",
);
const result = await runWiki(["-w", "testwiki", "commit", "Add new concept page"]);
await initWiki("testwiki", "git");
// Modify a file directly (not via wiki write, which auto-commits)
const indexPath = join(wikiDir, "wiki/index.md");
const content = await readFile(indexPath, "utf-8");
await writeFile(indexPath, content + "\n- [[new-entry]]\n", "utf-8");
const result = await runWiki(["-w", "testwiki", "commit", "Add new entry"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Add new concept page");
expect(result.stdout).toContain("Add new entry");
});
it("reports nothing to commit when clean", async () => {
await initWiki();
await initWiki("testwiki", "git");
const result = await runWiki(["-w", "testwiki", "commit", "Empty commit"]);
expect(result.stdout).toContain("Nothing to commit");
});
it("auto-generates message from last log entry", async () => {
await initWiki();
await runWiki(["-w", "testwiki", "log", "append", "ingest", "Added attention paper"]);
it("auto-commits on wiki write with git backend", async () => {
await initWiki("testwiki", "git");
await runWiki(
["-w", "testwiki", "write", "wiki/concepts/attention.md"],
"Attention content",
);
const result = await runWiki(["-w", "testwiki", "commit"]);
expect(result.exitCode).toBe(0);
// Should use the log entry message as commit message
expect(result.stdout).toContain("Added attention paper");
// Page was auto-committed, so manual commit has nothing to do
const result = await runWiki(["-w", "testwiki", "commit", "Manual commit"]);
expect(result.stdout).toContain("Nothing to commit");
});
});
@@ -380,29 +378,29 @@ describe("orphans command", () => {
describe("history command", () => {
it("shows git history for a page", async () => {
await initWiki();
await initWiki("testwiki", "git");
// wiki write auto-commits with git backend
await runWiki(
["-w", "testwiki", "write", "wiki/concepts/tracked.md"],
"Version 1",
);
await runWiki(["-w", "testwiki", "commit", "Add tracked page"]);
const result = await runWiki(["-w", "testwiki", "history", "wiki/concepts/tracked.md"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Add tracked page");
expect(result.stdout).toContain("update wiki/concepts/tracked.md");
});
});
describe("diff command", () => {
it("shows no changes when working tree is clean", async () => {
await initWiki();
await initWiki("testwiki", "git");
const result = await runWiki(["-w", "testwiki", "diff"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("No changes");
});
it("shows changes to tracked files", async () => {
await initWiki();
// Modify a tracked file (index.md was committed during init)
await initWiki("testwiki", "git");
// Modify a tracked file directly (not via wiki write, which auto-commits)
const indexPath = join(wikiDir, "wiki/index.md");
const content = await readFile(indexPath, "utf-8");
await writeFile(indexPath, content + "\n## New Section\n", "utf-8");

View File

@@ -282,7 +282,7 @@ describe("templates", () => {
// --- init integration ---
describe("init command (integration)", () => {
it("creates all directories and files", async () => {
it("creates all directories and files (filesystem)", async () => {
const wikiDir = join(testDir, "mywiki");
const proc = Bun.spawn(
["bun", "run", "bin/wiki.ts", "init", wikiDir, "--name", "mywiki", "--domain", "test"],
@@ -309,7 +309,31 @@ describe("init command (integration)", () => {
expect(await Bun.file(join(wikiDir, "wiki/index.md")).exists()).toBe(true);
expect(await Bun.file(join(wikiDir, "wiki/log.md")).exists()).toBe(true);
// Check git
// Filesystem backend should NOT have .git
let hasGit = true;
try { await stat(join(wikiDir, ".git")); } catch { hasGit = false; }
expect(hasGit).toBe(false);
});
it("creates git repo with --backend git", async () => {
const wikiDir = join(testDir, "gitwiki");
const proc = Bun.spawn(
["bun", "run", "bin/wiki.ts", "init", wikiDir, "--name", "gitwiki", "--backend", "git"],
{
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@test.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@test.com",
},
},
);
await proc.exited;
const gitStat = await stat(join(wikiDir, ".git"));
expect(gitStat.isDirectory()).toBe(true);
});

View File

@@ -3,6 +3,8 @@ import { mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { createProvider } from "../src/lib/storage.ts";
import { GitProvider } from "../src/lib/git-provider.ts";
import * as git from "../src/lib/git.ts";
import type { StorageProvider } from "../src/types.ts";
let testDir: string;
@@ -27,6 +29,17 @@ describe("createProvider", () => {
expect(provider.listPages).toBeInstanceOf(Function);
});
it("creates a git provider", () => {
const gitProvider = createProvider("git", testDir);
expect(gitProvider).toBeInstanceOf(GitProvider);
});
it("throws for supabase (not yet implemented)", () => {
expect(() => createProvider("supabase", testDir)).toThrow(
"Supabase backend not yet implemented",
);
});
it("throws for unknown backend", () => {
expect(() => createProvider("unknown" as any, testDir)).toThrow(
'Unknown storage backend: "unknown"',
@@ -84,3 +97,56 @@ describe("StorageProvider contract (filesystem)", () => {
expect(pages).not.toContain("root.md");
});
});
describe("GitProvider", () => {
let gitDir: string;
let gitProvider: StorageProvider;
beforeEach(async () => {
gitDir = await mkdtemp(join(tmpdir(), "llmwiki-git-"));
await git.init(gitDir);
gitProvider = createProvider("git", gitDir);
});
afterEach(async () => {
await rm(gitDir, { recursive: true, force: true });
});
it("writePage stores content and auto-commits", async () => {
await gitProvider.writePage("test.md", "hello");
const content = await gitProvider.readPage("test.md");
expect(content).toBe("hello");
const log = await git.log(gitDir, 1);
expect(log.ok).toBe(true);
expect(log.output).toContain("update test.md");
});
it("appendPage auto-commits on success", async () => {
await gitProvider.writePage("page.md", "first\n");
await gitProvider.appendPage("page.md", "second");
const log = await git.log(gitDir, 2);
expect(log.ok).toBe(true);
expect(log.output).toContain("append to page.md");
});
it("appendPage does not commit on missing page", async () => {
const ok = await gitProvider.appendPage("missing.md", "nope");
expect(ok).toBe(false);
const log = await git.log(gitDir, 1);
// No commits should exist (or only prior ones)
expect(log.output).not.toContain("append to missing.md");
});
it("readPage returns null for missing page", async () => {
const content = await gitProvider.readPage("nope.md");
expect(content).toBeNull();
});
it("listPages works like filesystem", async () => {
await gitProvider.writePage("a.md", "a");
await gitProvider.writePage("sub/b.md", "b");
const pages = await gitProvider.listPages();
expect(pages).toContain("a.md");
expect(pages).toContain("sub/b.md");
});
});