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 `