From 6d4d8878f94bf0c0d39df4302426a392537c3f9e Mon Sep 17 00:00:00 2001 From: "Sindri P. Ingimundarson" Date: Thu, 12 Mar 2026 23:04:54 +0100 Subject: [PATCH] feat: protect push --- src/git.ts | 1 + src/hooks/index.ts | 12 +++++- src/hooks/install.ts | 9 +++-- src/hooks/pre-push.test.ts | 65 ++++++++++++++++++++++++++++++ src/hooks/pre-push.ts | 23 +++++++++++ src/hooks/reference-transaction.ts | 4 +- src/index.ts | 19 ++++++--- 7 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 src/hooks/pre-push.test.ts create mode 100644 src/hooks/pre-push.ts diff --git a/src/git.ts b/src/git.ts index 5f7b6e4..e13b5ba 100644 --- a/src/git.ts +++ b/src/git.ts @@ -3,6 +3,7 @@ import { $ } from "bun"; export const BARE_DIR = ".bare"; export const GITDIR_POINTER = `gitdir: ./${BARE_DIR}\n`; +export const NULL_SHA = "0000000000000000000000000000000000000000"; export const PROTECT_CONFIG_KEY = "witty.protect"; export class GitConfig { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 077ddcf..f25250e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,12 @@ +import { prePush } from "./pre-push"; +import { referenceTransaction } from "./reference-transaction"; + export { installHook } from "./install"; -export { referenceTransaction } from "./reference-transaction"; + +export const hooks: Record< + string, + (...args: string[]) => void | Promise +> = { + "reference-transaction": referenceTransaction, + "pre-push": prePush, +}; diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 86a95bd..ec9740f 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -1,12 +1,15 @@ import { chmod } from "node:fs/promises"; import { join } from "node:path"; +import { hooks } from "./index"; const TRAMPOLINE = `#!/usr/bin/env sh exec git witty hook "$(basename "$0")" "$@" `; export async function installHook(bareDir: string) { - const hookPath = join(bareDir, "hooks", "reference-transaction"); - await Bun.write(hookPath, TRAMPOLINE); - await chmod(hookPath, 0o755); + for (const hook of Object.keys(hooks)) { + const hookPath = join(bareDir, "hooks", hook); + await Bun.write(hookPath, TRAMPOLINE); + await chmod(hookPath, 0o755); + } } diff --git a/src/hooks/pre-push.test.ts b/src/hooks/pre-push.test.ts new file mode 100644 index 0000000..1d85f21 --- /dev/null +++ b/src/hooks/pre-push.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { PROTECT_CONFIG_KEY } from "../git"; +import { createRepo, useTestDir } from "../test-helpers"; + +describe("pre-push hook", () => { + const ctx = useTestDir(); + + async function setup() { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + + const worktreeDir = join(ctx.dir, target, branch); + const bareDir = join(ctx.dir, target, ".bare"); + const sh = ctx.at(worktreeDir); + + // Create a bare remote we can push to + const remote = join(ctx.dir, "remote.git"); + await ctx.sh`git clone --bare ${origin} ${remote}`; + await sh`git remote add upstream ${remote}`; + + return { sh, worktreeDir, bareDir, branch }; + } + + test("allows push to unprotected branch", async () => { + const { sh } = await setup(); + + await sh`git commit --allow-empty -m "test"`; + const result = await sh`git push upstream main`.nothrow(); + + expect(result.exitCode).toBe(0); + }); + + test("blocks push to protected branch", async () => { + const { sh, bareDir } = await setup(); + await sh`git config --file ${bareDir}/config --add ${PROTECT_CONFIG_KEY} main`; + + await sh`git commit --allow-empty -m "test"`; + const result = await sh`git push upstream main`.nothrow(); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain( + "push to protected branch 'main'", + ); + }); + + test("blocks force push to protected branch", async () => { + const { sh, bareDir } = await setup(); + await sh`git config --file ${bareDir}/config --add ${PROTECT_CONFIG_KEY} main`; + + await sh`git commit --allow-empty -m "first"`; + await sh`git push upstream main`.nothrow(); + await sh`git reset --hard HEAD~1`; + await sh`git commit --allow-empty -m "divergent"`; + const result = await sh`git push --force upstream main`.nothrow(); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain( + "push to protected branch 'main'", + ); + }); +}); diff --git a/src/hooks/pre-push.ts b/src/hooks/pre-push.ts new file mode 100644 index 0000000..bebca8e --- /dev/null +++ b/src/hooks/pre-push.ts @@ -0,0 +1,23 @@ +import { Git, PROTECT_CONFIG_KEY } from "../git"; + +export async function prePush(_remote: string, _url: string) { + const git = await new Git().root(); + const protectedBranches = await git.config.getAll(PROTECT_CONFIG_KEY); + if (protectedBranches.length === 0) return; + + const input = await Bun.stdin.text(); + for (const line of input.trim().split("\n")) { + if (!line) continue; + const [localRef] = line.split(" "); + if (!localRef?.startsWith("refs/heads/")) continue; + + const branch = localRef.slice("refs/heads/".length); + if (protectedBranches.includes(branch)) { + console.error( + `error: push to protected branch '${branch}' is blocked by git-witty.`, + ); + console.error("Run 'git witty protect' to manage protected branches."); + process.exit(1); + } + } +} diff --git a/src/hooks/reference-transaction.ts b/src/hooks/reference-transaction.ts index 491e06c..432c625 100644 --- a/src/hooks/reference-transaction.ts +++ b/src/hooks/reference-transaction.ts @@ -1,6 +1,4 @@ -import { Git, PROTECT_CONFIG_KEY } from "../git"; - -const NULL_SHA = "0000000000000000000000000000000000000000"; +import { Git, NULL_SHA, PROTECT_CONFIG_KEY } from "../git"; export async function referenceTransaction(state: string) { if (state !== "prepared") return; diff --git a/src/index.ts b/src/index.ts index ff2a727..9d2b318 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { add, clone, protect, remove } from "./commands"; import { Git } from "./git"; -import { referenceTransaction } from "./hooks"; +import { hooks } from "./hooks"; const program = new Command() .name("git witty") @@ -48,11 +48,18 @@ program await remove(branch); }); -const hook = program.command("hook", { hidden: true }); -hook - .command("reference-transaction") - .argument("") - .action((state: string) => referenceTransaction(state)); +program + .command("hook", { hidden: true }) + .argument("") + .allowExcessArguments() + .action(async (name: string, _options, command) => { + const handler = hooks[name]; + if (!handler) { + console.error(`Unknown hook: ${name}`); + process.exit(1); + } + await handler(...(command.args.slice(1) as string[])); + }); const passthroughCommands = [ "list",