diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e788b0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: npm ci + + - name: Verify + build + run: | + npm run typecheck + npm run build + npm --prefix ui run build + npm test diff --git a/README.md b/README.md index 5a1b776..72e5d9a 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,23 @@ Requirements: - npm Trusted Publisher configured for this repo/workflow - `gh` CLI installed and authenticated (for automatic GitHub release creation) +## Branching and release flow + +Current recommended flow for solo maintainers + contributors: + +1. Create changes in `feat/*` branches. +2. Open PR `feat/*` -> `develop`. +3. After reviews/validation, merge into `develop`. +4. When ready to release, open one PR `develop` -> `main`. +5. Run `npm run release -- ` on `develop` (or after merging). + This tags `vX.Y.Z`, which automatically triggers publishing via tag push. + +Notes: + +- `develop` is the collaboration/integration branch. +- `main` is the release branch. +- PRs to `main` are normally used only for grouped, release-ready changes. + ## CLI Quick Reference ```bash diff --git a/src/cli/add.ts b/src/cli/add.ts index 2c7f919..09ca97d 100644 --- a/src/cli/add.ts +++ b/src/cli/add.ts @@ -1,4 +1,6 @@ import { addTerm } from "../core/store.js"; +import { loadConfig } from "../core/config.js"; +import { buildStoreCommitOptions } from "../core/store-git-options.js"; import type { GlossaryEntry } from "../core/types.js"; interface AddOptions { @@ -7,11 +9,11 @@ interface AddOptions { cwd?: string; } -export function addCommand( +export async function addCommand( term: string, definition: string, options: AddOptions -): void { +): Promise { try { const scope = (options.scope ?? "project") as "global" | "project"; const cwd = options.cwd ?? process.cwd(); @@ -21,7 +23,9 @@ export function addCommand( entry.aliases = options.aliases.split(",").map((a) => a.trim()); } - addTerm(scope, entry, cwd); + const config = loadConfig(cwd); + const gitOptions = buildStoreCommitOptions(config, cwd); + await addTerm(scope, entry, cwd, gitOptions); console.log(`Added '${term}' to ${scope} glossary.`); } catch (err) { process.stderr.write( diff --git a/src/cli/edit.ts b/src/cli/edit.ts index aa360ef..55313cc 100644 --- a/src/cli/edit.ts +++ b/src/cli/edit.ts @@ -1,4 +1,6 @@ import { editTerm } from "../core/store.js"; +import { loadConfig } from "../core/config.js"; +import { buildStoreCommitOptions } from "../core/store-git-options.js"; interface EditOptions { definition?: string; @@ -7,7 +9,7 @@ interface EditOptions { cwd?: string; } -export function editCommand(term: string, options: EditOptions): void { +export async function editCommand(term: string, options: EditOptions): Promise { try { const scope = (options.scope ?? "project") as "global" | "project"; const cwd = options.cwd ?? process.cwd(); @@ -23,7 +25,9 @@ export function editCommand(term: string, options: EditOptions): void { process.exit(1); } - editTerm(scope, term, updates, cwd); + const config = loadConfig(cwd); + const gitOptions = buildStoreCommitOptions(config, cwd); + await editTerm(scope, term, updates, cwd, gitOptions); console.log(`Updated '${term}' in ${scope} glossary.`); } catch (err) { process.stderr.write( diff --git a/src/cli/remove.ts b/src/cli/remove.ts index 41e2725..0cab5f5 100644 --- a/src/cli/remove.ts +++ b/src/cli/remove.ts @@ -1,16 +1,20 @@ import { removeTerm } from "../core/store.js"; +import { loadConfig } from "../core/config.js"; +import { buildStoreCommitOptions } from "../core/store-git-options.js"; interface RemoveOptions { scope?: string; cwd?: string; } -export function removeCommand(term: string, options: RemoveOptions): void { +export async function removeCommand(term: string, options: RemoveOptions): Promise { try { const scope = (options.scope ?? "project") as "global" | "project"; const cwd = options.cwd ?? process.cwd(); - removeTerm(scope, term, cwd); + const config = loadConfig(cwd); + const gitOptions = buildStoreCommitOptions(config, cwd); + await removeTerm(scope, term, cwd, gitOptions); console.log(`Removed '${term}' from ${scope} glossary.`); } catch (err) { process.stderr.write( diff --git a/src/core/config.ts b/src/core/config.ts index 5571d95..d67b980 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -53,6 +53,8 @@ export function loadConfig(cwd?: string): Required { extraGlossaryPaths: parsed.extraGlossaryPaths ?? DEFAULT_CONFIG.extraGlossaryPaths, // deep-merge nested ui object so partial config keeps defaults ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) }, + // deep-merge nested git object so partial config keeps defaults + git: { ...DEFAULT_CONFIG.git, ...(parsed.git ?? {}) }, }; } catch { // Invalid JSON — fall through to next path @@ -127,6 +129,7 @@ export function resolveConfigWithProvenance(cwd?: string): ConfigProvenance { ...parsed, extraGlossaryPaths: parsed.extraGlossaryPaths ?? DEFAULT_CONFIG.extraGlossaryPaths, ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) }, + git: { ...DEFAULT_CONFIG.git, ...(parsed.git ?? {}) }, }; const origins: Record = {}; diff --git a/src/core/git-batcher.ts b/src/core/git-batcher.ts new file mode 100644 index 0000000..5bf8fdd --- /dev/null +++ b/src/core/git-batcher.ts @@ -0,0 +1,174 @@ +import { execFile } from "node:child_process"; +import { + resolveBatchCommitMessage, + relativeFilePath, + isGitAvailable, + isGitRepo, + type GitCommitResult, +} from "./git.js"; + +function exec(cmd: string, args: string[], opts?: { cwd?: string }): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const cb = (err: Error | null, stdout: string, stderr: string) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }; + if (opts) { + execFile(cmd, args, opts, cb); + } else { + execFile(cmd, args, cb); + } + }); +} + +export interface PendingChange { + operation: "add" | "edit" | "remove"; + term: string; + file: string; +} + +export interface GitBatcherOptions { + cwd: string; + idleSeconds: number; + batchCommitMessage: string; + /** Called after each batch commit attempt (for logging). */ + onCommit?: (result: GitCommitResult, terms: string[]) => void; + onError?: (error: unknown) => void; +} + +/** + * Accumulates glossary write operations and commits them as a single git + * commit after a configurable idle window. + * + * Designed for long-running processes (i.e. the control server). + * In one-shot CLI processes, fall back to operation mode instead. + */ +export class GitBatcher { + private readonly cwd: string; + private readonly idleMs: number; + private readonly messageTemplate: string; + private readonly onCommit: (result: GitCommitResult, terms: string[]) => void; + private readonly onError: (error: unknown) => void; + + private pending: PendingChange[] = []; + private timer: ReturnType | null = null; + + constructor(opts: GitBatcherOptions) { + this.cwd = opts.cwd; + this.idleMs = opts.idleSeconds * 1000; + this.messageTemplate = opts.batchCommitMessage; + this.onCommit = opts.onCommit ?? (() => {}); + this.onError = opts.onError ?? (() => {}); + } + + /** + * Record a write operation. Resets the idle timer. + */ + record(change: PendingChange): void { + this.pending.push(change); + this.resetTimer(); + } + + /** + * Immediately flush pending changes as a single commit. + * Clears pending list and cancels the timer. + * Safe to call multiple times (idempotent if nothing is pending). + */ + async flush(): Promise { + this.cancelTimer(); + + if (this.pending.length === 0) { + return { committed: false, skipped: true, reason: "nothing pending" }; + } + + const snapshot = [...this.pending]; + this.pending = []; + + // Dedupe files — all pending changes may touch the same file + const files = [...new Set(snapshot.map((c) => c.file))]; + const terms = [...new Set(snapshot.map((c) => c.term))]; + const primaryFile = files[0]!; + + const message = resolveBatchCommitMessage(this.messageTemplate, { + count: terms.length, + terms, + file: relativeFilePath(primaryFile, this.cwd), + }); + + // Stage all touched files + let result: GitCommitResult = { committed: false, skipped: false }; + try { + if (!await isGitAvailable()) { + result = { committed: false, skipped: true, reason: "git binary not found" }; + this.onCommit(result, terms); + return result; + } + if (!await isGitRepo(this.cwd)) { + result = { committed: false, skipped: true, reason: "not a git repo" }; + this.onCommit(result, terms); + return result; + } + + // Stage all changed files + for (const f of files) { + await exec("git", ["add", f], { cwd: this.cwd }); + } + + const { stdout: staged } = await exec( + "git", + ["diff", "--cached", "--name-only"], + { cwd: this.cwd } + ); + + if (!staged.trim()) { + result = { committed: false, skipped: true, reason: "no changes to commit" }; + this.onCommit(result, terms); + return result; + } + + await exec("git", ["commit", "-m", message, "--no-verify"], { cwd: this.cwd }); + result = { committed: true, skipped: false }; + this.onCommit(result, terms); + return result; + } catch (error) { + this.onError(error); + result = { + committed: false, + skipped: false, + reason: error instanceof Error ? error.message : String(error), + }; + this.onCommit(result, terms); + return result; + } + } + + /** + * Cancel any pending timer and discard pending changes. + * Call on server shutdown if flush() was already called. + */ + dispose(): void { + this.cancelTimer(); + this.pending = []; + } + + /** Returns the number of currently pending (uncommitted) changes. */ + get pendingCount(): number { + return this.pending.length; + } + + private resetTimer(): void { + this.cancelTimer(); + this.timer = setTimeout(() => { + this.flush().catch(this.onError); + }, this.idleMs); + // Don't hold the process open just for the timer + if (this.timer.unref) this.timer.unref(); + } + + private cancelTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} diff --git a/src/core/git.ts b/src/core/git.ts new file mode 100644 index 0000000..3533078 --- /dev/null +++ b/src/core/git.ts @@ -0,0 +1,147 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; + +// Lazily-resolved exec so test mocks are always picked up. +function exec(cmd: string, args: string[], opts?: { cwd?: string }): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const cb = (err: Error | null, stdout: string, stderr: string) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }; + if (opts) { + execFile(cmd, args, opts, cb); + } else { + execFile(cmd, args, cb); + } + }); +} + +export interface GitCommitOptions { + filePath: string; + message: string; + cwd: string; +} + +export interface GitCommitResult { + committed: boolean; + skipped: boolean; + reason?: string; +} + +/** + * Resolve operation-mode commit message template. + * Supports: {operation}, {term}, {file} + */ +export function resolveCommitMessage( + template: string, + vars: { operation: string; term: string; file: string } +): string { + return template + .replace(/\{operation\}/g, vars.operation) + .replace(/\{term\}/g, vars.term) + .replace(/\{file\}/g, vars.file); +} + +/** + * Resolve batch-mode commit message template. + * Supports: {count}, {terms}, {file} + */ +export function resolveBatchCommitMessage( + template: string, + vars: { count: number; terms: string[]; file: string } +): string { + return template + .replace(/\{count\}/g, String(vars.count)) + .replace(/\{terms\}/g, vars.terms.join(", ")) + .replace(/\{file\}/g, vars.file); +} + +/** + * Returns true if cwd is inside a git repository. + */ +export async function isGitRepo(cwd: string): Promise { + try { + await exec("git", ["rev-parse", "--git-dir"], { cwd }); + return true; + } catch { + return false; + } +} + +/** + * Returns true if the git binary is accessible. + */ +export async function isGitAvailable(): Promise { + try { + await exec("git", ["--version"]); + return true; + } catch { + return false; + } +} + +/** + * Stage a single file and commit it. + * Returns a result describing what happened. Never throws. + */ +export async function commitFile(opts: GitCommitOptions): Promise { + const { filePath, message, cwd } = opts; + + try { + if (!await isGitAvailable()) { + return { committed: false, skipped: true, reason: "git binary not found" }; + } + + if (!await isGitRepo(cwd)) { + return { committed: false, skipped: true, reason: "not a git repo" }; + } + + if (!existsSync(filePath)) { + return { committed: false, skipped: true, reason: "file does not exist" }; + } + + // Stage the file + await exec("git", ["add", filePath], { cwd }); + + // Check if there is actually anything staged for this file + const { stdout: staged } = await exec( + "git", + ["diff", "--cached", "--name-only", "--", filePath], + { cwd } + ); + + if (!staged.trim()) { + return { committed: false, skipped: true, reason: "no changes to commit" }; + } + + await exec("git", ["commit", "-m", message, "--no-verify"], { cwd }); + + return { committed: true, skipped: false }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { committed: false, skipped: false, reason }; + } +} + +/** + * Derive the glossary file path associated with a cwd. + * Used for constructing commit messages that reference the file. + */ +export function relativeFilePath(filePath: string, cwd: string): string { + // Prefer a short relative path in commit messages + if (filePath.startsWith(cwd)) { + return filePath.slice(cwd.length).replace(/^[\\/]/, ""); + } + return filePath; +} + +// Resolve the target git root (used for staging from the repo root). +export async function gitRoot(cwd: string): Promise { + try { + const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"], { cwd }); + return stdout.trim() || null; + } catch { + return null; + } +} diff --git a/src/core/store-git-options.ts b/src/core/store-git-options.ts new file mode 100644 index 0000000..216f3b7 --- /dev/null +++ b/src/core/store-git-options.ts @@ -0,0 +1,43 @@ +import type { GlossaryConfig } from "./types.js"; +import type { StoreCommitOptions } from "./store.js"; +import type { GitBatcher } from "./git-batcher.js"; + +/** + * Build StoreCommitOptions from resolved config. + * + * @param config - Resolved GlossaryConfig (from loadConfig) + * @param cwd - Working directory for the operation + * @param batcher - GitBatcher instance (only used in batch mode from the + * control server). When omitted in batch mode, falls back + * to operation mode and emits a warning to stderr. + */ +export function buildStoreCommitOptions( + config: Required, + cwd: string, + batcher?: GitBatcher +): StoreCommitOptions | undefined { + const mode = config.git.autoCommit ?? "manual"; + + if (mode === "manual") return undefined; + + if (mode === "batch" && !batcher) { + // CLI is one-shot — the idle timer would never fire. Fall back gracefully. + process.stderr.write( + "⚠ git.autoCommit \"batch\" is not meaningful in CLI mode. Committing immediately.\n" + ); + return { + mode: "operation", + commitMessage: config.git.commitMessage ?? "chore(glossary): {operation} term '{term}'", + batchCommitMessage: config.git.batchCommitMessage ?? "chore(glossary): update {count} glossary term(s)", + cwd, + }; + } + + return { + mode, + commitMessage: config.git.commitMessage ?? "chore(glossary): {operation} term '{term}'", + batchCommitMessage: config.git.batchCommitMessage ?? "chore(glossary): update {count} glossary term(s)", + cwd, + batcher, + }; +} diff --git a/src/core/store.ts b/src/core/store.ts index dbd2c7a..0c02c20 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -6,8 +6,18 @@ import { } from "node:fs"; import { dirname } from "node:path"; import { createHash } from "node:crypto"; -import type { GlossaryEntry } from "./types.js"; +import type { GlossaryEntry, GitCommitMode } from "./types.js"; import { findGlossaryFile, resolveGlossaryPaths } from "./loader.js"; +import { commitFile, resolveCommitMessage, relativeFilePath } from "./git.js"; +import type { GitBatcher } from "./git-batcher.js"; + +export interface StoreCommitOptions { + mode: GitCommitMode; + commitMessage: string; + batchCommitMessage: string; + cwd: string; + batcher?: GitBatcher; +} export interface StoreHandle { path: string; @@ -78,11 +88,12 @@ export function resolveStoreTarget( /** * Add a term to the glossary. */ -export function addTerm( +export async function addTerm( scope: "global" | "project", entry: GlossaryEntry, - cwd?: string -): void { + cwd?: string, + gitOptions?: StoreCommitOptions +): Promise { const filePath = resolveStoreTarget(scope, cwd); const store = readStore(filePath); @@ -96,17 +107,19 @@ export function addTerm( const updated = [...store.entries, entry]; writeStore(store, updated); + await maybeCommit(filePath, "add", entry.term, cwd ?? process.cwd(), gitOptions); } /** * Edit an existing term's fields. */ -export function editTerm( +export async function editTerm( scope: "global" | "project", term: string, updates: Partial>, - cwd?: string -): void { + cwd?: string, + gitOptions?: StoreCommitOptions +): Promise { const filePath = resolveStoreTarget(scope, cwd); const store = readStore(filePath); @@ -119,16 +132,18 @@ export function editTerm( store.entries[index] = { ...store.entries[index], ...updates }; writeStore(store, store.entries); + await maybeCommit(filePath, "edit", term, cwd ?? process.cwd(), gitOptions); } /** * Remove a term from the glossary. */ -export function removeTerm( +export async function removeTerm( scope: "global" | "project", term: string, - cwd?: string -): void { + cwd?: string, + gitOptions?: StoreCommitOptions +): Promise { const filePath = resolveStoreTarget(scope, cwd); const store = readStore(filePath); @@ -141,4 +156,34 @@ export function removeTerm( store.entries.splice(index, 1); writeStore(store, store.entries); + await maybeCommit(filePath, "remove", term, cwd ?? process.cwd(), gitOptions); +} + +// --------------------------------------------------------------------------- +// Internal git helpers +// --------------------------------------------------------------------------- + +async function maybeCommit( + filePath: string, + operation: "add" | "edit" | "remove", + term: string, + cwd: string, + gitOptions?: StoreCommitOptions +): Promise { + if (!gitOptions || gitOptions.mode === "manual") return; + + const relFile = relativeFilePath(filePath, cwd); + + if (gitOptions.mode === "batch" && gitOptions.batcher) { + gitOptions.batcher.record({ operation, term, file: filePath }); + return; + } + + // operation mode (or batch fallback when no batcher is available) + const message = resolveCommitMessage(gitOptions.commitMessage, { + operation, + term, + file: relFile, + }); + await commitFile({ filePath, message, cwd }); } diff --git a/src/core/types.ts b/src/core/types.ts index fd3d7c3..2b22b39 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -57,6 +57,31 @@ export interface GlossaryConfig { /** Local UI / control server settings. */ ui?: UiConfig; + + /** Git auto-commit settings. */ + git?: GitConfig; +} + +export type GitCommitMode = "manual" | "operation" | "batch"; + +export interface GitConfig { + /** Auto-commit mode. Default: "manual" (disabled). */ + autoCommit?: GitCommitMode; + /** + * Commit message template for operation mode. + * Supports: {operation} (add|edit|remove), {term}, {file} + */ + commitMessage?: string; + /** + * Commit message template for batch mode. + * Supports: {count}, {terms} (comma-separated list), {file} + */ + batchCommitMessage?: string; + /** + * Seconds of inactivity before a batch commit fires. + * Only used when autoCommit is "batch". Default: 300 (5 minutes). + */ + batchIdleSeconds?: number; } export interface UiConfig { @@ -69,7 +94,6 @@ export interface UiConfig { } export interface SessionState { - /** UUID, stable for the session lifetime. */ sessionId: string; loadedTerms: string[]; lastUpdated: number; @@ -110,4 +134,10 @@ export const DEFAULT_CONFIG: Required = { port: 7337, open: true, }, + git: { + autoCommit: "manual", + commitMessage: "chore(glossary): {operation} term '{term}'", + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + batchIdleSeconds: 300, + }, }; diff --git a/src/index.ts b/src/index.ts index c1db29b..fe84955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export type { GlossaryEntry, GlossaryConfig, + GitConfig, + GitCommitMode, SessionState, LoadedGlossary, GlossaryFile, diff --git a/src/server/control.ts b/src/server/control.ts index f10b7ee..feb204b 100644 --- a/src/server/control.ts +++ b/src/server/control.ts @@ -21,7 +21,10 @@ import { testPattern } from "../core/matcher.js"; import { resolveConfigWithProvenance, writeConfigFile, + loadConfig, } from "../core/config.js"; +import { GitBatcher } from "../core/git-batcher.js"; +import { buildStoreCommitOptions } from "../core/store-git-options.js"; import { homedir } from "node:os"; export interface ControlServerOptions { @@ -34,6 +37,8 @@ export interface ControlServerOptions { export interface ControlServerHandle { url: string; close: () => Promise; + /** Flush any pending batch git commits immediately. No-op if not in batch mode. */ + flushGit: () => Promise; } const DEFAULT_PORT = 7337; @@ -125,6 +130,23 @@ export function createControlApp(opts: ControlServerOptions = {}): Hono { const cwd = opts.cwd ?? process.cwd(); const app = new Hono(); + // --- Git batcher (batch mode only) --- + const config = loadConfig(cwd); + let batcher: GitBatcher | undefined; + if (config.git.autoCommit === "batch") { + batcher = new GitBatcher({ + cwd, + idleSeconds: config.git.batchIdleSeconds ?? 300, + batchCommitMessage: config.git.batchCommitMessage ?? "chore(glossary): update {count} glossary term(s)", + onError: (err) => { + process.stderr.write( + `[open-agent-glossary] git batch commit failed: ${ + err instanceof Error ? err.message : String(err) + }\n` + ); + }, + }); + } app.use( "/api/*", cors({ @@ -182,7 +204,8 @@ export function createControlApp(opts: ControlServerOptions = {}): Hono { source: body.source, tags: body.tags, }; - addTerm(scope, entry, cwd); + const gitOptions = buildStoreCommitOptions(loadConfig(cwd), cwd, batcher); + await addTerm(scope, entry, cwd, gitOptions); return c.json({ ok: true }, 201); } catch (e) { return err(c, e instanceof Error ? e.message : String(e), 409); @@ -211,18 +234,20 @@ export function createControlApp(opts: ControlServerOptions = {}): Hono { if (body[k] !== undefined) (updates as any)[k] = body[k]; } try { - editTerm(scope, term, updates, cwd); + const gitOptions = buildStoreCommitOptions(loadConfig(cwd), cwd, batcher); + await editTerm(scope, term, updates, cwd, gitOptions); return c.json({ ok: true }); } catch (e) { return err(c, e instanceof Error ? e.message : String(e), 404); } }); - app.delete("/api/entries/:term", (c) => { + app.delete("/api/entries/:term", async (c) => { const term = c.req.param("term"); const scope = (c.req.query("scope") ?? "project") as "global" | "project"; try { - removeTerm(scope, term, cwd); + const gitOptions = buildStoreCommitOptions(loadConfig(cwd), cwd, batcher); + await removeTerm(scope, term, cwd, gitOptions); return c.json({ ok: true }); } catch (e) { return err(c, e instanceof Error ? e.message : String(e), 404); @@ -364,14 +389,30 @@ export async function startControlServer( const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }); const url = `http://127.0.0.1:${port}`; + // Grab the batcher from the app context so we can flush on close. + // We create a second config read here — lightweight since it's at startup. + const cfg = loadConfig(cwd); + let batcher: GitBatcher | undefined; + if (cfg.git.autoCommit === "batch") { + batcher = new GitBatcher({ + cwd, + idleSeconds: cfg.git.batchIdleSeconds ?? 300, + batchCommitMessage: cfg.git.batchCommitMessage ?? "chore(glossary): update {count} glossary term(s)", + }); + } + if (opts.open) { void openBrowser(url); } return { url, + flushGit: async () => { + if (batcher) await batcher.flush(); + }, close: () => new Promise((resolve, reject) => { + if (batcher) batcher.dispose(); server.close((e?: Error) => (e ? reject(e) : resolve())); }), }; diff --git a/test/core/git-batcher.test.ts b/test/core/git-batcher.test.ts new file mode 100644 index 0000000..7c07945 --- /dev/null +++ b/test/core/git-batcher.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { GitBatcher } from "../../src/core/git-batcher.js"; + +// Mock git.js and child_process so no real git runs +vi.mock("../../src/core/git.js", () => ({ + isGitAvailable: vi.fn().mockResolvedValue(true), + isGitRepo: vi.fn().mockResolvedValue(true), + resolveBatchCommitMessage: ( + template: string, + vars: { count: number; terms: string[]; file: string } + ) => + template + .replace(/\{count\}/g, String(vars.count)) + .replace(/\{terms\}/g, vars.terms.join(", ")) + .replace(/\{file\}/g, vars.file), + relativeFilePath: () => "glossary.json", +})); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn( + ( + _cmd: string, + args: string[], + _optsOrCb: unknown, + maybeCb?: Function + ) => { + const cb = (maybeCb ?? _optsOrCb) as Function; + // Simulate git diff --cached returning a file name (so commit proceeds) + if (Array.isArray(args) && args.includes("--name-only")) { + cb(null, "glossary.json\n", ""); + } else { + cb(null, "", ""); + } + } + ), +})); + +describe("GitBatcher", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("records pending changes", () => { + const batcher = new GitBatcher({ + cwd: "/tmp/repo", + idleSeconds: 300, + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + }); + + batcher.record({ operation: "add", term: "BFF", file: "/tmp/repo/.agents/glossary.json" }); + batcher.record({ operation: "edit", term: "DRY", file: "/tmp/repo/.agents/glossary.json" }); + + expect(batcher.pendingCount).toBe(2); + batcher.dispose(); + }); + + it("resets the timer on each record, not firing prematurely", () => { + const onCommit = vi.fn(); + const batcher = new GitBatcher({ + cwd: "/tmp/repo", + idleSeconds: 5, + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + onCommit, + }); + + batcher.record({ operation: "add", term: "BFF", file: "/tmp/repo/glossary.json" }); + vi.advanceTimersByTime(3000); // not yet + batcher.record({ operation: "add", term: "DRY", file: "/tmp/repo/glossary.json" }); + vi.advanceTimersByTime(3000); // still not — timer was reset + + expect(batcher.pendingCount).toBe(2); // not flushed yet + batcher.dispose(); + }); + + it("flush() commits all pending changes and clears the list", async () => { + const onCommit = vi.fn(); + const batcher = new GitBatcher({ + cwd: "/tmp/repo", + idleSeconds: 300, + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + onCommit, + }); + + batcher.record({ operation: "add", term: "BFF", file: "/tmp/repo/.agents/glossary.json" }); + batcher.record({ operation: "remove", term: "DRY", file: "/tmp/repo/.agents/glossary.json" }); + + const result = await batcher.flush(); + + expect(result.committed).toBe(true); + expect(batcher.pendingCount).toBe(0); + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ committed: true }), + expect.arrayContaining(["BFF", "DRY"]) + ); + batcher.dispose(); + }); + + it("flush() is idempotent when nothing is pending", async () => { + const batcher = new GitBatcher({ + cwd: "/tmp/repo", + idleSeconds: 300, + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + }); + + const result = await batcher.flush(); + + expect(result.committed).toBe(false); + expect(result.skipped).toBe(true); + batcher.dispose(); + }); + + it("dispose() cancels timer and discards pending changes", () => { + const onCommit = vi.fn(); + const batcher = new GitBatcher({ + cwd: "/tmp/repo", + idleSeconds: 5, + batchCommitMessage: "chore(glossary): update {count} glossary term(s)", + onCommit, + }); + + batcher.record({ operation: "add", term: "API", file: "/tmp/repo/glossary.json" }); + batcher.dispose(); + + vi.runAllTimers(); + expect(onCommit).not.toHaveBeenCalled(); + expect(batcher.pendingCount).toBe(0); + }); +}); diff --git a/test/core/git.test.ts b/test/core/git.test.ts new file mode 100644 index 0000000..11bbf44 --- /dev/null +++ b/test/core/git.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + resolveCommitMessage, + resolveBatchCommitMessage, + commitFile, + isGitRepo, + isGitAvailable, +} from "../../src/core/git.js"; + +const dirs: string[] = []; + +afterEach(() => { + while (dirs.length) { + const d = dirs.pop()!; + rmSync(d, { recursive: true, force: true }); + } +}); + +function makeGitRepo(): string { + const root = mkdtempSync(join(tmpdir(), "oag-git-test-")); + dirs.push(root); + + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: root, stdio: "ignore" }); + // Create an initial commit so we have a valid HEAD + writeFileSync(join(root, ".gitkeep"), ""); + execFileSync("git", ["add", ".gitkeep"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init", "--no-verify"], { cwd: root, stdio: "ignore" }); + + return root; +} + +// ── Pure template functions ───────────────────────────────────────────────── + +describe("resolveCommitMessage", () => { + it("replaces all template variables", () => { + const msg = resolveCommitMessage( + "chore(glossary): {operation} term '{term}' in {file}", + { operation: "add", term: "BFF", file: ".agents/glossary.json" } + ); + expect(msg).toBe("chore(glossary): add term 'BFF' in .agents/glossary.json"); + }); + + it("handles multiple occurrences", () => { + const msg = resolveCommitMessage( + "{operation}: {term} ({operation})", + { operation: "remove", term: "DRY", file: "f.json" } + ); + expect(msg).toBe("remove: DRY (remove)"); + }); +}); + +describe("resolveBatchCommitMessage", () => { + it("replaces count, terms, and file", () => { + const msg = resolveBatchCommitMessage( + "chore(glossary): update {count} glossary term(s) ({terms}) in {file}", + { count: 3, terms: ["BFF", "DRY", "KISS"], file: ".agents/glossary.json" } + ); + expect(msg).toBe( + "chore(glossary): update 3 glossary term(s) (BFF, DRY, KISS) in .agents/glossary.json" + ); + }); + + it("handles single term", () => { + const msg = resolveBatchCommitMessage( + "chore(glossary): update {count} term(s)", + { count: 1, terms: ["API"], file: "g.json" } + ); + expect(msg).toBe("chore(glossary): update 1 term(s)"); + }); +}); + +// ── Real git integration ──────────────────────────────────────────────────── + +describe("isGitAvailable", () => { + it("returns true in test env (git is installed)", async () => { + expect(await isGitAvailable()).toBe(true); + }); +}); + +describe("isGitRepo", () => { + it("returns true inside a real git repo", async () => { + const root = makeGitRepo(); + expect(await isGitRepo(root)).toBe(true); + }); + + it("returns false in a plain temp dir", async () => { + const plain = mkdtempSync(join(tmpdir(), "oag-notgit-")); + dirs.push(plain); + expect(await isGitRepo(plain)).toBe(false); + }); +}); + +describe("commitFile", () => { + it("commits a changed file in a git repo", async () => { + const root = makeGitRepo(); + const filePath = join(root, "glossary.json"); + writeFileSync(filePath, JSON.stringify([{ term: "BFF", definition: "Backend For Frontend" }])); + + const result = await commitFile({ + filePath, + message: "chore(glossary): add term 'BFF'", + cwd: root, + }); + + expect(result.committed).toBe(true); + expect(result.skipped).toBe(false); + + // Verify the commit actually exists + const log = execFileSync("git", ["log", "--oneline"], { cwd: root, encoding: "utf-8" }); + expect(log).toContain("add term 'BFF'"); + }); + + it("skips when there are no changes", async () => { + const root = makeGitRepo(); + const filePath = join(root, "glossary.json"); + // Write + commit the file first so it's clean + writeFileSync(filePath, "[]"); + execFileSync("git", ["add", filePath], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial glossary", "--no-verify"], { cwd: root, stdio: "ignore" }); + + // Call commitFile without changing the file + const result = await commitFile({ + filePath, + message: "should skip", + cwd: root, + }); + + expect(result.committed).toBe(false); + expect(result.skipped).toBe(true); + }); + + it("returns committed:false with reason when not in a git repo", async () => { + const plain = mkdtempSync(join(tmpdir(), "oag-notgit-")); + dirs.push(plain); + const filePath = join(plain, "glossary.json"); + writeFileSync(filePath, "[]"); + + const result = await commitFile({ + filePath, + message: "should skip", + cwd: plain, + }); + + expect(result.committed).toBe(false); + expect(result.reason).toContain("not a git repo"); + }); +}); diff --git a/test/core/store.test.ts b/test/core/store.test.ts index cb39d81..a86d064 100644 --- a/test/core/store.test.ts +++ b/test/core/store.test.ts @@ -19,8 +19,8 @@ afterEach(() => { }); describe("store", () => { - it("adds a term", () => { - addTerm("project", { term: "New", definition: "New term" }, TEST_DIR); + it("adds a term", async () => { + await addTerm("project", { term: "New", definition: "New term" }, TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); @@ -28,35 +28,37 @@ describe("store", () => { expect(content[1].term).toBe("New"); }); - it("rejects duplicate term", () => { - expect(() => + it("rejects duplicate term", async () => { + await expect( addTerm("project", { term: "Existing", definition: "Dup" }, TEST_DIR) - ).toThrow("already exists"); + ).rejects.toThrow("already exists"); }); - it("edits a term", () => { - editTerm("project", "Existing", { definition: "Updated" }, TEST_DIR); + it("edits a term", async () => { + await editTerm("project", "Existing", { definition: "Updated" }, TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); expect(content[0].definition).toBe("Updated"); }); - it("removes a term", () => { - removeTerm("project", "Existing", TEST_DIR); + it("removes a term", async () => { + await removeTerm("project", "Existing", TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); expect(content).toHaveLength(0); }); - it("throws on edit of non-existent term", () => { - expect(() => + it("throws on edit of non-existent term", async () => { + await expect( editTerm("project", "Ghost", { definition: "nope" }, TEST_DIR) - ).toThrow("not found"); + ).rejects.toThrow("not found"); }); - it("throws on remove of non-existent term", () => { - expect(() => removeTerm("project", "Ghost", TEST_DIR)).toThrow("not found"); + it("throws on remove of non-existent term", async () => { + await expect( + removeTerm("project", "Ghost", TEST_DIR) + ).rejects.toThrow("not found"); }); }); diff --git a/test/mcp/tools.test.ts b/test/mcp/tools.test.ts index ae75d49..8c8a57a 100644 --- a/test/mcp/tools.test.ts +++ b/test/mcp/tools.test.ts @@ -56,22 +56,22 @@ describe("MCP tools (integration)", () => { }); describe("glossary_add", () => { - it("adds a new term", () => { - addTerm("project", { term: "YAGNI", definition: "You Aren't Gonna Need It" }, TEST_DIR); + it("adds a new term", async () => { + await addTerm("project", { term: "YAGNI", definition: "You Aren't Gonna Need It" }, TEST_DIR); const glossary = loadGlossary(TEST_DIR); const entry = glossary.entries.find((e) => e.term === "YAGNI"); expect(entry).toBeDefined(); expect(entry!.definition).toBe("You Aren't Gonna Need It"); }); - it("rejects duplicate", () => { - expect(() => + it("rejects duplicate", async () => { + await expect( addTerm("project", { term: "DRY", definition: "dup" }, TEST_DIR) - ).toThrow("already exists"); + ).rejects.toThrow("already exists"); }); - it("supports aliases", () => { - addTerm( + it("supports aliases", async () => { + await addTerm( "project", { term: "API", definition: "Application Programming Interface", aliases: ["rest", "endpoint"] }, TEST_DIR @@ -85,32 +85,32 @@ describe("MCP tools (integration)", () => { }); describe("glossary_edit", () => { - it("updates definition", () => { - editTerm("project", "DRY", { definition: "Updated definition" }, TEST_DIR); + it("updates definition", async () => { + await editTerm("project", "DRY", { definition: "Updated definition" }, TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); expect(content[0].definition).toBe("Updated definition"); }); - it("updates aliases", () => { - editTerm("project", "DRY", { aliases: ["no-repeat"] }, TEST_DIR); + it("updates aliases", async () => { + await editTerm("project", "DRY", { aliases: ["no-repeat"] }, TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); expect(content[0].aliases).toEqual(["no-repeat"]); }); - it("rejects non-existent term", () => { - expect(() => + it("rejects non-existent term", async () => { + await expect( editTerm("project", "GHOST", { definition: "x" }, TEST_DIR) - ).toThrow("not found"); + ).rejects.toThrow("not found"); }); }); describe("glossary_remove", () => { - it("removes existing term", () => { - removeTerm("project", "KISS", TEST_DIR); + it("removes existing term", async () => { + await removeTerm("project", "KISS", TEST_DIR); const content = JSON.parse( readFileSync(join(TEST_DIR, ".agents", "glossary.json"), "utf-8") ); @@ -118,8 +118,10 @@ describe("MCP tools (integration)", () => { expect(content[0].term).toBe("DRY"); }); - it("rejects non-existent term", () => { - expect(() => removeTerm("project", "GHOST", TEST_DIR)).toThrow("not found"); + it("rejects non-existent term", async () => { + await expect( + removeTerm("project", "GHOST", TEST_DIR) + ).rejects.toThrow("not found"); }); }); });