diff --git a/src/commands/commit.ts b/src/commands/commit.ts index 88e7aeb..71747ec 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -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) { diff --git a/src/commands/diff.ts b/src/commands/diff.ts index c0a36f1..e883c1c 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -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) { diff --git a/src/commands/history.ts b/src/commands/history.ts index aa42367..6cc4832 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -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; diff --git a/src/commands/init.ts b/src/commands/init.ts index 67cf9e2..bcdea9a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -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 ", "wiki name") .option("-d, --domain ", "knowledge domain", "general") + .option("-b, --backend ", "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}`, + ); + } } } diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 59f8602..d9d0c6b 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -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.'); diff --git a/src/commands/push.ts b/src/commands/push.ts index eb757d2..169ff97 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -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.'); diff --git a/src/commands/status.ts b/src/commands/status.ts index 45d40b8..d3055a2 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -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); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index c8f7bb2..3218181 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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.'); diff --git a/src/lib/git-provider.ts b/src/lib/git-provider.ts new file mode 100644 index 0000000..4eab8f0 --- /dev/null +++ b/src/lib/git-provider.ts @@ -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 { + return this.wiki.readPage(relativePath); + } + + async writePage(relativePath: string, content: string): Promise { + await this.wiki.writePage(relativePath, content); + await this.autoCommit(`update ${relativePath}`); + } + + async appendPage(relativePath: string, content: string): Promise { + const ok = await this.wiki.appendPage(relativePath, content); + if (ok) { + await this.autoCommit(`append to ${relativePath}`); + } + return ok; + } + + async pageExists(relativePath: string): Promise { + return this.wiki.pageExists(relativePath); + } + + async listPages(dir?: string): Promise { + return this.wiki.listPages(dir); + } + + private async autoCommit(message: string): Promise { + await git.addAll(this.root); + await git.commit(this.root, message); + } +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 91e0676..24cf3f8 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -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); } diff --git a/src/lib/templates.ts b/src/lib/templates.ts index 746426a..a701d36 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -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", diff --git a/src/types.ts b/src/types.ts index c1e289e..b93cb9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type BackendType = "filesystem"; +export type BackendType = "filesystem" | "git" | "supabase"; export interface StorageProvider { readPage(relativePath: string): Promise; diff --git a/test/commands.test.ts b/test/commands.test.ts index c41d363..36c0177 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -32,8 +32,8 @@ async function runWiki(args: string[], input?: string): Promise<{ stdout: string return { stdout, stderr, exitCode }; } -async function initWiki(name = "testwiki"): Promise { - const result = await runWiki(["init", wikiDir, "--name", name, "--domain", "test"]); +async function initWiki(name = "testwiki", backend = "filesystem"): Promise { + 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"); diff --git a/test/init.test.ts b/test/init.test.ts index 62ed457..ad115c9 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -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); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 78e0044..ac63d83 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -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"); + }); +});