Skip to content

Git hook that verifies locally before pushing #2149

@kay-is

Description

@kay-is

I let an agent built a pre-push hook according the parameters @novusnota lined out in #353.

Don't know how well it's suited to replace anything in CI since it doesn't include any error reporting to a centralized API yet. That's why I created this issue to track and share my work.

I will test it over the next week to see if it prevents me sufficiently from forgetting to check everything before pushing 🥲

Like always, feedback welcome! 🫡


It currently consists of two files and requires to run npm run prepare manually to initialize Git hooks with Husky, as prepare scripts are currently disabled.

.husky/pre-push

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Run lint checks on changed files
# Usage: node scripts/lint-changed.mjs [baseRef]
# - baseRef: git reference to compare against (default: origin/main)

node scripts/lint-changed.mjs origin/main

scripts/lint-changed.mjs

import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";

/**
 * @typedef {{ok: true} | {ok: false; error: string; exitCode: number}} LintResult
 */

const MD_FILE_REGEX = /\.(md|mdx)$/;

/**
 * Get changed files for linting
 * Uses git diff to find files that differ from the default branch
 * @param {string} base - The base branch/ref to compare against
 * @returns {string[]} Array of file paths
 */
function getChangedFiles(base = "origin/main") {
  try {
    const output = execSync(`git diff --name-only "${base}"...HEAD`, {
      encoding: "utf-8",
    }).trim();

    if (output) {
      return output.split("\n").filter(Boolean);
    }
  } catch (error) {
    throw new Error(
      `Failed to get changed files from git using ${base}...HEAD: ${error.message}`,
    );
  }

  return [];
}

/**
 * Filter files to only include markdown/mdx files
 * @param {string[]} files - Array of file paths
 * @param {boolean} checkExists - Whether to verify files exist
 * @returns {string[]}
 */
function filterMarkdownFiles(files, checkExists = false) {
  return files.filter(
    (file) =>
      file && MD_FILE_REGEX.test(file) && (!checkExists || existsSync(file)),
  );
}

/**
 * Run a verification check on files
 * @param {string} checkName - Display name of the check
 * @param {string[]} npmScriptArgs - npm script name and any script arguments
 * @param {string[] | null} files - Array of file paths, or null for no file args
 * @param {NodeJS.ProcessEnv} envOverrides - Environment overrides for the command
 * @returns {LintResult}
 */
function verifyCheck(
  checkName,
  npmScriptArgs,
  files = null,
  envOverrides = {},
) {
  if (Array.isArray(files) && files.length === 0) {
    console.log(`✓ No files to check (${checkName})`);
    return { ok: true };
  }

  try {
    const npmArgs = ["run", ...npmScriptArgs];

    if (Array.isArray(files)) {
      console.log(`Checking ${checkName} for ${files.length} file(s)...`);
      npmArgs.push("--", ...files);
    } else {
      console.log(`Checking ${checkName}...`);
    }

    execFileSync("npm", npmArgs, {
      stdio: "inherit",
      env: { ...process.env, ...envOverrides },
    });

    console.log(`✓ ${checkName} check passed`);
    return { ok: true };
  } catch (error) {
    return {
      ok: false,
      error: `${checkName} check failed`,
      exitCode: error.status || 1,
    };
  }
}

/**
 * Run all lint checks in sequence
 * @param {string[]} changedFiles - Array of file paths
 * @returns {void}
 */
function runChecks(changedFiles) {
  const markdownFiles = filterMarkdownFiles(changedFiles);

  const checks = [
    {
      name: "formatting",
      args: ["check:fmt:some"],
      files: markdownFiles,
    },
    {
      name: "spelling",
      args: ["spell:some"],
      files: changedFiles,
    },
    {
      name: "broken links",
      args: ["check:links"],
    },
    {
      name: "navigation",
      args: ["check:navigation"],
    },
    {
      name: "redirects",
      args: ["check:redirects"],
      envOverrides: { ALLOW_NET: "true" },
    },
  ];

  for (const check of checks) {
    const result = verifyCheck(
      check.name,
      check.args,
      check.files ?? null,
      check.envOverrides ?? {},
    );
    if (!result.ok) {
      console.error(`\n✗ ${result.error}`);
      process.exit(result.exitCode);
    }
  }

  console.log("\n✓ All lint checks passed!");
  process.exit(0);
}

/**
 * Main linting function
 * @param {string[]} args - Command line arguments
 * @returns {void}
 */
function main(args) {
  const baseRef = args[0] || "origin/main";

  console.log(`Running lint checks (base: ${baseRef})...\n`);

  let changedFiles;
  try {
    changedFiles = getChangedFiles(baseRef);
  } catch (error) {
    console.error(error.message);
    process.exit(1);
  }

  if (changedFiles.length === 0) {
    console.log("No files changed. Skipping lint checks.");
    process.exit(0);
  }

  console.log(`Found ${changedFiles.length} changed file(s):\n`);
  changedFiles.forEach((file) => console.log(`  - ${file}`));

  runChecks(changedFiles);
}

main(process.argv.slice(2));

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions