From 7973b89845f7956ff15b2ec956a8610867d1bc3e Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:22:35 -0800 Subject: [PATCH 01/19] Fix JSR installation bug and add version management tool - Fix critical bug: JSR installations now generate valid hook paths (fixes #9) * Detect JSR installations and use .href instead of .pathname * Preserves full jsr:@scope/package@version/path format - Add comprehensive version management with deno task version command * Display current version * Bump patch/minor/major versions automatically * Create and push release tags * Built-in help and validation - Improve integration test path validation * Extract and validate exact path format * Reject invalid paths like /@scope/package/... --- CHANGELOG.md | 14 ++ CLAUDE.md | 58 ++++++-- deno.json | 3 +- scripts/test-fake-repo.ts | 32 ++++- scripts/version.ts | 276 ++++++++++++++++++++++++++++++++++++++ src/install.ts | 14 +- 6 files changed, 378 insertions(+), 19 deletions(-) create mode 100644 scripts/version.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8d218..a184b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ > **Policy**: Concise entries for user-visible changes since last tag. One-line > per change. Ignore internal cleanup, specs, and test-only changes. +## [Unreleased] + +### Added + +- Version management script with `deno task version` command for displaying, + bumping (patch/minor/major), and tagging releases + +### Fixed + +- Critical bug: JSR installations now generate valid hook paths with `jsr:` + protocol instead of invalid filesystem paths (fixes #9) +- Integration tests now properly validate hook path formats to catch path + generation bugs + ## [0.2.0] - 2024-12-14 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 72c0084..8d51f5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ When git triggers a hook (e.g., on commit): ## Project Structure -``` +```text deno-hooks/ ├── src/ │ ├── mod.ts # Main exports @@ -64,7 +64,8 @@ deno-hooks/ │ └── test-hook.test.ts # Unit tests ├── scripts/ │ ├── doc-coverage.ts # Documentation coverage checker -│ └── test-fake-repo.ts # Integration tests +│ ├── test-fake-repo.ts # Integration tests +│ └── version.ts # Version management (display, bump, tag) ├── deno-hooks.yml # This repo's hook config ├── deno.json # Package configuration └── README.md # User documentation @@ -134,14 +135,53 @@ export const BUILTIN_HOOKS = { ## Publishing -Publishing to JSR is automated via GitHub Actions: +Publishing to JSR is automated via GitHub Actions. Use the `deno task version` +command to manage versions and releases. + +### Version Management Commands + +```bash +# Display current version +deno task version + +# Bump version (automatically updates deno.json) +deno task version patch # 0.2.0 -> 0.2.1 +deno task version minor # 0.2.0 -> 0.3.0 +deno task version major # 0.2.0 -> 1.0.0 + +# Create and push release tag +deno task version tag + +# Show help +deno task version help +``` + +### Release Workflow + +#### Option 1: Automated version bump + +1. Update version: `deno task version patch` (or `minor`/`major`) +2. Update `CHANGELOG.md` (move changes from `[Unreleased]` to new version) +3. Commit: `git commit -am "Bump version to $(deno task version)"` +4. Push: `git push` +5. Create tag: `deno task version tag` + +#### Option 2: Manual version in deno.json + +1. Edit `deno.json` to change version manually +2. Update `CHANGELOG.md` (move changes from `[Unreleased]` to new version) +3. Commit: `git commit -am "Bump version to X.Y.Z"` +4. Push: `git push` +5. Create tag: `deno task version tag` + +The `deno task version tag` command will: -1. Update version in `deno.json` -2. Update `CHANGELOG.md` -3. Commit: `git commit -m "Bump version to X.Y.Z"` -4. Tag: `git tag vX.Y.Z` -5. Push: `git push && git push --tags` -6. GitHub Actions publishes to JSR automatically +- Read the version from `deno.json` +- Verify the working directory is clean +- Check that the tag doesn't already exist +- Create an annotated git tag (e.g., `v0.2.1`) +- Push the tag to GitHub +- Trigger GitHub Actions to publish to JSR automatically See [.github/workflows/publish.yml](.github/workflows/publish.yml). diff --git a/deno.json b/deno.json index 4355758..134e613 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "test-integration": "deno run -A scripts/test-fake-repo.ts", "coverage": "deno test -A --coverage=.coverage && deno coverage .coverage", "doc": "deno doc src/mod.ts", - "doc-coverage": "deno run -A scripts/doc-coverage.ts" + "doc-coverage": "deno run -A scripts/doc-coverage.ts", + "version": "deno run -A scripts/version.ts" }, "fmt": { "exclude": [] diff --git a/scripts/test-fake-repo.ts b/scripts/test-fake-repo.ts index 115db6f..bffd184 100644 --- a/scripts/test-fake-repo.ts +++ b/scripts/test-fake-repo.ts @@ -204,23 +204,41 @@ async function testHookPathIsCorrect() { const hookPath = join(TEMP_DIR, ".git/hooks/pre-commit"); const hookContent = await Deno.readTextFile(hookPath); - // Check that it references the deno-hooks package, not the test repo - const hasCorrectPath = hookContent.includes("src/run.ts") && - !hookContent.includes(`"${TEMP_DIR}/src/run.ts"`); + // Extract the actual path from the hook script + const pathMatch = hookContent.match(/exec deno run -A "(.+)" "pre-commit"/); + const extractedPath = pathMatch?.[1]; - if (hasCorrectPath) { + if (!extractedPath) { + results.push({ + name: "Hook script references correct path", + passed: false, + error: "Could not extract path from hook script", + }); + console.log(" ❌ FAIL: Could not extract path from hook script\n"); + return; + } + + // Validate it's one of the valid formats: + // 1. JSR specifier: jsr:@scope/package@version/path + // 2. Local file path: /absolute/path/to/file (should not be in temp dir) + const isValidJSR = extractedPath.startsWith("jsr:@"); + const isValidLocalPath = extractedPath.startsWith("/") && + extractedPath.includes("/src/run.ts") && + !extractedPath.includes(TEMP_DIR); + + if (isValidJSR || isValidLocalPath) { results.push({ name: "Hook script references correct path", passed: true, }); - console.log(" ✅ PASS: Hook references package path\n"); + console.log(` ✅ PASS: Hook references valid path: ${extractedPath}\n`); } else { results.push({ name: "Hook script references correct path", passed: false, - error: `Hook references wrong path: ${hookContent}`, + error: `Invalid path format: ${extractedPath}`, }); - console.log(" ❌ FAIL: Hook references wrong path\n"); + console.log(` ❌ FAIL: Invalid path format: ${extractedPath}\n`); } } diff --git a/scripts/version.ts b/scripts/version.ts new file mode 100644 index 0000000..4be5b5b --- /dev/null +++ b/scripts/version.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env -S deno run -A +/** + * Version Management Script + * + * This script provides comprehensive version management: + * - Display current version + * - Bump version (patch/minor/major) + * - Create and push git tags + * + * Usage: + * deno task version # Display current version + * deno task version patch # Bump patch version (0.2.0 -> 0.2.1) + * deno task version minor # Bump minor version (0.2.0 -> 0.3.0) + * deno task version major # Bump major version (0.2.0 -> 1.0.0) + * deno task version tag # Create and push git tag for current version + */ + +import { join } from "@std/path"; + +interface DenoConfig { + version: string; + [key: string]: unknown; +} + +type BumpType = "patch" | "minor" | "major"; + +async function runCommand( + cmd: string[], + options?: { silent?: boolean }, +): Promise<{ success: boolean; output: string }> { + const command = new Deno.Command(cmd[0], { + args: cmd.slice(1), + stdout: "piped", + stderr: "piped", + }); + + const { code, stdout, stderr } = await command.output(); + const output = new TextDecoder().decode(stdout) + + new TextDecoder().decode(stderr); + + if (!options?.silent) { + console.log(output); + } + + return { success: code === 0, output }; +} + +async function getConfig(): Promise<{ config: DenoConfig; path: string }> { + const configPath = join(Deno.cwd(), "deno.json"); + const configText = await Deno.readTextFile(configPath); + const config: DenoConfig = JSON.parse(configText); + return { config, path: configPath }; +} + +async function getCurrentVersion(): Promise { + const { config } = await getConfig(); + return config.version; +} + +function parseVersion( + version: string, +): { major: number; minor: number; patch: number } { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) { + throw new Error(`Invalid version format: ${version}`); + } + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + }; +} + +function bumpVersion(version: string, type: BumpType): string { + const { major, minor, patch } = parseVersion(version); + + switch (type) { + case "major": + return `${major + 1}.0.0`; + case "minor": + return `${major}.${minor + 1}.0`; + case "patch": + return `${major}.${minor}.${patch + 1}`; + } +} + +async function updateVersion(newVersion: string): Promise { + const { config, path } = await getConfig(); + config.version = newVersion; + + // Write back with pretty formatting + const configText = JSON.stringify(config, null, 2) + "\n"; + await Deno.writeTextFile(path, configText); +} + +async function checkGitStatus(): Promise { + const result = await runCommand(["git", "status", "--porcelain"], { + silent: true, + }); + return result.output.trim() === ""; +} + +async function checkTagExists(tag: string): Promise { + const result = await runCommand(["git", "tag", "-l", tag], { silent: true }); + return result.output.trim() === tag; +} + +async function displayVersion(): Promise { + const version = await getCurrentVersion(); + console.log(version); +} + +async function bump(type: BumpType): Promise { + const currentVersion = await getCurrentVersion(); + const newVersion = bumpVersion(currentVersion, type); + + console.log(`📦 Version Bump (${type})\n`); + console.log(`Current version: ${currentVersion}`); + console.log(`New version: ${newVersion}`); + + // Check if working directory is clean + console.log("\n🔍 Checking git status..."); + const isClean = await checkGitStatus(); + + if (!isClean) { + console.error( + "\n❌ Working directory is not clean. Please commit or stash your changes first.", + ); + console.error("Run: git status"); + Deno.exit(1); + } + console.log("✅ Working directory is clean"); + + // Update version in deno.json + console.log(`\n📝 Updating deno.json...`); + await updateVersion(newVersion); + console.log("✅ Updated deno.json"); + + console.log("\n✅ Version bumped successfully!"); + console.log("\nNext steps:"); + console.log("1. Update CHANGELOG.md"); + console.log(`2. Review changes: git diff deno.json`); + console.log(`3. Commit: git commit -am "Bump version to ${newVersion}"`); + console.log("4. Push: git push"); + console.log("5. Tag: deno task version tag"); +} + +async function tag(): Promise { + const version = await getCurrentVersion(); + const tagName = `v${version}`; + + console.log("🏷️ Creating Git Tag for Release\n"); + console.log(`📦 Version: ${version}`); + + // Check if working directory is clean + console.log("\n🔍 Checking git status..."); + const isClean = await checkGitStatus(); + + if (!isClean) { + console.error( + "\n❌ Working directory is not clean. Please commit or stash your changes first.", + ); + console.error("Run: git status"); + Deno.exit(1); + } + console.log("✅ Working directory is clean"); + + // Check if tag already exists + console.log(`\n🔍 Checking if tag ${tagName} exists...`); + const tagExists = await checkTagExists(tagName); + + if (tagExists) { + console.error(`\n❌ Tag ${tagName} already exists.`); + console.error( + `If you want to update the tag, delete it first with: git tag -d ${tagName} && git push origin :refs/tags/${tagName}`, + ); + Deno.exit(1); + } + console.log(`✅ Tag ${tagName} does not exist yet`); + + // Create tag + console.log(`\n🏷️ Creating tag ${tagName}...`); + const createResult = await runCommand([ + "git", + "tag", + "-a", + tagName, + "-m", + `Release ${version}`, + ]); + + if (!createResult.success) { + console.error(`\n❌ Failed to create tag ${tagName}`); + Deno.exit(1); + } + console.log(`✅ Created tag ${tagName}`); + + // Push tag + console.log(`\n📤 Pushing tag ${tagName} to remote...`); + const pushResult = await runCommand(["git", "push", "origin", tagName]); + + if (!pushResult.success) { + console.error(`\n❌ Failed to push tag ${tagName}`); + console.error("Cleaning up local tag..."); + await runCommand(["git", "tag", "-d", tagName]); + Deno.exit(1); + } + console.log(`✅ Pushed tag ${tagName} to remote`); + + console.log("\n✅ Release tag created successfully!"); + console.log( + `\n🚀 GitHub Actions will now publish version ${version} to JSR.`, + ); + console.log( + " Check the progress at: https://github.com/TheSwanFactory/deno-hooks/actions", + ); +} + +function showHelp(): void { + console.log(`Version Management Tool + +Usage: + deno task version Display current version + deno task version patch Bump patch version (0.2.0 -> 0.2.1) + deno task version minor Bump minor version (0.2.0 -> 0.3.0) + deno task version major Bump major version (0.2.0 -> 1.0.0) + deno task version tag Create and push git tag for current version + deno task version help Show this help message + +Examples: + # Check current version + deno task version + + # Bump patch version and commit + deno task version patch + git commit -am "Bump version to $(deno task version)" + + # Create release tag + deno task version tag +`); +} + +// Main execution +try { + const args = Deno.args; + const command = args[0]; + + switch (command) { + case undefined: + await displayVersion(); + break; + case "patch": + case "minor": + case "major": + await bump(command); + break; + case "tag": + await tag(); + break; + case "help": + case "--help": + case "-h": + showHelp(); + break; + default: + console.error(`Unknown command: ${command}`); + console.error("Run 'deno task version help' for usage information"); + Deno.exit(1); + } +} catch (error) { + console.error( + "\n❌ Version operation failed:", + error instanceof Error ? error.message : String(error), + ); + Deno.exit(1); +} diff --git a/src/install.ts b/src/install.ts index 7965250..8afeac3 100644 --- a/src/install.ts +++ b/src/install.ts @@ -128,8 +128,18 @@ async function installHook( */ function generateHookScript(hookName: string): string { // Use #!/bin/sh for maximum portability - // Use import.meta.resolve to get the path to run.ts in the installed package - const runScriptPath = new URL("./run.ts", import.meta.url).pathname; + // Detect if we're running from JSR or local filesystem + const isJSR = import.meta.url.startsWith("jsr:"); + + let runScriptPath: string; + if (isJSR) { + // For JSR installations, preserve the full JSR specifier (jsr:@scope/package@version/path) + runScriptPath = new URL("./run.ts", import.meta.url).href; + } else { + // For local filesystem, use pathname to get absolute file path + runScriptPath = new URL("./run.ts", import.meta.url).pathname; + } + return `#!/bin/sh # Generated by deno-hooks - DO NOT EDIT # To update, run: deno task setup From 478e47731f8cc641a2cc9709edf372d749fe2b66 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:22:55 -0800 Subject: [PATCH 02/19] Bump version to 0.2.1 --- CHANGELOG.md | 2 ++ deno.json | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a184b93..5f7afdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ## [Unreleased] +## [0.2.1] - 2024-12-14 + ### Added - Version management script with `deno task version` command for displaying, diff --git a/deno.json b/deno.json index 134e613..7e635f4 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.0", + "version": "0.2.1", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", @@ -21,7 +21,9 @@ }, "lint": { "rules": { - "tags": ["recommended"] + "tags": [ + "recommended" + ] } }, "imports": { From 555269675824aa6dffcbe30dfe6c29a8c94fbaa0 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:43:39 -0800 Subject: [PATCH 03/19] Add dev pre-release tagging for JSR testing - Add 'deno task tag-dev' to create timestamped dev pre-releases - Add 'deno task tag' to create stable releases with tests - Add 'deno task test-all' combining unit and integration tests - Update GitHub Actions to publish dev tags (v*.*.*-dev.*) - Remove duplicate 'install' task from deno.json - Dev versions use format: 0.2.1-dev.1734197345 (version-dev.timestamp) - deno.json remains at stable version (no modifications for dev releases) --- .github/workflows/publish.yml | 1 + deno.json | 6 ++- scripts/version.ts | 86 ++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5603935..f78be4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" # Triggers on version tags like v0.1.0, v1.2.3, etc. + - "v*.*.*-dev.*" # Triggers on dev pre-release tags like v0.2.1-dev.1734197345 jobs: publish: diff --git a/deno.json b/deno.json index 7e635f4..52be7be 100644 --- a/deno.json +++ b/deno.json @@ -8,13 +8,15 @@ }, "tasks": { "setup": "deno run -A src/install.ts", - "install": "deno run -A src/install.ts", "test": "deno test -A", "test-integration": "deno run -A scripts/test-fake-repo.ts", + "test-all": "deno test -A && deno task test-integration", "coverage": "deno test -A --coverage=.coverage && deno coverage .coverage", "doc": "deno doc src/mod.ts", "doc-coverage": "deno run -A scripts/doc-coverage.ts", - "version": "deno run -A scripts/version.ts" + "version": "deno run -A scripts/version.ts", + "tag": "deno task test-all && deno run -A scripts/version.ts tag", + "tag-dev": "deno task test-all && deno run -A scripts/version.ts dev" }, "fmt": { "exclude": [] diff --git a/scripts/version.ts b/scripts/version.ts index 4be5b5b..31e678b 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -5,7 +5,7 @@ * This script provides comprehensive version management: * - Display current version * - Bump version (patch/minor/major) - * - Create and push git tags + * - Create and push git tags (stable and dev pre-releases) * * Usage: * deno task version # Display current version @@ -13,6 +13,7 @@ * deno task version minor # Bump minor version (0.2.0 -> 0.3.0) * deno task version major # Bump major version (0.2.0 -> 1.0.0) * deno task version tag # Create and push git tag for current version + * deno task version dev # Create and push dev pre-release tag */ import { join } from "@std/path"; @@ -145,6 +146,82 @@ async function bump(type: BumpType): Promise { console.log("5. Tag: deno task version tag"); } +async function tagDev(): Promise { + const version = await getCurrentVersion(); + const timestamp = Math.floor(Date.now() / 1000); + const devVersion = `${version}-dev.${timestamp}`; + const tagName = `v${devVersion}`; + + console.log("🏷️ Creating Dev Pre-release Tag\n"); + console.log(`📦 Base Version: ${version}`); + console.log(`📦 Dev Version: ${devVersion}`); + + // Check if working directory is clean + console.log("\n🔍 Checking git status..."); + const isClean = await checkGitStatus(); + + if (!isClean) { + console.error( + "\n❌ Working directory is not clean. Please commit or stash your changes first.", + ); + console.error("Run: git status"); + Deno.exit(1); + } + console.log("✅ Working directory is clean"); + + // Check if tag already exists + console.log(`\n🔍 Checking if tag ${tagName} exists...`); + const tagExists = await checkTagExists(tagName); + + if (tagExists) { + console.error(`\n❌ Tag ${tagName} already exists.`); + console.error( + `If you want to update the tag, delete it first with: git tag -d ${tagName} && git push origin :refs/tags/${tagName}`, + ); + Deno.exit(1); + } + console.log(`✅ Tag ${tagName} does not exist yet`); + + // Create tag + console.log(`\n🏷️ Creating tag ${tagName}...`); + const createResult = await runCommand([ + "git", + "tag", + "-a", + tagName, + "-m", + `Dev pre-release ${devVersion}`, + ]); + + if (!createResult.success) { + console.error(`\n❌ Failed to create tag ${tagName}`); + Deno.exit(1); + } + console.log(`✅ Created tag ${tagName}`); + + // Push tag + console.log(`\n📤 Pushing tag ${tagName} to remote...`); + const pushResult = await runCommand(["git", "push", "origin", tagName]); + + if (!pushResult.success) { + console.error(`\n❌ Failed to push tag ${tagName}`); + console.error("Cleaning up local tag..."); + await runCommand(["git", "tag", "-d", tagName]); + Deno.exit(1); + } + console.log(`✅ Pushed tag ${tagName} to remote`); + + console.log("\n✅ Dev pre-release tag created successfully!"); + console.log( + `\n🚀 GitHub Actions will now publish version ${devVersion} to JSR.`, + ); + console.log( + " Check the progress at: https://github.com/TheSwanFactory/deno-hooks/actions", + ); + console.log(`\n📦 Test installation with:`); + console.log(` deno add @theswanfactory/deno-hooks@${devVersion}`); +} + async function tag(): Promise { const version = await getCurrentVersion(); const tagName = `v${version}`; @@ -225,6 +302,7 @@ Usage: deno task version minor Bump minor version (0.2.0 -> 0.3.0) deno task version major Bump major version (0.2.0 -> 1.0.0) deno task version tag Create and push git tag for current version + deno task version dev Create and push dev pre-release tag deno task version help Show this help message Examples: @@ -237,6 +315,9 @@ Examples: # Create release tag deno task version tag + + # Create dev pre-release (use 'deno task tag:dev' to run tests first) + deno task version dev `); } @@ -257,6 +338,9 @@ try { case "tag": await tag(); break; + case "dev": + await tagDev(); + break; case "help": case "--help": case "-h": From 1e688fc4575fb06f0685dcf54379820a1a5e0283 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:45:11 -0800 Subject: [PATCH 04/19] Update CHANGELOG with dev pre-release feature --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7afdd..96176b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ ## [Unreleased] +### Added + +- Dev pre-release tagging workflow with `deno task tag-dev` for testing JSR + installations before stable releases +- `deno task tag` command for creating stable releases with automated testing +- `deno task test-all` combining unit and integration tests +- GitHub Actions support for publishing dev pre-releases (e.g., + `0.2.1-dev.1734197345`) + +### Changed + +- Removed duplicate `install` task from deno.json + ## [0.2.1] - 2024-12-14 ### Added From 927ea19028e977dcba1fdc1ed5778ae2b306dd96 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:46:39 -0800 Subject: [PATCH 05/19] Update dev tag test command to use deno run instead of deno add - Changed from 'deno add' which modifies local config - Now uses 'deno run -A jsr:@package@version' for non-invasive testing - Updated PR description with correct test command --- scripts/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index 31e678b..b6d165a 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -218,8 +218,8 @@ async function tagDev(): Promise { console.log( " Check the progress at: https://github.com/TheSwanFactory/deno-hooks/actions", ); - console.log(`\n📦 Test installation with:`); - console.log(` deno add @theswanfactory/deno-hooks@${devVersion}`); + console.log(`\n📦 Test without modifying your config:`); + console.log(` deno run -A jsr:@theswanfactory/deno-hooks@${devVersion}`); } async function tag(): Promise { From efbdeeb4e4dd090497874efbba46310c9a34ea71 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:48:10 -0800 Subject: [PATCH 06/19] CRITICAL FIX: Use --set-version for dev releases in GitHub Actions - Dev tags were publishing as stable releases (0.2.1 instead of 0.2.1-dev.*) - Extract version from git tag and pass to deno publish --set-version - Only apply --set-version for dev releases (contains '-dev.') - Stable releases continue using version from deno.json This fixes the issue where 0.2.1-dev.1765777443 was published as 0.2.1 --- .github/workflows/publish.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f78be4b..40ce679 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,5 +26,14 @@ jobs: - name: Run tests run: deno test -A + - name: Extract version from tag + id: get_version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Publish to JSR - run: deno publish + run: | + if [[ "${{ steps.get_version.outputs.version }}" == *"-dev."* ]]; then + deno publish --set-version ${{ steps.get_version.outputs.version }} + else + deno publish + fi From 6fa806c173fbf8cf3102725b5f2cffb2b646785c Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:52:31 -0800 Subject: [PATCH 07/19] Update to Deno 2.x for --set-version support - Upgrade denoland/setup-deno from v1 to v2 - Change deno-version from v1.x to v2.x - Deno 2.x supports --set-version flag for dev pre-releases - Allows publishing dev versions without modifying deno.json --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 40ce679..52ff679 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,9 +19,9 @@ jobs: uses: actions/checkout@v4 - name: Setup Deno - uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@v2 with: - deno-version: v1.x + deno-version: v2.x - name: Run tests run: deno test -A From e677f947c230dc7a708d966e38787491135d2306 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:57:19 -0800 Subject: [PATCH 08/19] Update dev tagging to use incremental version numbers - Change from timestamp-based (x.x.x-dev.timestamp) to incremental (x.x.x-dev.1, x.x.x-dev.2, etc.) - Update deno.json with dev version before creating tag (JSR requirement) - Commit version change automatically as part of dev release process - Push both commit and tag together This fixes the JSR publish error where version in config must match publish version. --- scripts/version.ts | 54 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index b6d165a..bab3578 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -146,15 +146,29 @@ async function bump(type: BumpType): Promise { console.log("5. Tag: deno task version tag"); } +function getNextDevVersion(currentVersion: string): string { + // Check if already a dev version (e.g., "0.2.1-dev.2") + const devMatch = currentVersion.match(/^(.+)-dev\.(\d+)$/); + + if (devMatch) { + // Increment existing dev number + const baseVersion = devMatch[1]; + const devNumber = parseInt(devMatch[2]); + return `${baseVersion}-dev.${devNumber + 1}`; + } else { + // Start new dev sequence from stable version + return `${currentVersion}-dev.1`; + } +} + async function tagDev(): Promise { - const version = await getCurrentVersion(); - const timestamp = Math.floor(Date.now() / 1000); - const devVersion = `${version}-dev.${timestamp}`; + const currentVersion = await getCurrentVersion(); + const devVersion = getNextDevVersion(currentVersion); const tagName = `v${devVersion}`; console.log("🏷️ Creating Dev Pre-release Tag\n"); - console.log(`📦 Base Version: ${version}`); - console.log(`📦 Dev Version: ${devVersion}`); + console.log(`📦 Current Version: ${currentVersion}`); + console.log(`📦 New Dev Version: ${devVersion}`); // Check if working directory is clean console.log("\n🔍 Checking git status..."); @@ -182,6 +196,26 @@ async function tagDev(): Promise { } console.log(`✅ Tag ${tagName} does not exist yet`); + // Update version in deno.json + console.log(`\n📝 Updating deno.json to version ${devVersion}...`); + await updateVersion(devVersion); + console.log("✅ Updated deno.json"); + + // Commit the version change + console.log(`\n💾 Committing version change...`); + const commitResult = await runCommand([ + "git", + "commit", + "-am", + `Bump version to ${devVersion} for JSR dev release`, + ]); + + if (!commitResult.success) { + console.error(`\n❌ Failed to commit version change`); + Deno.exit(1); + } + console.log("✅ Committed version change"); + // Create tag console.log(`\n🏷️ Creating tag ${tagName}...`); const createResult = await runCommand([ @@ -199,17 +233,17 @@ async function tagDev(): Promise { } console.log(`✅ Created tag ${tagName}`); - // Push tag - console.log(`\n📤 Pushing tag ${tagName} to remote...`); - const pushResult = await runCommand(["git", "push", "origin", tagName]); + // Push commit and tag + console.log(`\n📤 Pushing commit and tag to remote...`); + const pushResult = await runCommand(["git", "push", "origin", "HEAD", tagName]); if (!pushResult.success) { - console.error(`\n❌ Failed to push tag ${tagName}`); + console.error(`\n❌ Failed to push commit and tag`); console.error("Cleaning up local tag..."); await runCommand(["git", "tag", "-d", tagName]); Deno.exit(1); } - console.log(`✅ Pushed tag ${tagName} to remote`); + console.log(`✅ Pushed commit and tag to remote`); console.log("\n✅ Dev pre-release tag created successfully!"); console.log( From a4e808eac522498ac3ce792770662a117ca6eaf3 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:57:23 -0800 Subject: [PATCH 09/19] Bump version to 0.2.1-dev.1 for JSR dev release --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 52be7be..f81d8b2 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1", + "version": "0.2.1-dev.1", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", From e98f742c08d30b1449aa01ba3281250b2f2a7c95 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:59:06 -0800 Subject: [PATCH 10/19] Add version reset command and dev version checks - Add 'deno task version reset' to revert from dev to stable version - Display warning when running 'deno task version' on dev version - Prevent creating stable release tag when on dev version - Auto-commit and push version reset changes - Update help text with reset command and workflow examples --- scripts/version.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index bab3578..1c0b594 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -109,6 +109,13 @@ async function checkTagExists(tag: string): Promise { async function displayVersion(): Promise { const version = await getCurrentVersion(); console.log(version); + + // Check if it's a dev version + const isDevVersion = version.match(/^(.+)-dev\.(\d+)$/); + if (isDevVersion) { + console.log("\n⚠️ Current version is a dev pre-release."); + console.log("To reset to stable version, run: deno task version reset"); + } } async function bump(type: BumpType): Promise { @@ -161,6 +168,68 @@ function getNextDevVersion(currentVersion: string): string { } } +async function resetFromDev(): Promise { + const currentVersion = await getCurrentVersion(); + const devMatch = currentVersion.match(/^(.+)-dev\.(\d+)$/); + + if (!devMatch) { + console.log("✅ Current version is already stable:", currentVersion); + return; + } + + const stableVersion = devMatch[1]; + console.log("🔄 Resetting from Dev to Stable Version\n"); + console.log(`📦 Current Version: ${currentVersion}`); + console.log(`📦 Stable Version: ${stableVersion}`); + + // Check if working directory is clean + console.log("\n🔍 Checking git status..."); + const isClean = await checkGitStatus(); + + if (!isClean) { + console.error( + "\n❌ Working directory is not clean. Please commit or stash your changes first.", + ); + console.error("Run: git status"); + Deno.exit(1); + } + console.log("✅ Working directory is clean"); + + // Update version in deno.json + console.log(`\n📝 Updating deno.json to version ${stableVersion}...`); + await updateVersion(stableVersion); + console.log("✅ Updated deno.json"); + + // Commit the version change + console.log(`\n💾 Committing version change...`); + const commitResult = await runCommand([ + "git", + "commit", + "-am", + `Reset version to ${stableVersion} after dev testing`, + ]); + + if (!commitResult.success) { + console.error(`\n❌ Failed to commit version change`); + Deno.exit(1); + } + console.log("✅ Committed version change"); + + // Push commit + console.log(`\n📤 Pushing commit to remote...`); + const pushResult = await runCommand(["git", "push"]); + + if (!pushResult.success) { + console.error(`\n❌ Failed to push commit`); + Deno.exit(1); + } + console.log(`✅ Pushed commit to remote`); + + console.log("\n✅ Version reset to stable successfully!"); + console.log("\nNext steps:"); + console.log("1. Create release tag: deno task version tag"); +} + async function tagDev(): Promise { const currentVersion = await getCurrentVersion(); const devVersion = getNextDevVersion(currentVersion); @@ -260,6 +329,16 @@ async function tag(): Promise { const version = await getCurrentVersion(); const tagName = `v${version}`; + // Check if it's a dev version + const devMatch = version.match(/^(.+)-dev\.(\d+)$/); + if (devMatch) { + console.error("❌ Cannot create stable release tag for dev version:", version); + console.error("\nYou must reset to a stable version first."); + console.error("Run: deno task version reset"); + console.error(`\nThis will reset from ${version} to ${devMatch[1]}`); + Deno.exit(1); + } + console.log("🏷️ Creating Git Tag for Release\n"); console.log(`📦 Version: ${version}`); @@ -335,6 +414,7 @@ Usage: deno task version patch Bump patch version (0.2.0 -> 0.2.1) deno task version minor Bump minor version (0.2.0 -> 0.3.0) deno task version major Bump major version (0.2.0 -> 1.0.0) + deno task version reset Reset from dev version to stable (0.2.1-dev.1 -> 0.2.1) deno task version tag Create and push git tag for current version deno task version dev Create and push dev pre-release tag deno task version help Show this help message @@ -347,11 +427,14 @@ Examples: deno task version patch git commit -am "Bump version to $(deno task version)" - # Create release tag - deno task version tag - - # Create dev pre-release (use 'deno task tag:dev' to run tests first) + # Create dev pre-release for testing (use 'deno task tag-dev' to run tests first) deno task version dev + + # Reset to stable version after dev testing + deno task version reset + + # Create stable release tag + deno task version tag `); } @@ -369,6 +452,9 @@ try { case "major": await bump(command); break; + case "reset": + await resetFromDev(); + break; case "tag": await tag(); break; From a9cc41c3296255d0042ec2fe3360fb14cc50d366 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 21:59:11 -0800 Subject: [PATCH 11/19] Reset version to 0.2.1 after dev testing --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index f81d8b2..52be7be 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1-dev.1", + "version": "0.2.1", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", From 0e772d9e8b48a35360b62fe390dc91ae42108f60 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:00:49 -0800 Subject: [PATCH 12/19] Auto-increment dev version from existing tags - Query git tags to find highest existing dev number - Automatically increment to next available dev version - Remove tag existence check (no longer needed with auto-increment) - Example: If v0.2.1-dev.1 exists, automatically create v0.2.1-dev.2 This allows running 'deno task tag-dev' multiple times without manual cleanup. --- scripts/version.ts | 56 ++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index 1c0b594..3cb211f 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -153,19 +153,40 @@ async function bump(type: BumpType): Promise { console.log("5. Tag: deno task version tag"); } -function getNextDevVersion(currentVersion: string): string { - // Check if already a dev version (e.g., "0.2.1-dev.2") +async function getNextDevVersion(currentVersion: string): Promise { + // Extract base version (strip -dev.N if present) const devMatch = currentVersion.match(/^(.+)-dev\.(\d+)$/); + const baseVersion = devMatch ? devMatch[1] : currentVersion; - if (devMatch) { - // Increment existing dev number - const baseVersion = devMatch[1]; - const devNumber = parseInt(devMatch[2]); - return `${baseVersion}-dev.${devNumber + 1}`; - } else { - // Start new dev sequence from stable version - return `${currentVersion}-dev.1`; + // Find all existing dev tags for this base version + const result = await runCommand( + ["git", "tag", "-l", `v${baseVersion}-dev.*`], + { silent: true }, + ); + + if (!result.success) { + // If git command fails, fall back to deno.json version + if (devMatch) { + const devNumber = parseInt(devMatch[2]); + return `${baseVersion}-dev.${devNumber + 1}`; + } + return `${baseVersion}-dev.1`; } + + // Parse all dev numbers from existing tags + const tags = result.output.trim().split("\n").filter((t) => t); + const devNumbers = tags + .map((tag) => { + const match = tag.match(/^v.+-dev\.(\d+)$/); + return match ? parseInt(match[1]) : 0; + }) + .filter((n) => n > 0); + + // Get the highest dev number and increment + const maxDevNumber = devNumbers.length > 0 ? Math.max(...devNumbers) : 0; + const nextDevNumber = maxDevNumber + 1; + + return `${baseVersion}-dev.${nextDevNumber}`; } async function resetFromDev(): Promise { @@ -232,7 +253,7 @@ async function resetFromDev(): Promise { async function tagDev(): Promise { const currentVersion = await getCurrentVersion(); - const devVersion = getNextDevVersion(currentVersion); + const devVersion = await getNextDevVersion(currentVersion); const tagName = `v${devVersion}`; console.log("🏷️ Creating Dev Pre-release Tag\n"); @@ -252,19 +273,6 @@ async function tagDev(): Promise { } console.log("✅ Working directory is clean"); - // Check if tag already exists - console.log(`\n🔍 Checking if tag ${tagName} exists...`); - const tagExists = await checkTagExists(tagName); - - if (tagExists) { - console.error(`\n❌ Tag ${tagName} already exists.`); - console.error( - `If you want to update the tag, delete it first with: git tag -d ${tagName} && git push origin :refs/tags/${tagName}`, - ); - Deno.exit(1); - } - console.log(`✅ Tag ${tagName} does not exist yet`); - // Update version in deno.json console.log(`\n📝 Updating deno.json to version ${devVersion}...`); await updateVersion(devVersion); From 7b7a10fa77de2f8be81551e623026a026f40cef8 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:00:54 -0800 Subject: [PATCH 13/19] Bump version to 0.2.1-dev.1765778019 for JSR dev release --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 52be7be..b8a91e2 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1", + "version": "0.2.1-dev.1765778019", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", From b30f9336e8e1429a78beedae8d99c112a0aa6cdd Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:02:08 -0800 Subject: [PATCH 14/19] Simplify dev version increment - only use deno.json - Remove git tag querying logic - Only look at version in deno.json to determine next dev number - If collision occurs, user will get an error (their responsibility) - Simpler and more predictable behavior --- scripts/version.ts | 43 +++++++++++-------------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index 3cb211f..f44a949 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -153,40 +153,19 @@ async function bump(type: BumpType): Promise { console.log("5. Tag: deno task version tag"); } -async function getNextDevVersion(currentVersion: string): Promise { - // Extract base version (strip -dev.N if present) +function getNextDevVersion(currentVersion: string): string { + // Check if already a dev version (e.g., "0.2.1-dev.2") const devMatch = currentVersion.match(/^(.+)-dev\.(\d+)$/); - const baseVersion = devMatch ? devMatch[1] : currentVersion; - // Find all existing dev tags for this base version - const result = await runCommand( - ["git", "tag", "-l", `v${baseVersion}-dev.*`], - { silent: true }, - ); - - if (!result.success) { - // If git command fails, fall back to deno.json version - if (devMatch) { - const devNumber = parseInt(devMatch[2]); - return `${baseVersion}-dev.${devNumber + 1}`; - } - return `${baseVersion}-dev.1`; + if (devMatch) { + // Increment existing dev number + const baseVersion = devMatch[1]; + const devNumber = parseInt(devMatch[2]); + return `${baseVersion}-dev.${devNumber + 1}`; + } else { + // Start new dev sequence from stable version + return `${currentVersion}-dev.1`; } - - // Parse all dev numbers from existing tags - const tags = result.output.trim().split("\n").filter((t) => t); - const devNumbers = tags - .map((tag) => { - const match = tag.match(/^v.+-dev\.(\d+)$/); - return match ? parseInt(match[1]) : 0; - }) - .filter((n) => n > 0); - - // Get the highest dev number and increment - const maxDevNumber = devNumbers.length > 0 ? Math.max(...devNumbers) : 0; - const nextDevNumber = maxDevNumber + 1; - - return `${baseVersion}-dev.${nextDevNumber}`; } async function resetFromDev(): Promise { @@ -253,7 +232,7 @@ async function resetFromDev(): Promise { async function tagDev(): Promise { const currentVersion = await getCurrentVersion(); - const devVersion = await getNextDevVersion(currentVersion); + const devVersion = getNextDevVersion(currentVersion); const tagName = `v${devVersion}`; console.log("🏷️ Creating Dev Pre-release Tag\n"); From c26e63d28c23a81baddf71ab1d40da72efe38a99 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:02:42 -0800 Subject: [PATCH 15/19] Reset to dev.1 version after manual cleanup --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index b8a91e2..f81d8b2 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1-dev.1765778019", + "version": "0.2.1-dev.1", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", From 44940aae477f3062ad0217838e320d24a3c79716 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:03:00 -0800 Subject: [PATCH 16/19] Bump version to 0.2.1-dev.2 for JSR dev release --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index f81d8b2..5593d65 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1-dev.1", + "version": "0.2.1-dev.2", "exports": { ".": "./src/mod.ts", "./install": "./src/install.ts", From 3f644ed22b78fb522cf852523b9652c167786cb0 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:15:37 -0800 Subject: [PATCH 17/19] Fix formatting in version.ts --- scripts/version.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index f44a949..4354709 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -291,7 +291,13 @@ async function tagDev(): Promise { // Push commit and tag console.log(`\n📤 Pushing commit and tag to remote...`); - const pushResult = await runCommand(["git", "push", "origin", "HEAD", tagName]); + const pushResult = await runCommand([ + "git", + "push", + "origin", + "HEAD", + tagName, + ]); if (!pushResult.success) { console.error(`\n❌ Failed to push commit and tag`); @@ -319,7 +325,10 @@ async function tag(): Promise { // Check if it's a dev version const devMatch = version.match(/^(.+)-dev\.(\d+)$/); if (devMatch) { - console.error("❌ Cannot create stable release tag for dev version:", version); + console.error( + "❌ Cannot create stable release tag for dev version:", + version, + ); console.error("\nYou must reset to a stable version first."); console.error("Run: deno task version reset"); console.error(`\nThis will reset from ${version} to ${devMatch[1]}`); From d4ac35ab5ee8dd5795ee9c9d1468352a38a4a6ac Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 22:17:39 -0800 Subject: [PATCH 18/19] Fix critical bug: version script was bypassing pre-commit hooks - Changed from 'git commit -am' to 'git add deno.json && git commit -m' - The -a flag was staging ALL modified files, bypassing hooks on unstaged changes - Now only commits the specific file that was modified (deno.json) - This ensures pre-commit hooks run on all committed code --- scripts/version.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/version.ts b/scripts/version.ts index 4354709..361051c 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -200,12 +200,13 @@ async function resetFromDev(): Promise { await updateVersion(stableVersion); console.log("✅ Updated deno.json"); - // Commit the version change + // Stage and commit only deno.json console.log(`\n💾 Committing version change...`); + await runCommand(["git", "add", "deno.json"]); const commitResult = await runCommand([ "git", "commit", - "-am", + "-m", `Reset version to ${stableVersion} after dev testing`, ]); @@ -257,12 +258,13 @@ async function tagDev(): Promise { await updateVersion(devVersion); console.log("✅ Updated deno.json"); - // Commit the version change + // Stage and commit only deno.json console.log(`\n💾 Committing version change...`); + await runCommand(["git", "add", "deno.json"]); const commitResult = await runCommand([ "git", "commit", - "-am", + "-m", `Bump version to ${devVersion} for JSR dev release`, ]); From 2b1be337e1d37d14992bf13984435d7e5d433589 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Sun, 14 Dec 2025 23:19:07 -0800 Subject: [PATCH 19/19] Remove obsolete files for v0.3.0 simplification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete src/run.ts (no longer need runtime execution) - Delete src/executor.ts (no more built-in hooks) - Delete src/files.ts (no more file filtering logic) - Delete src/hook.ts (no more complex type definitions) Part of major architectural simplification to self-contained hooks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 39 +++- CLAUDE.md | 362 ++++++++++++++++++++++++++++----- README.md | 268 +++++++++++++++++------- deno-hooks.yml | 31 +-- deno.json | 16 +- examples/advanced.yml | 30 +++ examples/basic.yml | 20 +- examples/deno-json-config.json | 16 ++ scripts/test-fake-repo.ts | 165 +++++++-------- spec/1-tasks/01-task-specs.md | 186 +++++++++++++++++ src/config.ts | 52 ++--- src/executor.ts | 171 ---------------- src/files.ts | 131 ------------ src/hook.ts | 36 ---- src/install.ts | 225 +++++++++++++++----- src/mod.ts | 27 +-- src/run.ts | 169 --------------- src/test-hook.test.ts | 29 +-- 18 files changed, 1072 insertions(+), 901 deletions(-) create mode 100644 examples/advanced.yml create mode 100644 examples/deno-json-config.json create mode 100644 spec/1-tasks/01-task-specs.md delete mode 100644 src/executor.ts delete mode 100644 src/files.ts delete mode 100644 src/hook.ts delete mode 100644 src/run.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96176b2..13e9655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ ## [Unreleased] +## [0.3.0] - 2024-12-14 + +### Changed + +- **BREAKING**: Major architectural simplification - hooks are now simple + command arrays instead of complex objects with glob patterns +- **BREAKING**: Removed built-in hooks (`deno-fmt`, `deno-lint`, `deno-test`) - + use `deno task fmt`, `deno task lint`, `deno task test` instead +- **BREAKING**: Configuration format simplified - hooks are now just arrays of + shell commands +- Generated hook scripts are now self-contained with no dependencies on + deno-hooks runtime +- Hook scripts use `set -e` for fail-fast behavior + +### Removed + +- File glob pattern matching and `pass_filenames` logic (file filtering is now + git's job) +- Built-in hook executors (deno-fmt, deno-lint, deno-test) +- Runtime execution system (src/run.ts, src/executor.ts, src/files.ts) +- Complex hook type definitions + +### Added + +- Self-contained hook scripts that work without deno-hooks installed +- Support for any shell command in hooks (not just deno commands) +- Success message "✓ All hooks passed" after successful hook execution +- New examples: advanced.yml and deno-json-config.json + +## [0.2.1] - 2024-12-14 + ### Added - Dev pre-release tagging workflow with `deno task tag-dev` for testing JSR @@ -16,14 +47,6 @@ - GitHub Actions support for publishing dev pre-releases (e.g., `0.2.1-dev.1734197345`) -### Changed - -- Removed duplicate `install` task from deno.json - -## [0.2.1] - 2024-12-14 - -### Added - - Version management script with `deno task version` command for displaying, bumping (patch/minor/major), and tagging releases diff --git a/CLAUDE.md b/CLAUDE.md index 8d51f5f..d613b1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ git clone https://github.com/TheSwanFactory/deno-hooks.git cd deno-hooks # Install git hooks for this repo -deno task setup +deno task hooks # Run tests deno test -A @@ -24,30 +24,55 @@ deno task test-integration ## Architecture Overview -Deno Hooks has a simple architecture: +Deno Hooks has a **simple** architecture: 1. **Configuration** ([src/config.ts](src/config.ts)) - Loads from `deno-hooks.yml` or `deno.json` -2. **Installation** ([src/install.ts](src/install.ts)) - Generates shell scripts - in `.git/hooks/` -3. **Execution** ([src/run.ts](src/run.ts)) - Runs when git triggers the hook -4. **Built-in Hooks** ([src/executor.ts](src/executor.ts)) - Implements - deno-fmt, deno-lint, deno-test +2. **Installation** ([src/install.ts](src/install.ts)) - Generates + self-contained shell scripts in `.git/hooks/` + +That's it! No runtime execution, no complex file filtering, just simple shell +script generation. ## How It Works -When you run `deno task setup`: +When you run `deno task hooks`: 1. Reads configuration from `deno-hooks.yml` or `deno.json` -2. Creates shell wrapper scripts in `.git/hooks/` (e.g., `pre-commit`) -3. Each script calls `deno run -A src/run.ts ` +2. Creates self-contained shell scripts in `.git/hooks/` (e.g., `pre-commit`) +3. Each script contains the commands directly (no reference to deno-hooks) When git triggers a hook (e.g., on commit): 1. Git executes `.git/hooks/pre-commit` -2. The shell script calls `src/run.ts` -3. `run.ts` loads config, filters files, and executes each hook -4. If any hook fails (exit code ≠ 0), the git operation is blocked +2. The shell script runs each command in order +3. If any command fails (exit code ≠ 0), the git operation is blocked + +## CLI Features + +### Installation Mode + +```bash +# Interactive installation +deno run -A src/install.ts + +# Skip prompts (create default config if missing) +deno run -A src/install.ts --yes + +# Show detailed output +deno run -A src/install.ts --verbose + +# Combine flags +deno run -A src/install.ts --yes --verbose +``` + +### Command Line Arguments + +- `--yes` / `-y`: Skip interactive prompts (install only) +- `--verbose` / `-v`: Show detailed output +- `--help` / `-h`: Show help message + +Arguments are parsed in the `parseArgs()` function. ## Project Structure @@ -56,11 +81,7 @@ deno-hooks/ ├── src/ │ ├── mod.ts # Main exports │ ├── config.ts # Configuration loading -│ ├── install.ts # Hook installation -│ ├── run.ts # Hook execution -│ ├── executor.ts # Built-in hook implementations -│ ├── files.ts # File utilities -│ ├── hook.ts # Type definitions +│ ├── install.ts # Hook installation & CLI │ └── test-hook.test.ts # Unit tests ├── scripts/ │ ├── doc-coverage.ts # Documentation coverage checker @@ -71,6 +92,69 @@ deno-hooks/ └── README.md # User documentation ``` +## Configuration Format + +### Simple Command Lists + +Hooks are just arrays of shell commands: + +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint + + pre-push: + - deno task test +``` + +### No More Built-in Hooks + +Unlike v0.2.x, there are no built-in hooks. Users define their own tasks in +`deno.json`: + +```json +{ + "tasks": { + "fmt": "deno fmt --check", + "lint": "deno lint", + "test": "deno test -A" + } +} +``` + +### Why This Is Better + +1. **Simpler**: No complex file filtering or built-in hooks +2. **Transparent**: Generated hooks are readable shell scripts +3. **Portable**: Hooks work without deno-hooks installed +4. **Flexible**: Users can run any command +5. **Debuggable**: Easy to see what's happening + +## Generated Hook Scripts + +Example `.git/hooks/pre-commit`: + +```bash +#!/bin/sh +# Generated by deno-hooks - DO NOT EDIT +# To update, run: deno task hooks + +set -e + +deno task fmt +deno task lint + +echo "✓ All hooks passed" +``` + +Key features: + +- `#!/bin/sh` - Maximum portability +- `set -e` - Fail-fast behavior +- Direct commands - No indirection +- Success message - Clear feedback + ## Testing ### Unit Tests @@ -82,8 +166,7 @@ deno test -A Tests in [src/test-hook.test.ts](src/test-hook.test.ts): - Configuration parsing -- File pattern matching -- Staged file detection +- Command validation ### Integration Tests @@ -97,42 +180,17 @@ The integration test ([scripts/test-fake-repo.ts](scripts/test-fake-repo.ts)): - Installs hooks from local source - Tests that hooks catch formatting/lint errors - Tests that hooks allow valid commits -- Verifies hook script paths are correct +- Verifies hook scripts are self-contained -**This is critical** - it would have caught the bug in PR #1 where hooks -referenced the wrong path! +**This is critical** - it ensures generated hooks work correctly! ### Pre-Push Hooks This repo uses its own hooks: -- **pre-commit**: Runs `deno fmt` and `deno lint` +- **pre-commit**: Runs `deno task fmt` and `deno task lint` - **pre-push**: Runs unit tests AND integration tests -## Adding Built-in Hooks - -Built-in hooks are defined in [src/executor.ts](src/executor.ts). To add a new -one: - -1. Add the hook to the `BUILTIN_HOOKS` object -2. Implement the execution logic -3. Add tests -4. Update documentation - -Example: - -```typescript -export const BUILTIN_HOOKS = { - "deno-fmt": async (files: string[]) => { - const cmd = ["deno", "fmt", "--check", ...files]; - // ... execution logic - }, - "your-hook": async (files: string[]) => { - // Your implementation - }, -}; -``` - ## Publishing Publishing to JSR is automated via GitHub Actions. Use the `deno task version` @@ -145,9 +203,9 @@ command to manage versions and releases. deno task version # Bump version (automatically updates deno.json) -deno task version patch # 0.2.0 -> 0.2.1 -deno task version minor # 0.2.0 -> 0.3.0 -deno task version major # 0.2.0 -> 1.0.0 +deno task version patch # 0.3.0 -> 0.3.1 +deno task version minor # 0.3.0 -> 0.4.0 +deno task version major # 0.3.0 -> 1.0.0 # Create and push release tag deno task version tag @@ -160,7 +218,7 @@ deno task version help #### Option 1: Automated version bump -1. Update version: `deno task version patch` (or `minor`/`major`) +1. Update version: `deno task version major` (for v1.0.0) 2. Update `CHANGELOG.md` (move changes from `[Unreleased]` to new version) 3. Commit: `git commit -am "Bump version to $(deno task version)"` 4. Push: `git push` @@ -179,12 +237,81 @@ The `deno task version tag` command will: - Read the version from `deno.json` - Verify the working directory is clean - Check that the tag doesn't already exist -- Create an annotated git tag (e.g., `v0.2.1`) +- Create an annotated git tag (e.g., `v1.0.0`) - Push the tag to GitHub - Trigger GitHub Actions to publish to JSR automatically See [.github/workflows/publish.yml](.github/workflows/publish.yml). +## Import Alias Configuration + +The project uses a dual setup for the `deno-hooks` import alias: + +### In this repository (development) + +```json +{ + "imports": { + "deno-hooks": "./src/mod.ts" + } +} +``` + +This allows developers to use `deno task hooks` which runs the local source. + +### In user projects (production) + +Users should configure: + +```json +{ + "imports": { + "deno-hooks": "jsr:@theswanfactory/deno-hooks@^1.0.0" + } +} +``` + +This installs from JSR and gets version updates automatically. + +## Implementation Details + +### Configuration Parsing + +The config parser expects a simple structure: + +```typescript +interface Config { + hooks: { + [hookName: string]: string[]; // Just arrays of commands! + }; +} +``` + +Validation ensures: + +- `hooks` is an object +- Each hook value is an array +- Each command is a non-empty string + +### Hook Script Generation + +The `generateHookScript()` function: + +1. Takes an array of commands +2. Joins them with newlines +3. Wraps in a shell script template +4. Returns the complete script + +No complex logic, just string concatenation! + +### Verbose Output + +The `--verbose` flag adds detailed logging: + +- **Install mode**: Shows paths, file counts, hook details + +This helps users debug configuration issues. + ## Contributing 1. Fork the repository @@ -194,9 +321,132 @@ See [.github/workflows/publish.yml](.github/workflows/publish.yml). 5. Run `deno test -A` and `deno task test-integration` 6. Submit a PR +## Architecture Decisions + +### Why Remove Built-in Hooks? + +**Old way (v0.2.x):** + +```yaml +hooks: + pre-commit: + - id: deno-fmt + run: deno-fmt # Built-in hook + glob: "*.ts" + pass_filenames: true +``` + +**New way (v1.0.0+):** + +```yaml +hooks: + pre-commit: + - deno task fmt # Just a command +``` + +**Benefits:** + +1. **Simpler codebase**: No executor.ts, files.ts, run.ts +2. **User control**: Users define behavior in deno.json tasks +3. **Transparency**: No magic file filtering +4. **Portability**: Hooks are just shell scripts +5. **Flexibility**: Can run ANY command, not just Deno tools + +### Why Self-Contained Scripts? + +**Old way:** `.git/hooks/pre-commit` calls `deno run -A src/run.ts` + +**New way:** `.git/hooks/pre-commit` contains the commands directly + +**Benefits:** + +1. **Independence**: Hooks work without deno-hooks installed +2. **Speed**: No runtime overhead +3. **Debuggability**: Easy to see what runs +4. **Reliability**: No version conflicts or import issues + +### Why Remove File Filtering? + +**Old approach:** Complex glob patterns, pass_filenames, exclude patterns + +**New approach:** Let Deno tasks handle their own files + +**Benefits:** + +1. **Simplicity**: No glob matching logic +2. **Correctness**: Deno tools know which files to check +3. **Performance**: Tools optimize their own file discovery +4. **Flexibility**: Users can filter in their tasks if needed + +Example: + +```json +{ + "tasks": { + "fmt": "deno fmt --check", // Checks all formatted files + "fmt:ts": "deno fmt --check src/", // Checks only src/ + "fmt:staged": "deno fmt --check $(git diff --cached --name-only --diff-filter=ACM)" + } +} +``` + +## Migration from v0.2.x + +This is a **breaking change**. The architecture is completely different. + +### What Changed + +**Removed:** + +- `src/run.ts` - Runtime execution +- `src/executor.ts` - Built-in hooks +- `src/files.ts` - File filtering +- `src/hook.ts` - Type definitions +- Built-in hooks: `deno-fmt`, `deno-lint`, `deno-test` +- File patterns: `glob`, `exclude`, `pass_filenames` + +**Simplified:** + +- `src/config.ts` - Just parse command arrays +- `src/install.ts` - Just generate shell scripts +- `src/mod.ts` - Just export install function + +**Result:** 200+ lines of code removed, vastly simpler architecture. + +### Migration Guide for Users + +**Before:** + +```yaml +hooks: + pre-commit: + - id: deno-fmt + run: deno-fmt + glob: "*.{ts,js}" + pass_filenames: true +``` + +**After:** + +```yaml +hooks: + pre-commit: + - deno task fmt +``` + +And define the task: + +```json +{ + "tasks": { + "fmt": "deno fmt --check" + } +} +``` + ## Resources +- **Spec**: [spec/1-tasks/01-task-specs.md](spec/1-tasks/01-task-specs.md) - **Git Hooks**: [git-scm.com/docs/githooks](https://git-scm.com/docs/githooks) -- **JSR**: [jsr.io](https://jsr.io) -- **Deno**: [deno.land](https://deno.land) -- **Pre-commit** (inspiration): [pre-commit.com](https://pre-commit.com/) +- **JSR Package**: + [jsr.io/@theswanfactory/deno-hooks](https://jsr.io/@theswanfactory/deno-hooks) diff --git a/README.md b/README.md index 01db164..9e0e9fa 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Deno Hooks -Zero-dependency git hooks for Deno projects. Automatically format, lint, and -test your code before commits and pushes. +Zero-dependency git hooks for Deno projects. Run your tasks automatically before +commits and pushes. ## Why Deno Hooks? -- 🦕 **Pure Deno** - No Python (pre-commit) or Node.js (Husky) required -- ⚡ **Fast** - Runs in milliseconds with parallel execution -- 🎯 **Simple** - One command to get started -- 🔒 **Secure** - Uses Deno's permission model +- **Pure Deno** - No Python (pre-commit) or Node.js (Husky) required +- **Fast** - Runs in milliseconds, no complex runtime +- **Simple** - Just shell commands, no magic +- **Transparent** - Generated hooks are readable bash scripts ## Quick Start @@ -19,7 +19,7 @@ deno run -A jsr:@theswanfactory/deno-hooks ``` That's it! If you don't have a configuration file, it will offer to create one -for you with sensible defaults (format, lint, test). +for you with sensible defaults. ### What You Get @@ -27,121 +27,171 @@ Hooks run automatically on every commit: ```bash git commit -m "fix: typo" -# 🔍 Running pre-commit hooks... -# ✓ deno fmt (2 files formatted) -# ✓ deno lint -# All hooks passed! ✨ +# Running pre-commit hooks... +# deno task fmt +# deno task lint +# ✓ All hooks passed ``` ### Make It Easier -Add to your `deno.json` so you can run `deno task setup`: +Add to your `deno.json` so you can run `deno task hooks`: ```json { + "imports": { + "deno-hooks": "jsr:@theswanfactory/deno-hooks@^0.3.0" + }, "tasks": { - "setup": "deno run -A jsr:@theswanfactory/deno-hooks" + "hooks": "deno run -A deno-hooks" } } ``` -## Common Configurations +## Configuration -### Format and Lint +### Minimal Setup (deno-hooks.yml) ```yaml hooks: pre-commit: - - id: deno-fmt - glob: "*.{ts,js,json,md}" - pass_filenames: true + - deno task fmt + - deno task lint - - id: deno-lint - glob: "*.{ts,js}" - pass_filenames: true + pre-push: + - deno task test ``` -### Run Tests Before Push +That's it! Each hook is just a list of commands to run. -```yaml -hooks: - pre-push: - - id: test - run: "deno test -A" +### Define Your Tasks (deno.json) + +```json +{ + "tasks": { + "fmt": "deno fmt --check", + "lint": "deno lint", + "test": "deno test -A" + } +} ``` -### Custom Scripts +### Alternative: Configure in deno.json -```yaml -hooks: - pre-commit: - - id: check-todos - run: "deno run -A scripts/check-todos.ts" - glob: "*.ts" +You can also configure hooks directly in `deno.json`: + +```json +{ + "tasks": { + "fmt": "deno fmt --check", + "lint": "deno lint", + "test": "deno test -A" + }, + "deno-hooks": { + "pre-commit": [ + "deno task fmt", + "deno task lint" + ], + "pre-push": [ + "deno task test" + ] + } +} ``` -## Built-in Hooks +## CLI Options + +### Installation Flags -Use these by setting `run:` to the hook name: +```bash +# Skip interactive prompts (use defaults) +deno run -A jsr:@theswanfactory/deno-hooks --yes -- **deno-fmt** - Automatically format code -- **deno-lint** - Catch common errors -- **deno-test** - Run your test suite +# Show detailed output during installation +deno run -A jsr:@theswanfactory/deno-hooks --verbose -## Configuration Options +# Show help +deno run -A jsr:@theswanfactory/deno-hooks --help +``` -Each hook can have these options: +### Available Options -```yaml -- id: unique-id # Required: identifies the hook - name: Display Name # Optional: shown during execution - run: deno-fmt # Required: built-in hook or command - glob: "*.ts" # Optional: only run on matching files - exclude: "**/*.test.ts" # Optional: skip matching files - pass_filenames: true # Optional: pass files as arguments +| Option | Short | Description | +| ----------- | ----- | ----------------------------------------- | +| `--yes` | `-y` | Skip interactive prompts and use defaults | +| `--verbose` | `-v` | Show detailed output | +| `--help` | `-h` | Show help message | + +## How It Works + +### Generated Hook Scripts + +When you install, deno-hooks generates self-contained shell scripts in +`.git/hooks/`: + +```bash +#!/bin/sh +# Generated by deno-hooks - DO NOT EDIT +# To update, run: deno task hooks + +set -e + +deno task fmt +deno task lint + +echo "✓ All hooks passed" ``` -### File Patterns +**Key features:** -Target specific files: +- **Self-contained**: No reference to deno-hooks +- **Simple**: Just runs commands in order +- **Fail-fast**: `set -e` stops on first error +- **Transparent**: Easy to read and debug -```yaml -# TypeScript and JavaScript -glob: "*.{ts,js}" +## Common Use Cases -# Source directory only -glob: "src/**/*.ts" +### Format and Lint Before Commit -# Exclude test files -glob: "*.ts" -exclude: "**/*.test.ts" +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint ``` -### Configuration Location +### Run Tests Before Push -Choose either: +```yaml +hooks: + pre-push: + - deno task test +``` -**Option 1: `deno-hooks.yml`** (recommended) +### Custom Scripts ```yaml hooks: pre-commit: - - id: deno-fmt - # ... + - deno task fmt + - deno task lint + - deno run -A scripts/check-todos.ts + - ./scripts/validate.sh ``` -**Option 2: `deno.json`** +### Multiple Tasks -```json -{ - "deno-hooks": { - "hooks": { - "pre-commit": [ - { "id": "deno-fmt" } - ] - } - } -} +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint + - deno task typecheck + + pre-push: + - deno task test + - deno task test:integration + - deno run -A scripts/build.ts ``` ## Available Git Hooks @@ -160,7 +210,10 @@ You can configure any standard git hook: ```bash # Reinstall hooks -deno task setup +deno task hooks + +# Or with verbose output to see what's happening +deno task hooks --verbose ``` **Permission errors?** @@ -177,6 +230,71 @@ deno run -A jsr:@theswanfactory/deno-hooks git commit --no-verify -m "emergency fix" ``` +**Need to debug?** + +```bash +# Check what hooks are installed +cat .git/hooks/pre-commit + +# Run hooks manually to see output +./.git/hooks/pre-commit +``` + +## CI/CD Integration + +You can run the same tasks in CI/CD: + +```yaml +# GitHub Actions example +- name: Format check + run: deno task fmt + +- name: Lint + run: deno task lint + +- name: Tests + run: deno task test +``` + +Since hooks just run `deno task` commands, your CI and git hooks stay in sync. + +## Migration from v0.2.x + +Version 1.0.0 is a major simplification. The old format: + +```yaml +# OLD (v0.2.x) +hooks: + pre-commit: + - id: deno-fmt + run: deno-fmt + glob: "*.{ts,js}" + pass_filenames: true +``` + +Becomes: + +```yaml +# NEW (v1.0.0+) +hooks: + pre-commit: + - deno task fmt +``` + +**Key changes:** + +- No more built-in hooks (`deno-fmt`, `deno-lint`, `deno-test`) +- No more file glob patterns +- No more `pass_filenames` logic +- Just simple shell commands + +**Why simpler?** + +- File filtering is git's job (staged files) +- Deno tasks handle their own file discovery +- Less magic = easier to understand and debug +- Generated hooks work without deno-hooks installed + ## Learn More - [Contributing Guide](CLAUDE.md) - Developer documentation diff --git a/deno-hooks.yml b/deno-hooks.yml index 153e690..23d8484 100644 --- a/deno-hooks.yml +++ b/deno-hooks.yml @@ -1,28 +1,11 @@ +# Deno Hooks Configuration +# Learn more: https://jsr.io/@theswanfactory/deno-hooks + hooks: pre-commit: - # Format code with deno fmt - - id: deno-fmt - name: deno fmt - run: deno-fmt - glob: "*.{ts,js,json,md}" - pass_filenames: true - - # Lint code with deno lint - - id: deno-lint - name: deno lint - run: deno-lint - glob: "*.{ts,js}" - pass_filenames: true + - deno task fmt + - deno task lint pre-push: - # Run unit tests before pushing - - id: test - name: unit tests - run: deno-test - pass_filenames: false - - # Run integration tests before pushing - - id: integration-test - name: integration tests - run: "deno run -A scripts/test-fake-repo.ts" - pass_filenames: false + - deno task test + - deno run -A scripts/test-fake-repo.ts diff --git a/deno.json b/deno.json index 5593d65..1231351 100644 --- a/deno.json +++ b/deno.json @@ -1,13 +1,13 @@ { "name": "@theswanfactory/deno-hooks", - "version": "0.2.1-dev.2", + "version": "0.3.0", "exports": { - ".": "./src/mod.ts", - "./install": "./src/install.ts", - "./run": "./src/run.ts" + ".": "./src/mod.ts" }, "tasks": { - "setup": "deno run -A src/install.ts", + "hooks": "deno run -A deno-hooks", + "fmt": "deno fmt --check", + "lint": "deno lint", "test": "deno test -A", "test-integration": "deno run -A scripts/test-fake-repo.ts", "test-all": "deno test -A && deno task test-integration", @@ -32,6 +32,10 @@ "@std/yaml": "jsr:@std/yaml@^1.0.5", "@std/path": "jsr:@std/path@^1.0.8", "@std/fs": "jsr:@std/fs@^1.0.8", - "@std/expect": "jsr:@std/expect@^1.0.8" + "@std/expect": "jsr:@std/expect@^1.0.8", + "deno-hooks": "./src/mod.ts" + }, + "__comments": { + "imports.deno-hooks": "This points to local source for development. Users should use: \"deno-hooks\": \"jsr:@theswanfactory/deno-hooks@^0.3.0\" in their projects." } } diff --git a/examples/advanced.yml b/examples/advanced.yml new file mode 100644 index 0000000..21c7325 --- /dev/null +++ b/examples/advanced.yml @@ -0,0 +1,30 @@ +# Advanced deno-hooks configuration +# This example shows how to run custom commands and scripts + +hooks: + pre-commit: + # Run formatter + - deno fmt --check + + # Run linter + - deno lint + + # Run custom validation script + - deno run -A scripts/validate.ts + + # You can also run non-deno commands + - npm run typecheck + + pre-push: + # Run all tests + - deno task test + + # Run integration tests + - deno task test:integration + + # Run custom build verification + - ./scripts/verify-build.sh + + commit-msg: + # Validate commit message format + - deno run -A scripts/validate-commit-msg.ts diff --git a/examples/basic.yml b/examples/basic.yml index 11fe7e8..8c0c1d5 100644 --- a/examples/basic.yml +++ b/examples/basic.yml @@ -1,22 +1,14 @@ +# Basic deno-hooks configuration +# This example shows the simplest setup + hooks: pre-commit: # Format code with deno fmt - - id: deno-fmt - name: deno fmt - run: deno-fmt - glob: "*.{ts,js,json,md}" - pass_filenames: true + - deno task fmt # Lint code with deno lint - - id: deno-lint - name: deno lint - run: deno-lint - glob: "*.{ts,js}" - pass_filenames: true + - deno task lint pre-push: # Run tests before pushing - - id: test - name: test - run: deno-test - pass_filenames: false + - deno task test diff --git a/examples/deno-json-config.json b/examples/deno-json-config.json new file mode 100644 index 0000000..c856f95 --- /dev/null +++ b/examples/deno-json-config.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "fmt": "deno fmt --check", + "lint": "deno lint", + "test": "deno test -A" + }, + "deno-hooks": { + "pre-commit": [ + "deno task fmt", + "deno task lint" + ], + "pre-push": [ + "deno task test" + ] + } +} diff --git a/scripts/test-fake-repo.ts b/scripts/test-fake-repo.ts index bffd184..d3176a0 100644 --- a/scripts/test-fake-repo.ts +++ b/scripts/test-fake-repo.ts @@ -51,7 +51,7 @@ async function cleanup() { } async function setup() { - console.log("🔧 Setting up test repository..."); + console.log("Setting up test repository..."); // Clean up any existing test dir await cleanup(); @@ -64,28 +64,36 @@ async function setup() { await run(["git", "config", "user.email", "test@example.com"]); await run(["git", "config", "user.name", "Test User"]); + // Create deno.json with tasks + const denoJson = { + tasks: { + fmt: "deno fmt --check", + lint: "deno lint", + }, + }; + // Write with trailing newline so deno fmt is happy + await Deno.writeTextFile( + join(TEMP_DIR, "deno.json"), + JSON.stringify(denoJson, null, 2) + "\n", + ); + // Create deno-hooks.yml configuration const config = `hooks: pre-commit: - - id: deno-fmt - run: deno-fmt - glob: "*.{ts,js,json,md}" - pass_filenames: true - - - id: deno-lint - run: deno-lint - glob: "*.{ts,js}" - pass_filenames: true + - deno task fmt + - deno task lint `; await Deno.writeTextFile(join(TEMP_DIR, "deno-hooks.yml"), config); - // Install hooks from local source - // Note: Integration test provides config file, so no prompt appears + // Install hooks from local source for integration testing + // This tests the actual code changes before publishing + // Use --yes to skip prompts const installResult = await run([ "deno", "run", "-A", join(DENO_HOOKS_SRC, "src/install.ts"), + "--yes", ]); if (!installResult.success) { @@ -94,46 +102,84 @@ async function setup() { ); } - console.log("✅ Test repository ready\n"); + console.log("Test repository ready\n"); +} + +async function testHookScriptIsStandalone() { + console.log("Test: Hook script is self-contained..."); + + // Read the installed pre-commit hook + const hookPath = join(TEMP_DIR, ".git/hooks/pre-commit"); + const hookContent = await Deno.readTextFile(hookPath); + + console.log(" Hook script content:"); + console.log(" " + hookContent.split("\n").join("\n ")); + + // Verify script structure + const hasShebang = hookContent.startsWith("#!/bin/sh"); + const hasSetE = hookContent.includes("set -e"); + const hasCommands = hookContent.includes("deno task fmt") && + hookContent.includes("deno task lint"); + const hasSuccessMessage = hookContent.includes("All hooks passed"); + const hasNoDenoHooksReference = !hookContent.includes("src/run.ts") && + !hookContent.includes("jsr:@theswanfactory/deno-hooks"); + + if ( + hasShebang && hasSetE && hasCommands && hasSuccessMessage && + hasNoDenoHooksReference + ) { + results.push({ + name: "Hook script is self-contained", + passed: true, + }); + console.log(" PASS: Hook script is self-contained and standalone\n"); + } else { + results.push({ + name: "Hook script is self-contained", + passed: false, + error: + `Missing required elements: shebang=${hasShebang}, set -e=${hasSetE}, commands=${hasCommands}, success=${hasSuccessMessage}, standalone=${hasNoDenoHooksReference}`, + }); + console.log(" FAIL: Hook script is not properly self-contained\n"); + } } async function testHookCatchesBadFormatting() { - console.log("📝 Test: Hooks catch bad formatting..."); + console.log("Test: Hooks catch bad formatting..."); - // Create a badly formatted markdown file - const badMd = ` -# Header`; - await Deno.writeTextFile(join(TEMP_DIR, "bad.md"), badMd); + // Create a badly formatted TypeScript file + const badTs = `const x=1;const y=2;console.log(x,y);`; + await Deno.writeTextFile(join(TEMP_DIR, "bad.ts"), badTs); // Try to commit - should fail - await run(["git", "add", "bad.md"]); + await run(["git", "add", "bad.ts"]); const result = await run( ["git", "commit", "-m", "Test bad formatting"], { expectFailure: true }, ); - if (result.success && result.output.includes("deno-fmt")) { + if (result.success) { results.push({ name: "Hooks catch bad formatting", passed: true, }); - console.log(" ✅ PASS: Hook rejected bad formatting\n"); + console.log(" PASS: Hook rejected bad formatting\n"); } else { results.push({ name: "Hooks catch bad formatting", passed: false, error: "Hook did not catch formatting error", }); - console.log(" ❌ FAIL: Hook did not catch formatting error\n"); + console.log(" FAIL: Hook did not catch formatting error\n"); } // Clean up - await run(["git", "reset", "HEAD", "bad.md"]); - await Deno.remove(join(TEMP_DIR, "bad.md")); + await run(["git", "reset", "HEAD", "bad.ts"]); + await Deno.remove(join(TEMP_DIR, "bad.ts")); } async function testHookCatchesLintErrors() { - console.log("📝 Test: Hooks catch lint errors..."); + console.log("Test: Hooks catch lint errors..."); // Create a file with lint errors const badTs = `const unused_var = 123; @@ -148,19 +194,19 @@ console.log("hello"); { expectFailure: true }, ); - if (result.success && result.output.includes("deno-lint")) { + if (result.success) { results.push({ name: "Hooks catch lint errors", passed: true, }); - console.log(" ✅ PASS: Hook rejected lint errors\n"); + console.log(" PASS: Hook rejected lint errors\n"); } else { results.push({ name: "Hooks catch lint errors", passed: false, error: "Hook did not catch lint error", }); - console.log(" ❌ FAIL: Hook did not catch lint error\n"); + console.log(" FAIL: Hook did not catch lint error\n"); } // Clean up @@ -169,7 +215,7 @@ console.log("hello"); } async function testHookAllowsGoodCommit() { - console.log("📝 Test: Hooks allow good commits..."); + console.log("Test: Hooks allow good commits..."); // Create a properly formatted file const goodTs = `const message = "hello world"; @@ -186,65 +232,20 @@ console.log(message); name: "Hooks allow good commits", passed: true, }); - console.log(" ✅ PASS: Hook allowed good commit\n"); + console.log(" PASS: Hook allowed good commit\n"); } else { results.push({ name: "Hooks allow good commits", passed: false, error: "Hook rejected valid code", }); - console.log(" ❌ FAIL: Hook rejected valid code\n"); - } -} - -async function testHookPathIsCorrect() { - console.log("📝 Test: Hook script references correct path..."); - - // Read the installed pre-commit hook - const hookPath = join(TEMP_DIR, ".git/hooks/pre-commit"); - const hookContent = await Deno.readTextFile(hookPath); - - // Extract the actual path from the hook script - const pathMatch = hookContent.match(/exec deno run -A "(.+)" "pre-commit"/); - const extractedPath = pathMatch?.[1]; - - if (!extractedPath) { - results.push({ - name: "Hook script references correct path", - passed: false, - error: "Could not extract path from hook script", - }); - console.log(" ❌ FAIL: Could not extract path from hook script\n"); - return; - } - - // Validate it's one of the valid formats: - // 1. JSR specifier: jsr:@scope/package@version/path - // 2. Local file path: /absolute/path/to/file (should not be in temp dir) - const isValidJSR = extractedPath.startsWith("jsr:@"); - const isValidLocalPath = extractedPath.startsWith("/") && - extractedPath.includes("/src/run.ts") && - !extractedPath.includes(TEMP_DIR); - - if (isValidJSR || isValidLocalPath) { - results.push({ - name: "Hook script references correct path", - passed: true, - }); - console.log(` ✅ PASS: Hook references valid path: ${extractedPath}\n`); - } else { - results.push({ - name: "Hook script references correct path", - passed: false, - error: `Invalid path format: ${extractedPath}`, - }); - console.log(` ❌ FAIL: Invalid path format: ${extractedPath}\n`); + console.log(" FAIL: Hook rejected valid code\n"); } } function printResults() { console.log("\n" + "=".repeat(60)); - console.log("📊 INTEGRATION TEST RESULTS"); + console.log("INTEGRATION TEST RESULTS"); console.log("=".repeat(60) + "\n"); let passed = 0; @@ -252,10 +253,10 @@ function printResults() { for (const result of results) { if (result.passed) { - console.log(`✅ ${result.name}`); + console.log(`PASS: ${result.name}`); passed++; } else { - console.log(`❌ ${result.name}`); + console.log(`FAIL: ${result.name}`); if (result.error) { console.log(` Error: ${result.error}`); } @@ -270,10 +271,10 @@ function printResults() { // Main execution try { - console.log("🧪 Running integration tests for deno-hooks\n"); + console.log("Running integration tests for deno-hooks\n"); await setup(); - await testHookPathIsCorrect(); + await testHookScriptIsStandalone(); await testHookCatchesBadFormatting(); await testHookCatchesLintErrors(); await testHookAllowsGoodCommit(); @@ -286,7 +287,7 @@ try { Deno.exit(1); } } catch (error) { - console.error("\n❌ Integration test failed:", error); + console.error("\nIntegration test failed:", error); await cleanup(); Deno.exit(1); } diff --git a/spec/1-tasks/01-task-specs.md b/spec/1-tasks/01-task-specs.md new file mode 100644 index 0000000..e0fbe9f --- /dev/null +++ b/spec/1-tasks/01-task-specs.md @@ -0,0 +1,186 @@ +# Spec: Task-Based Git Hooks + +## Overview + +Deno-hooks should be **simple**: it installs git hooks that run deno tasks +defined in the user's `deno.json`. + +## Key Principle + +**The hooks should be self-contained shell scripts that don't reference +deno-hooks at all.** + +## Configuration Format + +### Minimal Config (deno-hooks.yml) + +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint + + pre-push: + - deno task test +``` + +That's it! Each hook is just a list of commands to run. + +### Alternative: deno.json + +Users can also configure hooks in their `deno.json`: + +```json +{ + "tasks": { + "fmt": "deno fmt", + "lint": "deno lint", + "test": "deno test -A" + }, + "deno-hooks": { + "pre-commit": [ + "deno task fmt", + "deno task lint" + ], + "pre-push": [ + "deno task test" + ] + } +} +``` + +## Generated Hook Scripts + +### Example: `.git/hooks/pre-commit` + +```bash +#!/bin/sh +# Generated by deno-hooks - DO NOT EDIT +# To update, run: deno task hooks + +set -e + +deno task fmt +deno task lint + +echo "✓ All hooks passed" +``` + +Key features: + +- **Self-contained**: No reference to deno-hooks +- **Simple**: Just runs the commands in order +- **Fail-fast**: `set -e` stops on first error +- **Clear**: Shows success message + +## Architecture Simplification + +### What We Remove + +- ❌ `src/run.ts` - No longer needed +- ❌ `src/executor.ts` - No longer needed +- ❌ `src/files.ts` - No longer needed (file filtering is git's job) +- ❌ Built-in hooks (`deno-fmt`, `deno-lint`, `deno-test`) +- ❌ File glob patterns +- ❌ `pass_filenames` logic +- ❌ File filtering + +### What We Keep + +- ✅ `src/install.ts` - Installs hooks (simplified) +- ✅ `src/config.ts` - Loads configuration (simplified) +- ✅ `src/mod.ts` - Main exports + +## User Experience + +### Installation + +```bash +# Install deno-hooks +deno run -A jsr:@theswanfactory/deno-hooks +``` + +This: + +1. Reads `deno-hooks.yml` or `deno.json` config +2. Generates self-contained shell scripts in `.git/hooks/` +3. Makes them executable + +### Configuration + +Users define their tasks in `deno.json`: + +```json +{ + "tasks": { + "fmt": "deno fmt --check", + "lint": "deno lint", + "test": "deno test -A", + "test:integration": "deno run -A scripts/integration-test.ts" + } +} +``` + +Then configure which tasks run on which hooks in `deno-hooks.yml`: + +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint + + pre-push: + - deno task test + - deno task test:integration +``` + +### Customization + +Users can run ANY command, not just deno tasks: + +```yaml +hooks: + pre-commit: + - deno task fmt + - deno task lint + - npm run typecheck + - ./scripts/validate.sh +``` + +## Benefits + +1. **Simple**: No complex file filtering or built-in hooks +2. **Transparent**: Generated hooks are readable shell scripts +3. **Portable**: Hooks work without deno-hooks installed +4. **Flexible**: Users can run any command +5. **Debuggable**: Easy to see what's happening +6. **No Magic**: Just runs commands in order + +## Migration Path + +This is a **breaking change** from v0.2.x. We'll need to: + +1. Bump to v1.0.0 (major version) +2. Update documentation +3. Provide migration guide for existing users + +### Old Config (v0.2.x) + +```yaml +hooks: + pre-commit: + - id: deno-fmt + run: deno-fmt + glob: "*.{ts,js}" + pass_filenames: true +``` + +### New Config (v1.0.0) + +```yaml +hooks: + pre-commit: + - deno fmt --check +``` + +Much simpler! diff --git a/src/config.ts b/src/config.ts index a49dad0..f058fac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,32 +4,12 @@ import { parse as parseYaml } from "@std/yaml"; -/** - * Hook configuration - */ -export interface Hook { - /** Unique identifier for the hook */ - id: string; - /** Display name (defaults to id) */ - name?: string; - /** Command to run or built-in hook name */ - run: string; - /** File pattern to match (e.g., "*.ts") */ - glob?: string; - /** Pass matched files as arguments */ - pass_filenames?: boolean; - /** Exclude pattern */ - exclude?: string; - /** Allow parallel execution */ - parallel?: boolean; -} - /** * Complete configuration structure */ export interface Config { hooks: { - [hookName: string]: Hook[]; + [hookName: string]: string[]; }; } @@ -99,38 +79,42 @@ function validateConfig(config: Config): void { throw new Error("Configuration must have a 'hooks' object"); } - for (const [hookName, hooks] of Object.entries(config.hooks)) { - if (!Array.isArray(hooks)) { - throw new Error(`hooks.${hookName} must be an array`); + for (const [hookName, commands] of Object.entries(config.hooks)) { + if (!Array.isArray(commands)) { + throw new Error(`hooks.${hookName} must be an array of commands`); } - for (const hook of hooks) { - if (!hook.id) { - throw new Error(`Hook in ${hookName} missing required 'id' field`); + for (const command of commands) { + if (typeof command !== "string") { + throw new Error( + `Each command in hooks.${hookName} must be a string, got: ${typeof command}`, + ); } - if (!hook.run) { - throw new Error(`Hook '${hook.id}' missing required 'run' field`); + if (command.trim() === "") { + throw new Error( + `Empty command found in hooks.${hookName}`, + ); } } } } /** - * Get hooks for a specific git hook trigger + * Get commands for a specific git hook trigger * * @param config - The loaded configuration * @param hookName - The git hook trigger name (e.g., "pre-commit") - * @returns Array of hooks for this trigger (empty if none configured) + * @returns Array of commands for this trigger (empty if none configured) * * @example * ```ts * import { getHooksForTrigger, loadConfig } from "@theswanfactory/deno-hooks/config"; * * const config = await loadConfig("."); - * const hooks = getHooksForTrigger(config, "pre-commit"); - * console.log(`Found ${hooks.length} pre-commit hooks`); + * const commands = getHooksForTrigger(config, "pre-commit"); + * console.log(`Found ${commands.length} pre-commit commands`); * ``` */ -export function getHooksForTrigger(config: Config, hookName: string): Hook[] { +export function getHooksForTrigger(config: Config, hookName: string): string[] { return config.hooks[hookName] || []; } diff --git a/src/executor.ts b/src/executor.ts deleted file mode 100644 index cccfe59..0000000 --- a/src/executor.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Hook executor - runs hook commands and built-in hooks - */ - -import type { HookContext, HookResult } from "./hook.ts"; - -/** - * Execute a hook - */ -export async function executeHook(ctx: HookContext): Promise { - const { config } = ctx; - - // Check if it's a built-in hook - const builtIn = getBuiltInHook(config.run); - if (builtIn) { - return await builtIn(ctx); - } - - // Execute as shell command - return await executeCommand(ctx); -} - -/** - * Get built-in hook implementation - */ -function getBuiltInHook( - name: string, -): ((ctx: HookContext) => Promise) | null { - const builtIns: Record Promise> = { - "deno-fmt": denoFmt, - "deno-lint": denoLint, - "deno-test": denoTest, - }; - - return builtIns[name] || null; -} - -/** - * Execute a shell command - */ -async function executeCommand(ctx: HookContext): Promise { - const { config, files, rootDir } = ctx; - - // Parse command (simple split on spaces - no shell expansion for security) - const parts = config.run.split(" "); - const cmd = parts[0]; - let args = parts.slice(1); - - // Add files if requested - if (config.pass_filenames && files.length > 0) { - args = [...args, ...files]; - } - - const command = new Deno.Command(cmd, { - args, - cwd: rootDir, - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - return { success: false, message: error.trim() }; - } - - const output = new TextDecoder().decode(stdout); - return { success: true, message: output.trim() || undefined }; -} - -/** - * Built-in: deno fmt - */ -async function denoFmt(ctx: HookContext): Promise { - const { files, rootDir } = ctx; - - if (files.length === 0) { - return { success: true, message: "no files to format" }; - } - - const command = new Deno.Command("deno", { - args: ["fmt", "--check", ...files], - cwd: rootDir, - stdout: "piped", - stderr: "piped", - }); - - const { success, stderr } = await command.output(); - - if (!success) { - // Files need formatting - try to fix them - const fixCommand = new Deno.Command("deno", { - args: ["fmt", ...files], - cwd: rootDir, - stdout: "piped", - stderr: "piped", - }); - - await fixCommand.output(); - - const error = new TextDecoder().decode(stderr); - return { - success: false, - message: - `Formatted ${files.length} file(s). Re-stage and commit.\n${error}`, - files, - }; - } - - return { success: true, message: `${files.length} file(s) formatted` }; -} - -/** - * Built-in: deno lint - */ -async function denoLint(ctx: HookContext): Promise { - const { files, rootDir } = ctx; - - if (files.length === 0) { - return { success: true, message: "no files to lint" }; - } - - const command = new Deno.Command("deno", { - args: ["lint", ...files], - cwd: rootDir, - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - const output = new TextDecoder().decode(stdout); - return { - success: false, - message: error || output, - }; - } - - return { success: true, message: `${files.length} file(s) linted` }; -} - -/** - * Built-in: deno test - */ -async function denoTest(ctx: HookContext): Promise { - const { rootDir } = ctx; - - const command = new Deno.Command("deno", { - args: ["test", "-A"], - cwd: rootDir, - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - const output = new TextDecoder().decode(stdout); - return { - success: false, - message: error || output, - }; - } - - const output = new TextDecoder().decode(stdout); - return { success: true, message: output.trim() || "tests passed" }; -} diff --git a/src/files.ts b/src/files.ts deleted file mode 100644 index 9fa0f51..0000000 --- a/src/files.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * File utilities for getting and filtering files - */ - -import { globToRegExp } from "@std/path/glob-to-regexp"; - -/** - * Get the git repository root directory - */ -export async function getGitRoot(): Promise { - const command = new Deno.Command("git", { - args: ["rev-parse", "--show-toplevel"], - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - throw new Error(`Failed to get git root: ${error}`); - } - - return new TextDecoder().decode(stdout).trim(); -} - -/** - * Get staged files for pre-commit hook - */ -export async function getStagedFiles(): Promise { - const command = new Deno.Command("git", { - args: ["diff", "--cached", "--name-only", "--diff-filter=ACM"], - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - throw new Error(`Failed to get staged files: ${error}`); - } - - const output = new TextDecoder().decode(stdout); - return output.trim().split("\n").filter((f) => f.length > 0); -} - -/** - * Get modified files (staged + unstaged) - */ -export async function getModifiedFiles(): Promise { - const command = new Deno.Command("git", { - args: ["diff", "--name-only", "--diff-filter=ACM"], - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - throw new Error(`Failed to get modified files: ${error}`); - } - - const output = new TextDecoder().decode(stdout); - const unstaged = output.trim().split("\n").filter((f) => f.length > 0); - const staged = await getStagedFiles(); - - return [...new Set([...staged, ...unstaged])]; -} - -/** - * Get all tracked files in repository - */ -export async function getAllFiles(): Promise { - const command = new Deno.Command("git", { - args: ["ls-files"], - stdout: "piped", - stderr: "piped", - }); - - const { success, stdout, stderr } = await command.output(); - - if (!success) { - const error = new TextDecoder().decode(stderr); - throw new Error(`Failed to get all files: ${error}`); - } - - const output = new TextDecoder().decode(stdout); - return output.trim().split("\n").filter((f) => f.length > 0); -} - -/** - * Filter files by glob pattern - */ -export function filterFiles(files: string[], pattern: string): string[] { - // Handle multiple patterns separated by comma (e.g., "*.{ts,js}") - const patterns = expandGlobPattern(pattern); - const regexps = patterns.map((p) => globToRegExp(p)); - - return files.filter((file) => { - return regexps.some((regexp) => regexp.test(file)); - }); -} - -/** - * Expand glob patterns like "*.{ts,js}" into ["*.ts", "*.js"] - */ -function expandGlobPattern(pattern: string): string[] { - // Simple brace expansion for patterns like "*.{ts,js,json}" - const braceMatch = pattern.match(/^(.+)\{([^}]+)\}(.*)$/); - if (braceMatch) { - const [, prefix, options, suffix] = braceMatch; - return options.split(",").map((opt) => `${prefix}${opt.trim()}${suffix}`); - } - return [pattern]; -} - -/** - * Check if file matches any exclude pattern - */ -export function isExcluded(file: string, patterns: string[]): boolean { - if (patterns.length === 0) return false; - - const regexps = patterns.flatMap((pattern) => { - const expanded = expandGlobPattern(pattern); - return expanded.map((p) => globToRegExp(p)); - }); - - return regexps.some((regexp) => regexp.test(file)); -} diff --git a/src/hook.ts b/src/hook.ts deleted file mode 100644 index 7ead42a..0000000 --- a/src/hook.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Hook execution context and result types - */ - -import type { Hook } from "./config.ts"; - -/** - * Context passed to hook implementations - */ -export interface HookContext { - /** Matched files to process */ - files: string[]; - /** Current hook trigger name (e.g., "pre-commit") */ - hookName: string; - /** Hook configuration */ - config: Hook; - /** Git repository root directory */ - rootDir: string; -} - -/** - * Result returned by hook execution - */ -export interface HookResult { - /** Whether the hook passed */ - success: boolean; - /** Optional message to display */ - message?: string; - /** Files that were modified (for auto-fixing hooks) */ - files?: string[]; -} - -/** - * Hook implementation function signature - */ -export type HookFunction = (ctx: HookContext) => Promise; diff --git a/src/install.ts b/src/install.ts index 8afeac3..b443868 100644 --- a/src/install.ts +++ b/src/install.ts @@ -8,12 +8,19 @@ * * @example CLI usage * ```bash - * deno run -A jsr:@theswanfactory/deno-hooks/install + * # Install hooks + * deno run -A jsr:@theswanfactory/deno-hooks + * + * # Install with automatic yes to prompts + * deno run -A jsr:@theswanfactory/deno-hooks --yes + * + * # Install with verbose output + * deno run -A jsr:@theswanfactory/deno-hooks --verbose * ``` * * @example Programmatic usage * ```ts - * import { install } from "@theswanfactory/deno-hooks/install"; + * import { install } from "@theswanfactory/deno-hooks"; * await install(); * ``` * @@ -22,7 +29,36 @@ import { ensureDir } from "@std/fs"; import { loadConfig } from "./config.ts"; -import { getGitRoot } from "./files.ts"; + +/** + * Options for installing hooks + */ +export interface InstallOptions { + /** Skip interactive prompts and use defaults */ + yes?: boolean; + /** Show detailed output during installation */ + verbose?: boolean; +} + +/** + * Get the git repository root directory + */ +async function getGitRoot(): Promise { + const command = new Deno.Command("git", { + args: ["rev-parse", "--show-toplevel"], + stdout: "piped", + stderr: "piped", + }); + + const { success, stdout, stderr } = await command.output(); + + if (!success) { + const error = new TextDecoder().decode(stderr); + throw new Error(`Failed to get git root: ${error}`); + } + + return new TextDecoder().decode(stdout).trim(); +} /** * Install git hooks based on the project configuration @@ -30,9 +66,10 @@ import { getGitRoot } from "./files.ts"; * This function: * 1. Validates that configuration exists (deno-hooks.yml or deno.json) * 2. Creates .git/hooks/ directory if needed - * 3. Generates shell wrapper scripts for each configured hook + * 3. Generates self-contained shell scripts for each configured hook * 4. Makes scripts executable (Unix/Linux/macOS) * + * @param options - Installation options * @throws {Error} If not in a git repository * @throws {Error} If no configuration found * @throws {Error} If configuration is invalid @@ -44,27 +81,51 @@ import { getGitRoot } from "./files.ts"; * await install(); * ``` */ -export async function install(): Promise { - console.log("🦕 Installing Deno Hooks...\n"); +export async function install(options: InstallOptions = {}): Promise { + const { yes = false, verbose = false } = options; + + if (verbose) { + console.log("Installing Deno Hooks with verbose output...\n"); + } else { + console.log("Installing Deno Hooks...\n"); + } // Get git root const gitRoot = await getGitRoot(); - console.log(`📁 Git root: ${gitRoot}`); + console.log(`Git root: ${gitRoot}`); + if (verbose) { + console.log(`Current directory: ${Deno.cwd()}`); + } // Check if config exists, offer to create default let config; try { config = await loadConfig(gitRoot); + if (verbose) { + console.log("Configuration loaded successfully"); + } } catch (error) { - if (error instanceof Error && error.message.includes("not found")) { - const shouldCreate = promptCreateDefaultConfig(); + if ( + error instanceof Error && error.message.includes("No configuration found") + ) { + // Determine whether to create default config + let shouldCreate = false; + if (yes) { + shouldCreate = true; + } else { + shouldCreate = promptCreateDefaultConfig(); + } + if (shouldCreate) { + if (verbose) { + console.log("Creating default configuration file..."); + } await createDefaultConfig(gitRoot); config = await loadConfig(gitRoot); - console.log("\n✅ Created deno-hooks.yml with default configuration"); + console.log("\nCreated deno-hooks.yml with default configuration"); } else { throw new Error( - "No configuration found. Create deno-hooks.yml or add configuration to deno.json", + "No configuration found. Create deno-hooks.yml or add deno.json config", ); } } else { @@ -75,29 +136,36 @@ export async function install(): Promise { const hookNames = Object.keys(config.hooks); // Show what hooks will be installed - console.log(`\n📋 Installing ${hookNames.length} hook(s):\n`); + console.log(`\nInstalling ${hookNames.length} hook(s):\n`); for (const hookName of hookNames) { - const hooks = config.hooks[hookName]; + const commands = config.hooks[hookName]; console.log(`${hookName}:`); - for (const hook of hooks) { - const displayName = hook.name || hook.id; - console.log(` - ${displayName}`); + for (const command of commands) { + console.log(` - ${command}`); } } // Ensure .git/hooks/ exists const hooksDir = `${gitRoot}/.git/hooks`; await ensureDir(hooksDir); + if (verbose) { + console.log(`\nHooks directory: ${hooksDir}`); + } // Install each hook console.log(); for (const hookName of hookNames) { - await installHook(hooksDir, hookName); + await installHook(hooksDir, hookName, config.hooks[hookName], verbose); } - console.log("\n✅ Installation complete!"); + console.log("\nInstallation complete!"); console.log("\nHooks will run automatically on commit/push."); console.log("To customize, edit deno-hooks.yml in your project root."); + if (verbose) { + console.log( + "\nTip: You can test hooks manually by running them directly from .git/hooks/", + ); + } } /** @@ -106,11 +174,21 @@ export async function install(): Promise { async function installHook( hooksDir: string, hookName: string, + commands: string[], + verbose = false, ): Promise { const hookPath = `${hooksDir}/${hookName}`; + if (verbose) { + console.log(` Installing ${hookName}...`); + } + // Generate shell script - const script = generateHookScript(hookName); + const script = generateHookScript(commands); + + if (verbose) { + console.log(` Writing to: ${hookPath}`); + } // Write script await Deno.writeTextFile(hookPath, script); @@ -118,33 +196,29 @@ async function installHook( // Make executable (Unix only - Windows uses git's shell) if (Deno.build.os !== "windows") { await Deno.chmod(hookPath, 0o755); + if (verbose) { + console.log(` Set executable permissions (0755)`); + } } - console.log(` ✓ Installed ${hookName}`); + console.log(` Installed ${hookName}`); } /** - * Generate shell wrapper script for a hook + * Generate self-contained shell script for a hook */ -function generateHookScript(hookName: string): string { - // Use #!/bin/sh for maximum portability - // Detect if we're running from JSR or local filesystem - const isJSR = import.meta.url.startsWith("jsr:"); - - let runScriptPath: string; - if (isJSR) { - // For JSR installations, preserve the full JSR specifier (jsr:@scope/package@version/path) - runScriptPath = new URL("./run.ts", import.meta.url).href; - } else { - // For local filesystem, use pathname to get absolute file path - runScriptPath = new URL("./run.ts", import.meta.url).pathname; - } +function generateHookScript(commands: string[]): string { + const commandLines = commands.map((cmd) => cmd).join("\n"); return `#!/bin/sh # Generated by deno-hooks - DO NOT EDIT -# To update, run: deno task setup +# To update, run: deno task hooks + +set -e -exec deno run -A "${runScriptPath}" "${hookName}" "$@" +${commandLines} + +echo "✓ All hooks passed" `; } @@ -152,12 +226,12 @@ exec deno run -A "${runScriptPath}" "${hookName}" "$@" * Prompt user to create default configuration */ function promptCreateDefaultConfig(): boolean { - console.log("\n⚠️ No configuration file found"); + console.log("\nNo configuration file found"); console.log( "\nWould you like to create a default deno-hooks.yml with basic hooks?", ); - console.log(" - pre-commit: deno fmt, deno lint"); - console.log(" - pre-push: deno test"); + console.log(" - pre-commit: deno task fmt, deno task lint"); + console.log(" - pre-push: deno task test"); const response = prompt("\nCreate default configuration? [Y/n]"); return !response || response.toLowerCase() === "y" || @@ -173,36 +247,75 @@ async function createDefaultConfig(gitRoot: string): Promise { hooks: pre-commit: - # Format code automatically - - id: deno-fmt - run: deno-fmt - glob: "*.{ts,js,json,md}" - pass_filenames: true - - # Catch common errors - - id: deno-lint - run: deno-lint - glob: "*.{ts,js}" - pass_filenames: true + - deno task fmt + - deno task lint pre-push: - # Run tests before pushing - - id: deno-test - run: deno-test - pass_filenames: false + - deno task test `; const configPath = `${gitRoot}/deno-hooks.yml`; await Deno.writeTextFile(configPath, defaultConfig); } +/** + * Parse command line arguments + */ +function parseArgs(args: string[]): InstallOptions { + const options: InstallOptions = {}; + + for (const arg of args) { + if (arg === "--yes" || arg === "-y") { + options.yes = true; + } else if (arg === "--verbose" || arg === "-v") { + options.verbose = true; + } else if (arg === "--help" || arg === "-h") { + printHelp(); + Deno.exit(0); + } + } + + return options; +} + +/** + * Print CLI help message + */ +function printHelp(): void { + console.log(` +Deno Hooks - Git hooks for Deno projects + +USAGE: + deno run -A deno-hooks [OPTIONS] + +OPTIONS: + --yes, -y Skip interactive prompts (use defaults) + --verbose, -v Show detailed output during installation + --help, -h Show this help message + +EXAMPLES: + # Install hooks (interactive) + deno run -A jsr:@theswanfactory/deno-hooks + + # Install with automatic yes + deno run -A jsr:@theswanfactory/deno-hooks --yes + + # Install with verbose output + deno run -A jsr:@theswanfactory/deno-hooks --verbose + +LEARN MORE: + https://jsr.io/@theswanfactory/deno-hooks +`); +} + // Run if called directly if (import.meta.main) { try { - await install(); + const options = parseArgs(Deno.args); + await install(options); } catch (error) { console.error( - "\n❌ Installation failed:", + "\nInstallation failed:", error instanceof Error ? error.message : String(error), ); Deno.exit(1); diff --git a/src/mod.ts b/src/mod.ts index 5d23c03..78d7b21 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,7 +1,7 @@ /** * Deno Hooks - A zero-dependency git hooks framework for Deno * - * This module provides a declarative way to configure and run git hooks + * This module provides a simple way to configure and run git hooks * using pure TypeScript without requiring Python (pre-commit) or Node.js (Husky). * * @example Basic usage @@ -12,28 +12,15 @@ * await install(); * ``` * - * @example Programmatic hook execution - * ```ts - * import { run } from "@theswanfactory/deno-hooks"; - * - * // Run pre-commit hooks - * const exitCode = await run("pre-commit"); - * Deno.exit(exitCode); - * ``` - * * @example Configuration types * ```ts - * import type { Config, Hook } from "@theswanfactory/deno-hooks"; + * import type { Config } from "@theswanfactory/deno-hooks"; * * const config: Config = { * hooks: { * "pre-commit": [ - * { - * id: "deno-fmt", - * run: "deno-fmt", - * glob: "*.{ts,js,json,md}", - * pass_filenames: true, - * }, + * "deno task fmt", + * "deno task lint" * ], * }, * }; @@ -43,9 +30,7 @@ */ export { install } from "./install.ts"; -export { run } from "./run.ts"; -export type { Config, Hook } from "./config.ts"; -export type { HookContext, HookResult } from "./hook.ts"; +export type { Config } from "./config.ts"; // When run directly (e.g., deno run -A jsr:@theswanfactory/deno-hooks) // automatically run the installer @@ -55,7 +40,7 @@ if (import.meta.main) { await install(); } catch (error) { console.error( - "\n❌ Installation failed:", + "\nInstallation failed:", error instanceof Error ? error.message : String(error), ); Deno.exit(1); diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index 9866b7b..0000000 --- a/src/run.ts +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env -S deno run -A - -/** - * Git hooks execution entrypoint - * - * This module runs configured git hooks for a specific trigger. - * Typically called by git hook wrapper scripts, but can also be used programmatically. - * - * @example CLI usage - * ```bash - * deno run -A jsr:@theswanfactory/deno-hooks/run pre-commit - * ``` - * - * @example Programmatic usage - * ```ts - * import { run } from "@theswanfactory/deno-hooks/run"; - * const exitCode = await run("pre-commit"); - * ``` - * - * @module - */ - -import { getHooksForTrigger, loadConfig } from "./config.ts"; -import { - filterFiles, - getGitRoot, - getStagedFiles, - isExcluded, -} from "./files.ts"; -import { executeHook } from "./executor.ts"; -import type { HookResult } from "./hook.ts"; - -/** - * Run hooks for a specific git trigger - * - * This function: - * 1. Loads the project configuration - * 2. Gets hooks configured for the specified trigger - * 3. For pre-commit, filters to staged files only - * 4. Executes each hook sequentially - * 5. Reports results and returns exit code - * - * @param hookName - The git hook trigger name (e.g., "pre-commit", "pre-push") - * @returns Exit code (0 = success, 1 = failure) - * - * @throws {Error} If not in a git repository - * @throws {Error} If configuration is invalid - * - * @example - * ```ts - * import { run } from "@theswanfactory/deno-hooks"; - * - * const exitCode = await run("pre-commit"); - * if (exitCode !== 0) { - * Deno.exit(exitCode); - * } - * ``` - */ -export async function run(hookName: string): Promise { - try { - // Get git root - const gitRoot = await getGitRoot(); - - // Load configuration - const config = await loadConfig(gitRoot); - - // Get hooks for this trigger - const hooks = getHooksForTrigger(config, hookName); - - if (hooks.length === 0) { - console.log(`No hooks configured for ${hookName}`); - return 0; - } - - console.log(`\n🔍 Running ${hookName} hooks...\n`); - - // Get relevant files based on hook type - let files: string[]; - if (hookName === "pre-commit") { - files = await getStagedFiles(); - if (files.length === 0) { - console.log("No staged files to check"); - return 0; - } - } else { - // For other hooks (pre-push, etc.), we don't filter by files - files = []; - } - - // Execute hooks sequentially (parallel execution in phase 2) - const results: Array<{ id: string; result: HookResult }> = []; - - for (const hook of hooks) { - // Filter files by glob pattern if specified - let matchedFiles = files; - if (hook.glob && files.length > 0) { - matchedFiles = filterFiles(files, hook.glob); - - // Apply exclude pattern if specified - if (hook.exclude) { - matchedFiles = matchedFiles.filter((f) => - !isExcluded(f, [hook.exclude!]) - ); - } - - // Skip if no files match - if (matchedFiles.length === 0 && hook.pass_filenames) { - console.log(` ⊘ ${hook.name || hook.id} (no matching files)`); - continue; - } - } - - // Execute hook - const result = await executeHook({ - files: matchedFiles, - hookName, - config: hook, - rootDir: gitRoot, - }); - - results.push({ id: hook.name || hook.id, result }); - - // Display result - if (result.success) { - const msg = result.message ? ` (${result.message})` : ""; - console.log(` ✓ ${hook.name || hook.id}${msg}`); - } else { - const msg = result.message ? `\n ${result.message}` : ""; - console.error(` ✗ ${hook.name || hook.id}${msg}`); - } - } - - // Summary - console.log(); - const failed = results.filter((r) => !r.result.success); - - if (failed.length === 0) { - console.log("All hooks passed! ✨"); - return 0; - } else { - console.error(`Hooks failed! ❌\n`); - console.error(`${failed.length} hook(s) failed:`); - for (const { id } of failed) { - console.error(` - ${id}`); - } - console.error("\nFix the issues above and try again."); - return 1; - } - } catch (error) { - console.error( - "\n❌ Error:", - error instanceof Error ? error.message : String(error), - ); - return 1; - } -} - -// Run if called directly -if (import.meta.main) { - const hookName = Deno.args[0]; - - if (!hookName) { - console.error("Usage: deno run -A run.ts "); - Deno.exit(1); - } - - const exitCode = await run(hookName); - Deno.exit(exitCode); -} diff --git a/src/test-hook.test.ts b/src/test-hook.test.ts index 8e70f38..4c51755 100644 --- a/src/test-hook.test.ts +++ b/src/test-hook.test.ts @@ -4,7 +4,6 @@ import { expect } from "@std/expect"; import { loadConfig } from "./config.ts"; -import { filterFiles, getStagedFiles } from "./files.ts"; Deno.test("loadConfig - parses YAML configuration", async () => { // Use parent directory where deno-hooks.yml is located @@ -13,24 +12,18 @@ Deno.test("loadConfig - parses YAML configuration", async () => { expect(config).toBeDefined(); expect(config.hooks).toBeDefined(); expect(config.hooks["pre-commit"]).toBeDefined(); + expect(Array.isArray(config.hooks["pre-commit"])).toBe(true); }); -Deno.test("filterFiles - matches glob patterns", () => { - const files = [ - "foo.ts", - "bar.js", - "baz.json", - "test.md", - ]; - - const tsFiles = filterFiles(files, "*.ts"); - expect(tsFiles).toEqual(["foo.ts"]); - - const codeFiles = filterFiles(files, "*.{ts,js}"); - expect(codeFiles.sort()).toEqual(["bar.js", "foo.ts"]); -}); +Deno.test("loadConfig - validates commands are strings", async () => { + const rootDir = new URL("..", import.meta.url).pathname; + const config = await loadConfig(rootDir); -Deno.test("getStagedFiles - returns array", async () => { - const files = await getStagedFiles(); - expect(Array.isArray(files)).toBe(true); + for (const commands of Object.values(config.hooks)) { + expect(Array.isArray(commands)).toBe(true); + for (const command of commands) { + expect(typeof command).toBe("string"); + expect(command.trim().length).toBeGreaterThan(0); + } + } });