mirror of
https://github.com/doum1004/llmwiki-cli.git
synced 2026-05-14 23:18:26 +02:00
feat: implement Git backend support for wiki storage and commands
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
43
src/lib/git-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type BackendType = "filesystem";
|
||||
export type BackendType = "filesystem" | "git" | "supabase";
|
||||
|
||||
export interface StorageProvider {
|
||||
readPage(relativePath: string): Promise<string | null>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user