review

review patchsets using your default editor
Log | Files | Refs

hook.odin (6837B)


      1 /*
      2 Package hook is what makes the review a gate: a commit-msg hook, which
      3 gates every agent that commits through git, and the stanza an agent's own
      4 harness takes. The text an agent needs in its instructions is printed
      5 from the binary too, so the tool explains itself wherever it is.
      6 */
      7 package hook
      8 
      9 import "core:fmt"
     10 import "core:os"
     11 import "core:path/filepath"
     12 import "core:strings"
     13 
     14 import "../git"
     15 
     16 // run is the hook subcommand: install, or print.
     17 run :: proc(args: []string) -> (out: string, err: string) {
     18 	force := false
     19 	verb := ""
     20 	for arg in args {
     21 		switch arg {
     22 		case "-force", "--force":
     23 			force = true
     24 		case:
     25 			verb = arg
     26 		}
     27 	}
     28 	switch verb {
     29 	case "install":
     30 		return install(force)
     31 	case "print", "":
     32 		return text(), ""
     33 	}
     34 	return "", fmt.aprintf("hook takes install or print, not %q", verb)
     35 }
     36 
     37 // install writes the commit-msg hook into the repository, naming this
     38 // binary by its absolute path so the hook works wherever the shell's path
     39 // does not reach. A hook already there is not overwritten unasked: it is
     40 // someone's, and it may do more than this.
     41 install :: proc(force: bool) -> (out: string, err: string) {
     42 	cwd, cwd_err := os.get_working_directory(context.temp_allocator)
     43 	if cwd_err != nil {
     44 		return "", "no working directory"
     45 	}
     46 	root, in_repo := git.toplevel(cwd, context.temp_allocator)
     47 	if !in_repo {
     48 		return "", "not in a git repository"
     49 	}
     50 	// With core.hooksPath set, git reads hooks from one directory for
     51 	// every repository and ignores .git/hooks. Writing there would change
     52 	// every repository on the machine, and writing to .git/hooks would
     53 	// change nothing; neither is this tool's to do unasked.
     54 	if shared, set := git.run(root, {"config", "--get", "core.hooksPath"}, context.temp_allocator);
     55 	   set && strings.trim_space(shared) != "" {
     56 		where_at := strings.trim_space(shared)
     57 		return commit_msg_hook(
     58 			
     59 		), fmt.aprintf("core.hooksPath is %s, so git reads hooks there and not from .git/hooks; add the exec line above to %s/commit-msg yourself", where_at, where_at)
     60 	}
     61 	dir, found := git.run(root, {"rev-parse", "--git-path", "hooks"}, context.temp_allocator)
     62 	if !found {
     63 		return "", "git names no hooks directory"
     64 	}
     65 	dir = strings.trim_space(dir)
     66 	if !os.is_absolute_path(dir) {
     67 		dir = filepath.join({root, dir}, context.temp_allocator) or_else dir
     68 	}
     69 	path := filepath.join({dir, "commit-msg"}, context.temp_allocator) or_else ""
     70 	if os.exists(path) && !force {
     71 		return "", fmt.aprintf("%s exists; read it, then pass -force to replace it", path)
     72 	}
     73 	if !os.is_dir(dir) && os.make_directory_all(dir) != nil {
     74 		return "", fmt.aprintf("cannot make %s", dir)
     75 	}
     76 	if os.write_entire_file(
     77 		   path,
     78 		   transmute([]byte)commit_msg_hook(),
     79 		   os.Permissions_Read_All + {.Write_User} + os.Permissions_Execute_All,
     80 	   ) !=
     81 	   nil {
     82 		return "", fmt.aprintf("cannot write %s", path)
     83 	}
     84 	return fmt.aprintf("wrote %s\n\n%s", path, agent_stanza()), ""
     85 }
     86 
     87 // self is the absolute path of the running binary, or its bare name where
     88 // that cannot be known.
     89 self :: proc(allocator := context.allocator) -> string {
     90 	exe, err := os.get_executable_path(allocator)
     91 	if err != nil {
     92 		return "review"
     93 	}
     94 	return exe
     95 }
     96 
     97 // commit_msg_hook is the hook git runs with the proposed message's file:
     98 // the staged change is reviewed with that message, and a must-fix finding
     99 // refuses the commit.
    100 commit_msg_hook :: proc(allocator := context.allocator) -> string {
    101 	return fmt.aprintf(
    102 		"#!/bin/sh\n# Installed by review. The staged change is reviewed with the message being\n# committed; a must-fix finding that stands refuses the commit. Dismiss a\n# finding where it is wrong, in the source: //review:ignore <rule> <why>\nexec %q --message-file \"$1\" --exit-code\n",
    103 		self(context.temp_allocator),
    104 		allocator = allocator,
    105 	)
    106 }
    107 
    108 // text is everything hook print shows: the git hook and the harness
    109 // stanzas.
    110 text :: proc(allocator := context.allocator) -> string {
    111 	return strings.concatenate(
    112 		{
    113 			"# .git/hooks/commit-msg — or run: review hook install\n",
    114 			commit_msg_hook(context.temp_allocator),
    115 			"\n",
    116 			agent_stanza(context.temp_allocator),
    117 		},
    118 		allocator,
    119 	)
    120 }
    121 
    122 // agent_stanza is the hook an agent's harness takes, for the harnesses
    123 // whose shape is known. Claude Code runs a PreToolUse hook before each
    124 // tool call; the one below reviews the staged change when the call is a
    125 // git commit, and a non-zero exit stops the call with the review's output
    126 // as the reason.
    127 agent_stanza :: proc(allocator := context.allocator) -> string {
    128 	return strings.concatenate(
    129 		{
    130 			"# Claude Code: .claude/settings.json (or ~/.claude/settings.json)\n",
    131 			"# Reviews the staged change before any \"git commit\" the agent runs.\n",
    132 			"{\n  \"hooks\": {\n    \"PreToolUse\": [{\n      \"matcher\": \"Bash\",\n      \"hooks\": [{\n        \"type\": \"command\",\n",
    133 			"        \"command\": \"if grep -q 'git commit' ; then ",
    134 			self(context.temp_allocator),
    135 			" --exit-code; fi\"\n",
    136 			"      }]\n    }]\n  }\n}\n\n",
    137 			"# Any other agent: the commit-msg hook above gates every commit it makes\n",
    138 			"# through git, whatever harness it runs in. Put the output of\n#   review agent\n",
    139 			"# in its instructions so it knows what the refusal means and how to answer it.\n",
    140 		},
    141 		allocator,
    142 	)
    143 }
    144 
    145 // agent_text is what an agent's instructions should say about this tool:
    146 // how to run it, how to read it, how to answer it, and when it may stop.
    147 agent_text ::
    148 	`## Code review
    149 
    150 Before committing, run ` +
    151 	"`review --json`" +
    152 	` on the staged change and act on the report.
    153 
    154 - ` +
    155 	"`status`" +
    156 	` is ` +
    157 	"`complete`" +
    158 	` when every reader read everything. An empty findings list
    159   under any other status is a hole, not a pass.
    160 - Every finding carries a stable ` +
    161 	"`id`" +
    162 	`, a ` +
    163 	"`rule`" +
    164 	`, a ` +
    165 	"`severity`" +
    166 	`, the ` +
    167 	"`file`" +
    168 	` and
    169   ` +
    170 	"`line`" +
    171 	`, the ` +
    172 	"`snippet`" +
    173 	` at that line, and a ` +
    174 	"`fix`" +
    175 	`: the concrete change to make.
    176   Read the rule with ` +
    177 	"`review rules <rule>`" +
    178 	` when the finding is unclear.
    179 - Fix every ` +
    180 	"`must-fix`" +
    181 	`. Weigh each ` +
    182 	"`consider`" +
    183 	`. A ` +
    184 	"`note`" +
    185 	` needs no action.
    186 - Where a finding is wrong, dismiss it in the source it concerns, on the line
    187   above, with the reason: ` +
    188 	"`//review:ignore <rule> <why>`" +
    189 	`. Never dismiss to pass;
    190   a dismissal added by the change under review is itself a must-fix finding.
    191 - Re-run with ` +
    192 	"`--baseline previous.json`" +
    193 	` to see which ids resolved, persist or
    194   are new. Stop when nothing must-fix persists and the status is complete.
    195 - Never delete a test to pass the review; deleting one is a must-fix finding.
    196 - The commit message is measured too: ` +
    197 	"`package: explainer`" +
    198 	`, the explainer in
    199   the imperative, a body saying why for any change over fifty lines, under 150
    200   words, naming only code that exists.
    201 `