Skip to content
Open
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
29 changes: 29 additions & 0 deletions .github/workflows/lore-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Lore Validate

on:
pull_request:
branches: [main]

jobs:
validate:
runs-on: ubuntu-latest
# Non-blocking initially — remove continue-on-error to enforce
continue-on-error: true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: '20'

- run: npm ci
- run: npm run build

- name: Validate Lore commits in PR
run: |
echo "Validating commits since origin/main..."
node ./bin/lore.js validate --since origin/main || true
echo ""
echo "Note: Lore validation is advisory. To enforce, remove continue-on-error from the workflow."
8 changes: 8 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- id: lore-validate
name: Lore protocol validation
description: Validate commit messages follow the Lore protocol
entry: lore validate --commit-msg-file
language: node
stages: [commit-msg]
always_run: true
additional_dependencies: ['lore-protocol']
93 changes: 93 additions & 0 deletions src/commands/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Command } from 'commander';
import type { IGitClient } from '../interfaces/git-client.js';
import type { IOutputFormatter } from '../interfaces/output-formatter.js';
import type { IHookInstaller } from '../interfaces/hook-installer.js';

/**
* Register the `lore hooks` command group.
* Subcommands: install, uninstall.
*/
export function registerHooksCommand(
program: Command,
deps: {
hookInstaller: IHookInstaller;
gitClient: IGitClient;
getFormatter: () => IOutputFormatter;
},
): void {
const hooks = program
.command('hooks')
.description('Manage git hooks for Lore protocol enforcement');

async function resolveRepoRoot(): Promise<string | null> {
const { gitClient, getFormatter } = deps;
try {
return await gitClient.getRepoRoot();
} catch {
const formatter = getFormatter();
console.error(formatter.formatError(1, [
{ severity: 'error', message: 'Not inside a git repository' },
]));
process.exitCode = 1;
return null;
}
}

hooks
.command('install')
.description('Install commit-msg hook for Lore validation')
.option('--force', 'Overwrite existing non-Lore hooks (backs up to .bak)')
.action(async (options: { force?: boolean }) => {
const { hookInstaller, getFormatter } = deps;
const repoRoot = await resolveRepoRoot();
if (!repoRoot) return;

const result = await hookInstaller.install(repoRoot, { force: options.force ?? false });
const formatter = getFormatter();

if (!result.installed) {
console.error(formatter.formatError(1, [
{ severity: 'error', message: `Hook already exists at ${result.hookPath}. Use --force to overwrite (existing hook will be backed up to .bak).` },
]));
process.exitCode = 1;
return;
}

if (result.backedUp) {
console.log(formatter.formatSuccess(`Existing hook backed up to ${result.backedUp}`));
}

if (result.alreadyInstalled) {
console.log(formatter.formatSuccess('Lore commit-msg hook updated.'));
} else {
console.log(formatter.formatSuccess(`Lore commit-msg hook installed at ${result.hookPath}`));
}
});

hooks
.command('uninstall')
.description('Remove Lore-managed commit-msg hook')
.action(async () => {
const { hookInstaller, getFormatter } = deps;
const repoRoot = await resolveRepoRoot();
if (!repoRoot) return;

const result = await hookInstaller.uninstall(repoRoot);
const formatter = getFormatter();

if (result.notLoreHook) {
console.error(formatter.formatError(1, [
{ severity: 'error', message: `Hook at ${result.hookPath} was not installed by Lore. Remove it manually if needed.` },
]));
process.exitCode = 1;
return;
}

if (!result.removed) {
console.log(formatter.formatSuccess('No commit-msg hook found. Nothing to remove.'));
return;
}

console.log(formatter.formatSuccess('Lore commit-msg hook removed.'));
});
}
6 changes: 6 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ max_depth = 3

[cli]
update_check = true

[hooks]
enforce = true
allow_fixup = true
skip_authors = []
skip_patterns = ["^Merge "]
`;

/**
Expand Down
51 changes: 35 additions & 16 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { Command } from 'commander';
import type { Validator } from '../services/validator.js';
import type { IGitClient } from '../interfaces/git-client.js';
import type { IGitClient, RawCommit } from '../interfaces/git-client.js';
import type { IOutputFormatter } from '../interfaces/output-formatter.js';
import type { TrailerParser } from '../services/trailer-parser.js';
import type { CommitValidationResult, FormattableValidationResult, ValidationIssue } from '../types/output.js';
import { readFile } from 'node:fs/promises';

interface ValidateCommandOptions {
readonly since?: string;
readonly last?: number;
readonly strict?: boolean;
readonly commitMsgFile?: string;
}

/**
Expand All @@ -21,6 +24,7 @@ export function registerValidateCommand(
deps: {
validator: Validator;
gitClient: IGitClient;
trailerParser: TrailerParser;
getFormatter: () => IOutputFormatter;
},
): void {
Expand All @@ -30,26 +34,41 @@ export function registerValidateCommand(
.option('--since <ref>', 'Validate all commits since ref (e.g., main)')
.option('--last <n>', 'Validate the last N commits', parseInt)
.option('--strict', 'Treat warnings as errors')
.option('--commit-msg-file <path>', 'Validate a commit message file (used by git hooks)')
.action(async (range: string | undefined, options: ValidateCommandOptions) => {
const { validator, gitClient, getFormatter } = deps;
const { validator, gitClient, trailerParser, getFormatter } = deps;

// Determine the revision range
let logArgs: string[];
let rawCommits: readonly RawCommit[];

if (range) {
// Explicit range: e.g., HEAD~5..HEAD or main..feature
logArgs = [range];
} else if (options.since) {
logArgs = [`${options.since}..HEAD`];
} else if (options.last !== undefined && options.last > 0) {
logArgs = [`-${options.last}`];
if (options.commitMsgFile) {
// Hook mode: validate a commit message file before the commit exists
const content = await readFile(options.commitMsgFile, 'utf-8');
const trailerBlock = trailerParser.extractTrailerBlock(content);
const lines = content.split('\n');
rawCommits = [{
hash: '(pending)',
date: new Date().toISOString(),
author: '',
subject: lines[0] ?? '',
body: content,
trailers: trailerBlock,
}];
} else {
// Default: last commit
logArgs = ['-1'];
}
// Normal mode: validate commits from git history
let logArgs: string[];

if (range) {
logArgs = [range];
} else if (options.since) {
logArgs = [`${options.since}..HEAD`];
} else if (options.last !== undefined && options.last > 0) {
logArgs = [`-${options.last}`];
} else {
logArgs = ['-1'];
}

// Get raw commits from git
const rawCommits = await gitClient.log(logArgs);
rawCommits = await gitClient.log(logArgs);
}

// Validate all commits
let results: readonly CommitValidationResult[] = await validator.validate(rawCommits);
Expand Down
23 changes: 23 additions & 0 deletions src/interfaces/hook-installer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { LoreConfig } from '../types/config.js';

export interface IHookScriptGenerator {
generateCommitMsgHook(config: LoreConfig['hooks']): string;
}

export interface HookInstallResult {
readonly installed: boolean;
readonly hookPath: string;
readonly backedUp?: string;
readonly alreadyInstalled: boolean;
}

export interface HookUninstallResult {
readonly removed: boolean;
readonly hookPath: string;
readonly notLoreHook: boolean;
}

export interface IHookInstaller {
install(repoRoot: string, options: { force: boolean }): Promise<HookInstallResult>;
uninstall(repoRoot: string): Promise<HookUninstallResult>;
}
13 changes: 13 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import { registerCommitCommand } from './commands/commit.js';
import { registerValidateCommand } from './commands/validate.js';
import { registerSquashCommand } from './commands/squash.js';
import { registerDoctorCommand } from './commands/doctor.js';
import { registerHooksCommand } from './commands/hooks.js';

import { HookScriptGenerator } from './services/hook-script-generator.js';
import { HookInstaller } from './services/hook-installer.js';

import { LoreError, ValidationError } from './util/errors.js';
import { shouldCheckForUpdate } from './util/update-check.js';
Expand Down Expand Up @@ -182,6 +186,7 @@ async function main(): Promise<void> {
registerValidateCommand(program, {
validator,
gitClient,
trailerParser,
getFormatter,
});

Expand All @@ -197,6 +202,14 @@ async function main(): Promise<void> {
getFormatter,
});

const hookScriptGenerator = new HookScriptGenerator();
const hookInstaller = new HookInstaller(hookScriptGenerator, config.hooks);
registerHooksCommand(program, {
hookInstaller,
gitClient,
getFormatter,
});

// 7. Parse and run
await program.parseAsync(process.argv);
}
Expand Down
5 changes: 5 additions & 0 deletions src/services/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const CAMEL_TO_SNAKE: Record<string, Record<string, string>> = {
cli: {
updateCheck: 'update_check',
},
hooks: {
allowFixup: 'allow_fixup',
skipAuthors: 'skip_authors',
skipPatterns: 'skip_patterns',
},
};

const VALID_OUTPUT_FORMATS = new Set(['text', 'json']);
Expand Down
83 changes: 83 additions & 0 deletions src/services/hook-installer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { readFile, writeFile, mkdir, rename, unlink, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import type { IHookInstaller, IHookScriptGenerator, HookInstallResult, HookUninstallResult } from '../interfaces/hook-installer.js';
import type { LoreConfig } from '../types/config.js';
import { LORE_MARKER } from './hook-script-generator.js';

/**
* Installs and uninstalls Lore-managed git hooks.
*
* GRASP: Pure Fabrication — filesystem infrastructure for hook management.
* SOLID: SRP — only manages hook file operations. Script generation is
* delegated to HookScriptGenerator.
*/
export class HookInstaller implements IHookInstaller {
constructor(
private readonly scriptGenerator: IHookScriptGenerator,
private readonly hooksConfig: LoreConfig['hooks'],
) {}

async install(repoRoot: string, options: { force: boolean }): Promise<HookInstallResult> {
const hooksDir = join(repoRoot, '.git', 'hooks');
const hookPath = join(hooksDir, 'commit-msg');

await mkdir(hooksDir, { recursive: true });

let existingContent: string | null = null;
try {
existingContent = await readFile(hookPath, 'utf-8');
} catch {
// No existing hook
}

if (existingContent !== null) {
if (this.isLoreHook(existingContent)) {
// Lore-managed hook — overwrite silently (upgrade)
} else if (!options.force) {
// Non-Lore hook exists, no --force — refuse
return { installed: false, hookPath, alreadyInstalled: false };
} else {
// Non-Lore hook exists with --force — back up then install
const backupPath = `${hookPath}.bak`;
await rename(hookPath, backupPath);
await this.writeHook(hookPath);
return { installed: true, hookPath, backedUp: backupPath, alreadyInstalled: false };
}
}

await this.writeHook(hookPath);
return {
installed: true,
hookPath,
alreadyInstalled: existingContent !== null && this.isLoreHook(existingContent),
};
}

async uninstall(repoRoot: string): Promise<HookUninstallResult> {
const hookPath = join(repoRoot, '.git', 'hooks', 'commit-msg');

let content: string;
try {
content = await readFile(hookPath, 'utf-8');
} catch {
return { removed: false, hookPath, notLoreHook: false };
}

if (!this.isLoreHook(content)) {
return { removed: false, hookPath, notLoreHook: true };
}

await unlink(hookPath);
return { removed: true, hookPath, notLoreHook: false };
}

private async writeHook(hookPath: string): Promise<void> {
const script = this.scriptGenerator.generateCommitMsgHook(this.hooksConfig);
await writeFile(hookPath, script, 'utf-8');
await chmod(hookPath, 0o755);
}

private isLoreHook(content: string): boolean {
return content.includes(LORE_MARKER);
}
}
Loading
Loading