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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <patch|minor|major>` 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
Expand Down
10 changes: 7 additions & 3 deletions src/cli/add.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,11 +9,11 @@ interface AddOptions {
cwd?: string;
}

export function addCommand(
export async function addCommand(
term: string,
definition: string,
options: AddOptions
): void {
): Promise<void> {
try {
const scope = (options.scope ?? "project") as "global" | "project";
const cwd = options.cwd ?? process.cwd();
Expand All @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions src/cli/edit.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<void> {
try {
const scope = (options.scope ?? "project") as "global" | "project";
const cwd = options.cwd ?? process.cwd();
Expand All @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions src/cli/remove.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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(
Expand Down
3 changes: 3 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function loadConfig(cwd?: string): Required<GlossaryConfig> {
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
Expand Down Expand Up @@ -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<string, string> = {};
Expand Down
174 changes: 174 additions & 0 deletions src/core/git-batcher.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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<GitCommitResult> {
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;
}
}
}
Loading
Loading