Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>
> = {
"reference-transaction": referenceTransaction,
"pre-push": prePush,
};
9 changes: 6 additions & 3 deletions src/hooks/install.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
65 changes: 65 additions & 0 deletions src/hooks/pre-push.test.ts
Original file line number Diff line number Diff line change
@@ -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'",
);
});
});
23 changes: 23 additions & 0 deletions src/hooks/pre-push.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
4 changes: 1 addition & 3 deletions src/hooks/reference-transaction.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
19 changes: 13 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -48,11 +48,18 @@ program
await remove(branch);
});

const hook = program.command("hook", { hidden: true });
hook
.command("reference-transaction")
.argument("<state>")
.action((state: string) => referenceTransaction(state));
program
.command("hook", { hidden: true })
.argument("<name>")
.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",
Expand Down
Loading