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

No fields configured for Task.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions