29768e2c
Stamp Patch: trailers from a commit-msg hook
a73x 2026-08-10 11:14
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -61,6 +61,23 @@ message along. Or record it by hand: | |||
| 61 | $ git-collab patch merge a1b2c3d4 | 61 | $ git-collab patch merge a1b2c3d4 |
| 62 | ``` | 62 | ``` |
| 63 | 63 | ||
| 64 | `git-collab init` installs a `commit-msg` hook that writes the trailer for you, | ||
| 65 | on commits made while you are on a branch that exactly one open patch was | ||
| 66 | created from. It is a convenience only — `.git/hooks` is not cloned, so it helps | ||
| 67 | the machine it was installed on and only for commits made afterwards, which is | ||
| 68 | why scanning at sync time stays the mechanism that actually records merges. | ||
| 69 | |||
| 70 | The hook never fails a commit and never touches a message it is unsure about: | ||
| 71 | no matching patch, more than one, a detached HEAD, or no `git-collab` on `PATH` | ||
| 72 | all leave the message exactly as you wrote it. If a `commit-msg` hook of your | ||
| 73 | own is already there, `init` will not touch it and tells you the one line to add | ||
| 74 | if you want the trailer: | ||
| 75 | |||
| 76 | ```console | ||
| 77 | $ git-collab hooks status # is it installed, what would it stamp | ||
| 78 | $ git-collab hooks install # install it on its own | ||
| 79 | ``` | ||
| 80 | |||
| 64 | Either way, a patch created with `--fixes` closes the issue it fixes at the same | 81 | Either way, a patch created with `--fixes` closes the issue it fixes at the same |
| 65 | moment. `git-collab issue reopen` then stays reopened: the fix landing and | 82 | moment. `git-collab issue reopen` then stays reopened: the fix landing and |
| 66 | turning out to be wrong is ordinary, and nothing re-closes an issue over a | 83 | turning out to be wrong is ordinary, and nothing re-closes an issue over a |
| @@ -84,7 +101,7 @@ $ make install # installs git-collab and git-collab-server, plus m | |||
| 84 | ```console | 101 | ```console |
| 85 | $ cd your-repo | 102 | $ cd your-repo |
| 86 | $ git-collab init-key # generate an Ed25519 signing key | 103 | $ git-collab init-key # generate an Ed25519 signing key |
| 87 | $ git-collab init # add collab refspecs to your remotes | 104 | $ git-collab init # collab refspecs on your remotes, plus the commit-msg hook |
| 88 | 105 | ||
| 89 | $ git-collab issue open -t "Parser drops trailing newline" | 106 | $ git-collab issue open -t "Parser drops trailing newline" |
| 90 | $ git-collab issue list | 107 | $ git-collab issue list |
| @@ -120,6 +137,7 @@ than type. | |||
| 120 | | `issue` | open, list, show, comment, edit, label, assign, close | | 137 | | `issue` | open, list, show, comment, edit, label, assign, close | |
| 121 | | `patch` | create, list, show, diff, comment, review, revise, log, checkout, merge, close | | 138 | | `patch` | create, list, show, diff, comment, review, revise, log, checkout, merge, close | |
| 122 | | `sync` | fetch, reconcile and push collab refs | | 139 | | `sync` | fetch, reconcile and push collab refs | |
| 140 | | `hooks` | install and inspect the `commit-msg` trailer hook | | ||
| 123 | | `status` | project overview | | 141 | | `status` | project overview | |
| 124 | | `dashboard` | interactive TUI | | 142 | | `dashboard` | interactive TUI | |
| 125 | | `search` | full-text across issues and patches | | 143 | | `search` | full-text across issues and patches | |
| @@ -132,7 +150,9 @@ Every command takes `--help`, and `man git-collab` covers the same ground. | |||
| 132 | 150 | ||
| 133 | ## Sync | 151 | ## Sync |
| 134 | 152 | ||
| 135 | `git-collab init` adds collab refspecs to every remote of the repo. `git-collab | 153 | `git-collab init` adds collab refspecs to every remote of the repo (and installs |
| 154 | the `commit-msg` hook). It is idempotent: running it again configures nothing | ||
| 155 | twice and reports what was already in place. `git-collab | ||
| 136 | sync` (no arguments) syncs all of them — fetch, reconcile, push — and only | 156 | sync` (no arguments) syncs all of them — fetch, reconcile, push — and only |
| 137 | reports success once every one of them has actually succeeded. A remote that | 157 | reports success once every one of them has actually succeeded. A remote that |
| 138 | fails does not stop the others: failures are reported together, alongside | 158 | fails does not stop the others: failures are reported together, alongside |
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -103,6 +103,10 @@ pub enum Commands { | |||
| 103 | /// Initialize collab refspecs on all remotes | 103 | /// Initialize collab refspecs on all remotes |
| 104 | Init, | 104 | Init, |
| 105 | 105 | ||
| 106 | /// Manage the commit-msg hook that stamps `Patch:` trailers | ||
| 107 | #[command(subcommand)] | ||
| 108 | Hooks(HookCmd), | ||
| 109 | |||
| 106 | /// Manage issues | 110 | /// Manage issues |
| 107 | #[command(subcommand)] | 111 | #[command(subcommand)] |
| 108 | Issue(IssueCmd), | 112 | Issue(IssueCmd), |
| @@ -179,6 +183,28 @@ pub enum Commands { | |||
| 179 | } | 183 | } |
| 180 | 184 | ||
| 181 | #[derive(Subcommand)] | 185 | #[derive(Subcommand)] |
| 186 | pub enum HookCmd { | ||
| 187 | /// Install the commit-msg hook (also done by `git-collab init`) | ||
| 188 | Install, | ||
| 189 | |||
| 190 | /// Report whether the hook is installed and what it would stamp | ||
| 191 | Status, | ||
| 192 | |||
| 193 | /// Stamp a commit message file. Called by the hook; not for typing. | ||
| 194 | /// | ||
| 195 | /// Hidden because it is an internal entry point with a shell contract: | ||
| 196 | /// it takes the path git passes a `commit-msg` hook, edits it in place, | ||
| 197 | /// prints nothing, and never fails. | ||
| 198 | #[command(name = "run-commit-msg", hide = true)] | ||
| 199 | RunCommitMsg { | ||
| 200 | // Fully qualified: `src/cli.rs` is `include!`d by `build.rs`, which has | ||
| 201 | // its own `use std::path::PathBuf`, and a second import there collides. | ||
| 202 | /// Path to the commit message file | ||
| 203 | file: std::path::PathBuf, | ||
| 204 | }, | ||
| 205 | } | ||
| 206 | |||
| 207 | #[derive(Subcommand)] | ||
| 182 | pub enum IssueCmd { | 208 | pub enum IssueCmd { |
| 183 | /// Open a new issue | 209 | /// Open a new issue |
| 184 | Open { | 210 | Open { |
src/hooks.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,655 @@ | |||
| 1 | //! The `commit-msg` hook that stamps `Patch: <id>` trailers — layer 1 of merge | ||
| 2 | //! recording. | ||
| 3 | //! | ||
| 4 | //! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md | ||
| 5 | //! | ||
| 6 | //! [`merge_scan`](crate::merge_scan) records a merge when it finds `Patch: <id>` | ||
| 7 | //! on the patch's base branch. Something has to put it there. This does, for | ||
| 8 | //! commits made after `git-collab init` ran on this machine — which is all a | ||
| 9 | //! hook can ever cover, since `.git/hooks` is not cloned. Sync-time scanning | ||
| 10 | //! stays the mechanism that works for everyone, retroactively; this only | ||
| 11 | //! removes the need to type the trailer. | ||
| 12 | //! | ||
| 13 | //! The installed hook is a four-line shell shim that calls back into | ||
| 14 | //! `git-collab hooks run-commit-msg`, so the part that can be wrong is Rust and | ||
| 15 | //! is tested (`tests/commit_msg_hook_test.rs`). | ||
| 16 | //! | ||
| 17 | //! Three properties, in the order they matter: | ||
| 18 | //! | ||
| 19 | //! **It fails open.** Every path here returns without touching the message | ||
| 20 | //! rather than reporting an error, and the shim discards our exit status on top | ||
| 21 | //! of that. A `commit-msg` hook that can block a commit is a hook that gets | ||
| 22 | //! deleted, and it would be blocking commits to add a convenience. | ||
| 23 | //! | ||
| 24 | //! **It never clobbers an existing hook.** If a `commit-msg` hook is already | ||
| 25 | //! there and we did not write it, installation declines and says so. See | ||
| 26 | //! [`install`] for why declining rather than chaining. | ||
| 27 | //! | ||
| 28 | //! **It is idempotent.** A message already mentioning a `Patch:` trailer is | ||
| 29 | //! left exactly as it is, which is what makes `git commit --amend` — the hook | ||
| 30 | //! re-running over its own output — a no-op. | ||
| 31 | |||
| 32 | use std::path::{Path, PathBuf}; | ||
| 33 | |||
| 34 | use git2::Repository; | ||
| 35 | |||
| 36 | use crate::error::Error; | ||
| 37 | use crate::state::{self, PatchState, PatchStatus}; | ||
| 38 | use crate::trailer; | ||
| 39 | |||
| 40 | /// Marks a hook file as ours. Installation only ever overwrites a file | ||
| 41 | /// containing this, so a hook we did not write cannot be lost. | ||
| 42 | const MARKER: &str = "git-collab-managed-hook"; | ||
| 43 | |||
| 44 | /// The subcommand the shim calls. Also what [`install`] looks for inside a | ||
| 45 | /// foreign hook to tell "someone wired us in by hand" apart from "someone | ||
| 46 | /// else's hook entirely". | ||
| 47 | const SHIM_SUBCOMMAND: &str = "hooks run-commit-msg"; | ||
| 48 | |||
| 49 | /// The trailer key written into messages. Lowercase [`trailer::PATCH_TOKEN`] is | ||
| 50 | /// what reads it back; the match is case-insensitive. | ||
| 51 | const TRAILER_KEY: &str = "Patch"; | ||
| 52 | |||
| 53 | /// Git's default comment character, used when `core.commentChar` is unset or | ||
| 54 | /// is something we cannot resolve to a single character (`auto`). | ||
| 55 | const DEFAULT_COMMENT_CHAR: char = '#'; | ||
| 56 | |||
| 57 | // --------------------------------------------------------------------------- | ||
| 58 | // The script | ||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | |||
| 61 | /// The shim installed as `commit-msg`. | ||
| 62 | /// | ||
| 63 | /// `exe` is baked in as an absolute path because a hook runs in whatever | ||
| 64 | /// environment invoked git — a GUI client, an editor, a cron job — and those | ||
| 65 | /// routinely have a `PATH` that never included `~/.local/bin`. The bare name is | ||
| 66 | /// kept as a fallback for the case the binary moved, and if neither resolves | ||
| 67 | /// the `|| true` makes that a silent no-op rather than a failed commit. | ||
| 68 | pub fn hook_script(exe: &Path) -> String { | ||
| 69 | format!( | ||
| 70 | "#!/bin/sh\n\ | ||
| 71 | # {marker}: stamps `{key}: <id>` trailers onto commit messages.\n\ | ||
| 72 | #\n\ | ||
| 73 | # Written by `git-collab init`, which rewrites this file in place —\n\ | ||
| 74 | # edits here are not preserved. Safe to delete: it is a convenience,\n\ | ||
| 75 | # and merges are recorded by scanning at sync time regardless.\n\ | ||
| 76 | #\n\ | ||
| 77 | # This must never fail a commit, so the exit status below is discarded\n\ | ||
| 78 | # deliberately — do not \"fix\" it.\n\ | ||
| 79 | GIT_COLLAB='{exe}'\n\ | ||
| 80 | [ -x \"$GIT_COLLAB\" ] || GIT_COLLAB=git-collab\n\ | ||
| 81 | \"$GIT_COLLAB\" {shim} \"$1\" >/dev/null 2>&1 || true\n\ | ||
| 82 | exit 0\n", | ||
| 83 | marker = MARKER, | ||
| 84 | key = TRAILER_KEY, | ||
| 85 | exe = exe.display().to_string().replace('\'', "'\\''"), | ||
| 86 | shim = SHIM_SUBCOMMAND, | ||
| 87 | ) | ||
| 88 | } | ||
| 89 | |||
| 90 | /// The line someone with their own `commit-msg` hook adds to it by hand. | ||
| 91 | pub fn shim_line() -> String { | ||
| 92 | format!("git-collab {} \"$1\" >/dev/null 2>&1 || true", SHIM_SUBCOMMAND) | ||
| 93 | } | ||
| 94 | |||
| 95 | // --------------------------------------------------------------------------- | ||
| 96 | // Installation | ||
| 97 | // --------------------------------------------------------------------------- | ||
| 98 | |||
| 99 | /// Where git looks for hooks in this repo. | ||
| 100 | /// | ||
| 101 | /// `core.hooksPath` has to be honoured: when it is set, git runs hooks from | ||
| 102 | /// there and nowhere else, so installing into `.git/hooks` anyway would leave a | ||
| 103 | /// hook that never runs and produces no symptom but silence — the one failure | ||
| 104 | /// mode this feature cannot afford, since silence is also what it looks like | ||
| 105 | /// when everything is fine. | ||
| 106 | /// | ||
| 107 | /// The common dir, not the gitdir: in a linked worktree those differ, and | ||
| 108 | /// hooks live with the main repository, so installing into the worktree's own | ||
| 109 | /// gitdir would put the hook somewhere git never looks. | ||
| 110 | pub fn hooks_dir(repo: &Repository) -> PathBuf { | ||
| 111 | if let Ok(config) = repo.config() { | ||
| 112 | if let Ok(configured) = config.get_path("core.hooksPath") { | ||
| 113 | if configured.is_absolute() { | ||
| 114 | return configured; | ||
| 115 | } | ||
| 116 | if let Some(workdir) = repo.workdir() { | ||
| 117 | return workdir.join(configured); | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | common_dir(repo).join("hooks") | ||
| 122 | } | ||
| 123 | |||
| 124 | /// The repository's common directory. | ||
| 125 | /// | ||
| 126 | /// git2 0.19 exposes no `commondir`, so this reads the `commondir` file git | ||
| 127 | /// writes into a linked worktree's gitdir — the same file git itself reads. | ||
| 128 | /// Its content is usually relative to the gitdir. A plain repository has no | ||
| 129 | /// such file, and there the gitdir *is* the common dir. | ||
| 130 | fn common_dir(repo: &Repository) -> PathBuf { | ||
| 131 | let git_dir = repo.path(); | ||
| 132 | let Ok(contents) = std::fs::read_to_string(git_dir.join("commondir")) else { | ||
| 133 | return git_dir.to_path_buf(); | ||
| 134 | }; | ||
| 135 | let relative = Path::new(contents.trim()); | ||
| 136 | if relative.as_os_str().is_empty() { | ||
| 137 | return git_dir.to_path_buf(); | ||
| 138 | } | ||
| 139 | if relative.is_absolute() { | ||
| 140 | relative.to_path_buf() | ||
| 141 | } else { | ||
| 142 | git_dir.join(relative) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | /// The path of the `commit-msg` hook for this repo. | ||
| 147 | pub fn hook_path(repo: &Repository) -> PathBuf { | ||
| 148 | hooks_dir(repo).join("commit-msg") | ||
| 149 | } | ||
| 150 | |||
| 151 | /// What [`install`] did, or declined to do. | ||
| 152 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 153 | pub enum InstallOutcome { | ||
| 154 | Installed(PathBuf), | ||
| 155 | /// Ours already, byte-for-byte. Rewriting it would be equivalent; not | ||
| 156 | /// rewriting it is what makes repeated `init` provably inert. | ||
| 157 | AlreadyInstalled(PathBuf), | ||
| 158 | /// Someone else's hook that already calls us. Reported rather than | ||
| 159 | /// "corrected", so that following the advice in [`InstallOutcome::Foreign`] | ||
| 160 | /// does not produce a nag on every subsequent `init`. | ||
| 161 | ForeignHookCallsUs(PathBuf), | ||
| 162 | /// Someone else's hook. Nothing was written. | ||
| 163 | Foreign(PathBuf), | ||
| 164 | } | ||
| 165 | |||
| 166 | /// Install the `commit-msg` hook, unless a hook we did not write is there. | ||
| 167 | /// | ||
| 168 | /// **Declines rather than chains,** and the choice is not close. Chaining means | ||
| 169 | /// moving the user's file to a name of our invention and calling it from ours: | ||
| 170 | /// a destructive filesystem change made by a command whose whole job is to edit | ||
| 171 | /// config. It breaks hooks that inspect `$0`, it is invisible in `git config`, | ||
| 172 | /// and it loses outright against `husky`/`pre-commit`/`lefthook`, all of which | ||
| 173 | /// own `commit-msg` and regenerate it — silently reverting our chain, or | ||
| 174 | /// double-invoking the user's hook after we renamed something they still | ||
| 175 | /// reference. Against that, the cost of declining is one line the user pastes | ||
| 176 | /// if they want it, and *nothing else stops working*: sync-time scanning still | ||
| 177 | /// records every merge, on every machine, retroactively. The hook is a | ||
| 178 | /// convenience. Convenience is not worth a chance of eating someone's hook. | ||
| 179 | pub fn install(repo: &Repository) -> Result<InstallOutcome, Error> { | ||
| 180 | let path = hook_path(repo); | ||
| 181 | let script = hook_script(¤t_exe()); | ||
| 182 | |||
| 183 | if path.exists() { | ||
| 184 | let existing = std::fs::read_to_string(&path).unwrap_or_default(); | ||
| 185 | if existing.contains(MARKER) { | ||
| 186 | if existing == script { | ||
| 187 | return Ok(InstallOutcome::AlreadyInstalled(path)); | ||
| 188 | } | ||
| 189 | // Ours, but from another install (a different binary path, or an | ||
| 190 | // older version of this script). Refreshing it is safe precisely | ||
| 191 | // because the marker says nobody else's work is in there. | ||
| 192 | write_executable(&path, &script)?; | ||
| 193 | return Ok(InstallOutcome::Installed(path)); | ||
| 194 | } | ||
| 195 | if existing.contains(SHIM_SUBCOMMAND) { | ||
| 196 | return Ok(InstallOutcome::ForeignHookCallsUs(path)); | ||
| 197 | } | ||
| 198 | return Ok(InstallOutcome::Foreign(path)); | ||
| 199 | } | ||
| 200 | |||
| 201 | if let Some(parent) = path.parent() { | ||
| 202 | std::fs::create_dir_all(parent)?; | ||
| 203 | } | ||
| 204 | write_executable(&path, &script)?; | ||
| 205 | Ok(InstallOutcome::Installed(path)) | ||
| 206 | } | ||
| 207 | |||
| 208 | fn write_executable(path: &Path, contents: &str) -> Result<(), Error> { | ||
| 209 | std::fs::write(path, contents)?; | ||
| 210 | #[cfg(unix)] | ||
| 211 | { | ||
| 212 | use std::os::unix::fs::PermissionsExt; | ||
| 213 | let mut perms = std::fs::metadata(path)?.permissions(); | ||
| 214 | perms.set_mode(0o755); | ||
| 215 | std::fs::set_permissions(path, perms)?; | ||
| 216 | } | ||
| 217 | Ok(()) | ||
| 218 | } | ||
| 219 | |||
| 220 | /// The absolute path of the running binary, falling back to the bare name if | ||
| 221 | /// the OS will not say (which the shim then resolves through `PATH`). | ||
| 222 | fn current_exe() -> PathBuf { | ||
| 223 | std::env::current_exe().unwrap_or_else(|_| PathBuf::from("git-collab")) | ||
| 224 | } | ||
| 225 | |||
| 226 | /// Print what [`install`] did, in `init`'s reporting style. | ||
| 227 | pub fn report_install(outcome: &InstallOutcome) { | ||
| 228 | match outcome { | ||
| 229 | InstallOutcome::Installed(path) => { | ||
| 230 | println!("Installed commit-msg hook ({})", path.display()); | ||
| 231 | } | ||
| 232 | InstallOutcome::AlreadyInstalled(path) => { | ||
| 233 | println!("commit-msg hook already installed ({})", path.display()); | ||
| 234 | } | ||
| 235 | InstallOutcome::ForeignHookCallsUs(path) => { | ||
| 236 | println!( | ||
| 237 | "commit-msg hook already invokes git-collab ({})", | ||
| 238 | path.display() | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | InstallOutcome::Foreign(path) => { | ||
| 242 | println!( | ||
| 243 | "commit-msg hook not installed: {} already exists and was not written by git-collab.", | ||
| 244 | path.display() | ||
| 245 | ); | ||
| 246 | println!(" Nothing was changed. To stamp Patch: trailers from your own hook, add:"); | ||
| 247 | println!(" {}", shim_line()); | ||
| 248 | } | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | // --------------------------------------------------------------------------- | ||
| 253 | // Which patch a commit on HEAD belongs to | ||
| 254 | // --------------------------------------------------------------------------- | ||
| 255 | |||
| 256 | /// What the hook would do for HEAD as it stands. | ||
| 257 | #[derive(Debug, Clone)] | ||
| 258 | pub enum Target { | ||
| 259 | /// Exactly one open patch records this branch: the only case that stamps. | ||
| 260 | One { branch: String, patch: Box<PatchState> }, | ||
| 261 | /// No open patch records this branch. | ||
| 262 | NoPatch { branch: String }, | ||
| 263 | /// Several do. Ambiguous, so nothing is stamped — see [`target`]. | ||
| 264 | Ambiguous { branch: String, count: usize }, | ||
| 265 | /// HEAD is not on a branch (detached, mid-rebase, mid-bisect). | ||
| 266 | NoBranch, | ||
| 267 | /// The patches could not be read at all. | ||
| 268 | Unreadable(String), | ||
| 269 | } | ||
| 270 | |||
| 271 | /// Which patch, if any, a commit made right now belongs to. | ||
| 272 | /// | ||
| 273 | /// Matching is by branch name, against `PatchState::branch`. That field is | ||
| 274 | /// documented as provenance only — nothing else resolves through it any more, | ||
| 275 | /// because patches are addressed by their own revision refs — and this is the | ||
| 276 | /// one place a branch name is the only thing available: at `commit-msg` time | ||
| 277 | /// the commit does not exist yet, so there is no commit to look up. | ||
| 278 | /// | ||
| 279 | /// The consequence is that the hook inherits the known weakness of | ||
| 280 | /// branch-addressing (see the revision-refs spec, issue `659f0350`): an | ||
| 281 | /// ephemeral or renamed worktree branch does not match, and two worktrees on | ||
| 282 | /// the same generated name look like one branch. Both land in a *silent* case | ||
| 283 | /// below, which is why silence is the right answer for anything but an exact | ||
| 284 | /// single match: guessing would record a merge of a patch that never landed, | ||
| 285 | /// and a `commit-msg` hook has nobody to ask. | ||
| 286 | pub fn target(repo: &Repository) -> Target { | ||
| 287 | let Some(branch) = head_branch(repo) else { | ||
| 288 | return Target::NoBranch; | ||
| 289 | }; | ||
| 290 | let patches = match state::list_patches(repo) { | ||
| 291 | Ok(p) => p, | ||
| 292 | Err(e) => return Target::Unreadable(e.to_string()), | ||
| 293 | }; | ||
| 294 | let mut matched: Vec<PatchState> = patches | ||
| 295 | .into_iter() | ||
| 296 | .filter(|p| p.status == PatchStatus::Open && p.branch == branch) | ||
| 297 | .collect(); | ||
| 298 | match matched.len() { | ||
| 299 | 0 => Target::NoPatch { branch }, | ||
| 300 | 1 => Target::One { | ||
| 301 | branch, | ||
| 302 | patch: Box::new(matched.remove(0)), | ||
| 303 | }, | ||
| 304 | count => Target::Ambiguous { branch, count }, | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | /// The branch HEAD points at, or `None` when HEAD is detached. | ||
| 309 | /// | ||
| 310 | /// Reads the symbolic ref rather than `Repository::head`, which errors on an | ||
| 311 | /// unborn branch — the very first commit in a repo, where erroring would be | ||
| 312 | /// wrong and where a hook must be as quiet as anywhere else. | ||
| 313 | fn head_branch(repo: &Repository) -> Option<String> { | ||
| 314 | let head = repo.find_reference("HEAD").ok()?; | ||
| 315 | let target = head.symbolic_target()?; | ||
| 316 | target | ||
| 317 | .strip_prefix("refs/heads/") | ||
| 318 | .map(|name| name.to_string()) | ||
| 319 | } | ||
| 320 | |||
| 321 | // --------------------------------------------------------------------------- | ||
| 322 | // Stamping | ||
| 323 | // --------------------------------------------------------------------------- | ||
| 324 | |||
| 325 | /// The hook body: stamp the message file in place, or leave it exactly alone. | ||
| 326 | /// | ||
| 327 | /// Returns nothing, on purpose. There is no error here worth a caller's | ||
| 328 | /// attention: every failure means "the message is unchanged", which is a state | ||
| 329 | /// the caller already handles because it is also the common case. | ||
| 330 | pub fn run_commit_msg(repo: &Repository, file: &Path) { | ||
| 331 | let Ok(contents) = std::fs::read_to_string(file) else { | ||
| 332 | return; | ||
| 333 | }; | ||
| 334 | let Target::One { patch, .. } = target(repo) else { | ||
| 335 | return; | ||
| 336 | }; | ||
| 337 | let Some(stamped) = stamp(&contents, comment_char(repo), &patch.id) else { | ||
| 338 | return; | ||
| 339 | }; | ||
| 340 | let _ = replace_atomically(file, &stamped); | ||
| 341 | } | ||
| 342 | |||
| 343 | /// Replace `file` with `contents` by rename, never by truncate-and-write. | ||
| 344 | /// | ||
| 345 | /// git reads this file the moment we exit. A crash or a full disk halfway | ||
| 346 | /// through a truncating write would hand it a message that is neither the | ||
| 347 | /// author's nor ours; a rename either happened or did not. | ||
| 348 | fn replace_atomically(file: &Path, contents: &str) -> std::io::Result<()> { | ||
| 349 | let dir = file.parent().unwrap_or_else(|| Path::new(".")); | ||
| 350 | let tmp = dir.join(format!( | ||
| 351 | ".git-collab-commit-msg.{}", | ||
| 352 | std::process::id() | ||
| 353 | )); | ||
| 354 | std::fs::write(&tmp, contents)?; | ||
| 355 | if let Err(e) = std::fs::rename(&tmp, file) { | ||
| 356 | let _ = std::fs::remove_file(&tmp); | ||
| 357 | return Err(e); | ||
| 358 | } | ||
| 359 | Ok(()) | ||
| 360 | } | ||
| 361 | |||
| 362 | /// `core.commentChar`, or `#`. | ||
| 363 | /// | ||
| 364 | /// `auto` (and any multi-character `core.commentString`) resolves to `#`, which | ||
| 365 | /// is what git itself starts from. Getting this wrong costs a missed stamp at | ||
| 366 | /// worst — the trailer lands in a paragraph that does not parse — never a | ||
| 367 | /// mangled message. | ||
| 368 | fn comment_char(repo: &Repository) -> char { | ||
| 369 | repo.config() | ||
| 370 | .ok() | ||
| 371 | .and_then(|c| c.get_string("core.commentChar").ok()) | ||
| 372 | .and_then(|s| { | ||
| 373 | let mut chars = s.chars(); | ||
| 374 | let first = chars.next()?; | ||
| 375 | chars.next().is_none().then_some(first) | ||
| 376 | }) | ||
| 377 | .unwrap_or(DEFAULT_COMMENT_CHAR) | ||
| 378 | } | ||
| 379 | |||
| 380 | /// Insert `Patch: <id>` into a raw commit-message file, or return `None` to | ||
| 381 | /// leave it untouched. | ||
| 382 | /// | ||
| 383 | /// The input is the file git hands a `commit-msg` hook, which is **not** the | ||
| 384 | /// message git will store: it still carries git's `#` commentary, and below a | ||
| 385 | /// scissors line it may carry a whole diff. Both are stripped afterwards, and | ||
| 386 | /// that stripping is what makes placement subtle — appending at the end of the | ||
| 387 | /// file puts the trailer *after* the commentary, and once the commentary | ||
| 388 | /// vanishes the trailer joins whatever paragraph preceded it. If that paragraph | ||
| 389 | /// is prose, the trailer is unreadable to [`trailer::parse_trailers`], and the | ||
| 390 | /// merge it was supposed to record silently never happens. | ||
| 391 | /// | ||
| 392 | /// So the insertion point is the end of the message *as git will store it*: the | ||
| 393 | /// last line that is neither commentary nor below the scissors. Two shapes: | ||
| 394 | /// | ||
| 395 | /// - the final paragraph is already a trailer block (and is not the subject) — | ||
| 396 | /// join it, because starting a new paragraph would push the trailers that | ||
| 397 | /// were there out of the final paragraph and unlink them; | ||
| 398 | /// - anything else — a blank line, then the trailer. | ||
| 399 | /// | ||
| 400 | /// Returns `None` — writing nothing at all — when: | ||
| 401 | /// | ||
| 402 | /// - the message has no content yet. Stamping an empty message would make it | ||
| 403 | /// non-empty and commit work the author was in the middle of abandoning; | ||
| 404 | /// git aborts on an empty message and that abort has to keep working. | ||
| 405 | /// - a `Patch:` line is already there, anywhere. This is what makes `git commit | ||
| 406 | /// --amend` idempotent, and it also leaves a hand-written short-id trailer | ||
| 407 | /// alone rather than stapling a 40-char one underneath it. | ||
| 408 | /// - the file uses CRLF. Rebuilding it would rewrite every line ending in the | ||
| 409 | /// file, which is a much larger edit than the one asked for. | ||
| 410 | pub fn stamp(contents: &str, comment_char: char, id: &str) -> Option<String> { | ||
| 411 | if contents.contains("\r\n") { | ||
| 412 | return None; | ||
| 413 | } | ||
| 414 | |||
| 415 | let lines: Vec<&str> = contents.lines().collect(); | ||
| 416 | let cut = lines | ||
| 417 | .iter() | ||
| 418 | .position(|line| is_scissors(line, comment_char)) | ||
| 419 | .unwrap_or(lines.len()); | ||
| 420 | let body = &lines[..cut]; | ||
| 421 | |||
| 422 | // The message as git will store it, so that every decision below is made | ||
| 423 | // against what the reader will eventually see. | ||
| 424 | let stored: String = body | ||
| 425 | .iter() | ||
| 426 | .filter(|line| !is_comment(line, comment_char)) | ||
| 427 | .copied() | ||
| 428 | .collect::<Vec<&str>>() | ||
| 429 | .join("\n"); | ||
| 430 | |||
| 431 | if stored.trim().is_empty() { | ||
| 432 | return None; | ||
| 433 | } | ||
| 434 | if trailer::contains_trailer_line(&stored, trailer::PATCH_TOKEN) { | ||
| 435 | return None; | ||
| 436 | } | ||
| 437 | |||
| 438 | let last_content = body | ||
| 439 | .iter() | ||
| 440 | .rposition(|line| !is_comment(line, comment_char) && !line.trim().is_empty())?; | ||
| 441 | let paragraph = trailer::final_paragraph(&stored)?; | ||
| 442 | let join = paragraph.is_trailer_block && !paragraph.starts_the_message; | ||
| 443 | |||
| 444 | let mut out: Vec<String> = lines.iter().map(|line| line.to_string()).collect(); | ||
| 445 | let at = last_content + 1; | ||
| 446 | let new_line = format!("{}: {}", TRAILER_KEY, id); | ||
| 447 | if join { | ||
| 448 | out.insert(at, new_line); | ||
| 449 | } else { | ||
| 450 | out.splice(at..at, [String::new(), new_line]); | ||
| 451 | } | ||
| 452 | |||
| 453 | let mut result = out.join("\n"); | ||
| 454 | // `lines()` drops the final newline; commit message files have one, and a | ||
| 455 | // message file that did not should not grow one. | ||
| 456 | if contents.ends_with('\n') { | ||
| 457 | result.push('\n'); | ||
| 458 | } | ||
| 459 | Some(result) | ||
| 460 | } | ||
| 461 | |||
| 462 | /// A git commentary line: comment character in column one, exactly as git | ||
| 463 | /// tests it. | ||
| 464 | fn is_comment(line: &str, comment_char: char) -> bool { | ||
| 465 | line.starts_with(comment_char) | ||
| 466 | } | ||
| 467 | |||
| 468 | /// The `# ------------------------ >8 ------------------------` line that | ||
| 469 | /// `commit -v` writes, below which git truncates everything. | ||
| 470 | fn is_scissors(line: &str, comment_char: char) -> bool { | ||
| 471 | let Some(rest) = line.strip_prefix(comment_char) else { | ||
| 472 | return false; | ||
| 473 | }; | ||
| 474 | let rest = rest.trim(); | ||
| 475 | rest.contains(">8") | ||
| 476 | && !rest.is_empty() | ||
| 477 | && rest | ||
| 478 | .chars() | ||
| 479 | .all(|c| c == '-' || c == '>' || c == '8' || c == ' ') | ||
| 480 | } | ||
| 481 | |||
| 482 | // --------------------------------------------------------------------------- | ||
| 483 | // Status | ||
| 484 | // --------------------------------------------------------------------------- | ||
| 485 | |||
| 486 | /// Explain what the hook is and what it would do right now. | ||
| 487 | /// | ||
| 488 | /// The hook is silent by design, so when it is not working the only symptom is | ||
| 489 | /// that nothing happens — indistinguishable from nothing needing to happen. | ||
| 490 | /// This is the command that tells those apart, and it is the reason a | ||
| 491 | /// standalone `hooks` command group exists at all. | ||
| 492 | pub fn status(repo: &Repository) -> Result<(), Error> { | ||
| 493 | let path = hook_path(repo); | ||
| 494 | let contents = std::fs::read_to_string(&path).unwrap_or_default(); | ||
| 495 | if !path.exists() { | ||
| 496 | println!( | ||
| 497 | "commit-msg hook: not installed ({} does not exist)", | ||
| 498 | path.display() | ||
| 499 | ); | ||
| 500 | println!("Install it with: git-collab hooks install"); | ||
| 501 | } else if contents.contains(MARKER) { | ||
| 502 | println!("commit-msg hook: installed ({})", path.display()); | ||
| 503 | } else if contents.contains(SHIM_SUBCOMMAND) { | ||
| 504 | println!( | ||
| 505 | "commit-msg hook: another hook that invokes git-collab ({})", | ||
| 506 | path.display() | ||
| 507 | ); | ||
| 508 | } else { | ||
| 509 | println!( | ||
| 510 | "commit-msg hook: not installed — {} is another hook", | ||
| 511 | path.display() | ||
| 512 | ); | ||
| 513 | println!(" To stamp Patch: trailers from it, add:"); | ||
| 514 | println!(" {}", shim_line()); | ||
| 515 | } | ||
| 516 | |||
| 517 | match target(repo) { | ||
| 518 | Target::One { branch, patch } => println!( | ||
| 519 | "HEAD is on '{}': commits would be stamped `Patch: {}` (patch {:.8} {})", | ||
| 520 | branch, patch.id, patch.id, patch.title | ||
| 521 | ), | ||
| 522 | Target::NoPatch { branch } => println!( | ||
| 523 | "HEAD is on '{}': no open patch records this branch, so nothing would be stamped", | ||
| 524 | branch | ||
| 525 | ), | ||
| 526 | Target::Ambiguous { branch, count } => println!( | ||
| 527 | "HEAD is on '{}': {} open patches record this branch, so nothing would be stamped", | ||
| 528 | branch, count | ||
| 529 | ), | ||
| 530 | Target::NoBranch => { | ||
| 531 | println!("HEAD is not on a branch, so nothing would be stamped") | ||
| 532 | } | ||
| 533 | Target::Unreadable(e) => { | ||
| 534 | println!("Patches could not be read ({}), so nothing would be stamped", e) | ||
| 535 | } | ||
| 536 | } | ||
| 537 | Ok(()) | ||
| 538 | } | ||
| 539 | |||
| 540 | #[cfg(test)] | ||
| 541 | mod tests { | ||
| 542 | use super::*; | ||
| 543 | |||
| 544 | /// The round trip in miniature: whatever `stamp` writes, `parse_trailers` | ||
| 545 | /// reads back. The integration tests drive this through real `git commit`, | ||
| 546 | /// which is what actually proves it; these pin the shapes that are awkward | ||
| 547 | /// to reach from there. | ||
| 548 | fn assert_round_trip(input: &str) { | ||
| 549 | let out = stamp(input, '#', "abc123").expect("expected a stamp"); | ||
| 550 | // Approximate git's cleanup: drop commentary and anything below the | ||
| 551 | // scissors line. The integration tests use git's real implementation. | ||
| 552 | let cut = out | ||
| 553 | .lines() | ||
| 554 | .position(|l| is_scissors(l, '#')) | ||
| 555 | .unwrap_or(usize::MAX); | ||
| 556 | let stored: String = out | ||
| 557 | .lines() | ||
| 558 | .take(cut) | ||
| 559 | .filter(|l| !is_comment(l, '#')) | ||
| 560 | .collect::<Vec<_>>() | ||
| 561 | .join("\n"); | ||
| 562 | assert_eq!( | ||
| 563 | trailer::parse_trailers(&stored, trailer::PATCH_TOKEN), | ||
| 564 | vec!["abc123".to_string()], | ||
| 565 | "did not read back from:\n{}", | ||
| 566 | stored | ||
| 567 | ); | ||
| 568 | } | ||
| 569 | |||
| 570 | #[test] | ||
| 571 | fn stamps_a_subject_only_message_in_its_own_paragraph() { | ||
| 572 | let out = stamp("subject\n", '#', "abc123").unwrap(); | ||
| 573 | assert_eq!(out, "subject\n\nPatch: abc123\n"); | ||
| 574 | assert_round_trip("subject\n"); | ||
| 575 | } | ||
| 576 | |||
| 577 | #[test] | ||
| 578 | fn a_subject_that_looks_like_a_trailer_still_gets_a_blank_line() { | ||
| 579 | // Joining here would fold the trailer into the subject: git takes the | ||
| 580 | // whole first paragraph as the subject line. | ||
| 581 | let out = stamp("Fix: thing\n", '#', "abc123").unwrap(); | ||
| 582 | assert_eq!(out, "Fix: thing\n\nPatch: abc123\n"); | ||
| 583 | } | ||
| 584 | |||
| 585 | #[test] | ||
| 586 | fn joins_an_existing_trailer_block() { | ||
| 587 | let out = stamp("subject\n\nIssue: xyz\n", '#', "abc123").unwrap(); | ||
| 588 | assert_eq!(out, "subject\n\nIssue: xyz\nPatch: abc123\n"); | ||
| 589 | assert_round_trip("subject\n\nIssue: xyz\n"); | ||
| 590 | } | ||
| 591 | |||
| 592 | #[test] | ||
| 593 | fn writes_above_the_comment_block_not_below_it() { | ||
| 594 | let input = "subject\n\nbody prose\n\n# commentary\n# more\n"; | ||
| 595 | let out = stamp(input, '#', "abc123").unwrap(); | ||
| 596 | assert_eq!( | ||
| 597 | out, | ||
| 598 | "subject\n\nbody prose\n\nPatch: abc123\n\n# commentary\n# more\n" | ||
| 599 | ); | ||
| 600 | assert_round_trip(input); | ||
| 601 | } | ||
| 602 | |||
| 603 | #[test] | ||
| 604 | fn writes_above_the_scissors_line() { | ||
| 605 | let input = "subject\n\n# ------------------------ >8 ------------------------\ndiff --git a b\n+Patch: notmine\n"; | ||
| 606 | let out = stamp(input, '#', "abc123").unwrap(); | ||
| 607 | assert!( | ||
| 608 | out.find("Patch: abc123").unwrap() < out.find(">8").unwrap(), | ||
| 609 | "trailer landed below the scissors line:\n{}", | ||
| 610 | out | ||
| 611 | ); | ||
| 612 | } | ||
| 613 | |||
| 614 | #[test] | ||
| 615 | fn a_patch_trailer_below_the_scissors_does_not_count_as_present() { | ||
| 616 | // It is part of a diff, not of the message, and git throws it away. | ||
| 617 | let input = "subject\n# ------------------------ >8 ------------------------\n+Patch: notmine\n"; | ||
| 618 | assert!(stamp(input, '#', "abc123").is_some()); | ||
| 619 | } | ||
| 620 | |||
| 621 | #[test] | ||
| 622 | fn leaves_a_message_that_already_has_a_patch_trailer() { | ||
| 623 | assert_eq!(stamp("subject\n\nPatch: abc123\n", '#', "abc123"), None); | ||
| 624 | assert_eq!(stamp("subject\n\nPatch: abc\n", '#', "abc123"), None); | ||
| 625 | // Unparseable, but written by a human all the same. | ||
| 626 | assert_eq!(stamp("subject\n\nPatch: abc oops\n", '#', "abc123"), None); | ||
| 627 | assert_eq!(stamp("subject\n\nPatch: abc\nprose\n", '#', "abc123"), None); | ||
| 628 | } | ||
| 629 | |||
| 630 | #[test] | ||
| 631 | fn leaves_an_empty_message() { | ||
| 632 | assert_eq!(stamp("", '#', "abc123"), None); | ||
| 633 | assert_eq!(stamp("\n\n", '#', "abc123"), None); | ||
| 634 | assert_eq!(stamp("# just commentary\n", '#', "abc123"), None); | ||
| 635 | } | ||
| 636 | |||
| 637 | #[test] | ||
| 638 | fn leaves_a_crlf_message() { | ||
| 639 | assert_eq!(stamp("subject\r\n", '#', "abc123"), None); | ||
| 640 | } | ||
| 641 | |||
| 642 | #[test] | ||
| 643 | fn honours_a_non_default_comment_char() { | ||
| 644 | let out = stamp("subject\n; commentary\n", ';', "abc123").unwrap(); | ||
| 645 | assert_eq!(out, "subject\n\nPatch: abc123\n; commentary\n"); | ||
| 646 | } | ||
| 647 | |||
| 648 | #[test] | ||
| 649 | fn the_script_carries_the_marker_and_exactly_one_shim_line() { | ||
| 650 | let script = hook_script(Path::new("/usr/bin/git-collab")); | ||
| 651 | assert!(script.contains(MARKER)); | ||
| 652 | assert_eq!(script.matches(SHIM_SUBCOMMAND).count(), 1); | ||
| 653 | assert!(script.contains("|| true")); | ||
| 654 | } | ||
| 655 | } | ||
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ pub mod dag; | |||
| 5 | pub mod editor; | 5 | pub mod editor; |
| 6 | pub mod error; | 6 | pub mod error; |
| 7 | pub mod event; | 7 | pub mod event; |
| 8 | pub mod hooks; | ||
| 8 | pub mod identity; | 9 | pub mod identity; |
| 9 | pub mod issue; | 10 | pub mod issue; |
| 10 | pub mod log; | 11 | pub mod log; |
| @@ -21,7 +22,7 @@ pub mod trust; | |||
| 21 | pub mod tui; | 22 | pub mod tui; |
| 22 | 23 | ||
| 23 | use base64::Engine; | 24 | use base64::Engine; |
| 24 | use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd}; | 25 | use cli::{Commands, HookCmd, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd}; |
| 25 | use event::ReviewVerdict; | 26 | use event::ReviewVerdict; |
| 26 | use git2::Repository; | 27 | use git2::Repository; |
| 27 | 28 | ||
| @@ -114,6 +115,20 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 114 | let is_write = cli.command.is_write(); | 115 | let is_write = cli.command.is_write(); |
| 115 | match cli.command { | 116 | match cli.command { |
| 116 | Commands::Init => sync::init(repo), | 117 | Commands::Init => sync::init(repo), |
| 118 | Commands::Hooks(cmd) => match cmd { | ||
| 119 | HookCmd::Install => { | ||
| 120 | let outcome = hooks::install(repo)?; | ||
| 121 | hooks::report_install(&outcome); | ||
| 122 | Ok(()) | ||
| 123 | } | ||
| 124 | HookCmd::Status => hooks::status(repo), | ||
| 125 | // Never returns an error: this runs inside `git commit`, and the | ||
| 126 | // one thing it must not do is give git a reason to abort. | ||
| 127 | HookCmd::RunCommitMsg { file } => { | ||
| 128 | hooks::run_commit_msg(repo, &file); | ||
| 129 | Ok(()) | ||
| 130 | } | ||
| 131 | }, | ||
| 117 | Commands::Issue(cmd) => match cmd { | 132 | Commands::Issue(cmd) => match cmd { |
| 118 | IssueCmd::Open { | 133 | IssueCmd::Open { |
| 119 | title, | 134 | title, |
src/merge_scan.rs
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,7 @@ | |||
| 8 | //! squash merge at all. So a merge is recorded, three ways that degrade into | 8 | //! squash merge at all. So a merge is recorded, three ways that degrade into |
| 9 | //! each other: | 9 | //! each other: |
| 10 | //! | 10 | //! |
| 11 | //! 1. a `commit-msg` hook stamps `Patch: <id>` onto commits (not implemented); | 11 | //! 1. a `commit-msg` hook stamps `Patch: <id>` onto commits — [`crate::hooks`]; |
| 12 | //! 2. `sync` scans the base branch for those trailers — [`scan_and_record_merges`]; | 12 | //! 2. `sync` scans the base branch for those trailers — [`scan_and_record_merges`]; |
| 13 | //! 3. `git-collab patch merge <id>` records it by hand — [`crate::patch::merge`]. | 13 | //! 3. `git-collab patch merge <id>` records it by hand — [`crate::patch::merge`]. |
| 14 | //! | 14 | //! |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -444,7 +444,18 @@ fn has_collab_refspec(repo: &Repository, remote_name: &str) -> Result<bool, Erro | |||
| 444 | /// check a second `init` silently gives the remote a duplicate refspec — and | 444 | /// check a second `init` silently gives the remote a duplicate refspec — and |
| 445 | /// says "Configured remote" as though it had done something new, which is how | 445 | /// says "Configured remote" as though it had done something new, which is how |
| 446 | /// a repo ends up fetching every collab ref twice with nothing to indicate it. | 446 | /// a repo ends up fetching every collab ref twice with nothing to indicate it. |
| 447 | /// Configure collab refspecs on every remote, and install the `commit-msg` | ||
| 448 | /// hook. | ||
| 449 | /// | ||
| 450 | /// Idempotent in both halves: a second run reconfigures nothing and rewrites | ||
| 451 | /// nothing, and says so. | ||
| 447 | pub fn init(repo: &Repository) -> Result<(), Error> { | 452 | pub fn init(repo: &Repository) -> Result<(), Error> { |
| 453 | init_refspecs(repo)?; | ||
| 454 | init_hook(repo); | ||
| 455 | Ok(()) | ||
| 456 | } | ||
| 457 | |||
| 458 | fn init_refspecs(repo: &Repository) -> Result<(), Error> { | ||
| 448 | let remotes = repo.remotes()?; | 459 | let remotes = repo.remotes()?; |
| 449 | if remotes.is_empty() { | 460 | if remotes.is_empty() { |
| 450 | println!("No remotes configured."); | 461 | println!("No remotes configured."); |
| @@ -463,6 +474,16 @@ pub fn init(repo: &Repository) -> Result<(), Error> { | |||
| 463 | Ok(()) | 474 | Ok(()) |
| 464 | } | 475 | } |
| 465 | 476 | ||
| 477 | /// Install the hook, and never let that failure fail `init`. The hook is a | ||
| 478 | /// convenience; the refspecs above are the part that has to have happened when | ||
| 479 | /// `init` returns. | ||
| 480 | fn init_hook(repo: &Repository) { | ||
| 481 | match crate::hooks::install(repo) { | ||
| 482 | Ok(outcome) => crate::hooks::report_install(&outcome), | ||
| 483 | Err(e) => eprintln!("warning: could not install the commit-msg hook: {}", e), | ||
| 484 | } | ||
| 485 | } | ||
| 486 | |||
| 466 | // --------------------------------------------------------------------------- | 487 | // --------------------------------------------------------------------------- |
| 467 | // Multi-remote orchestration | 488 | // Multi-remote orchestration |
| 468 | // --------------------------------------------------------------------------- | 489 | // --------------------------------------------------------------------------- |
src/trailer.rs
| Old | New | ||
|---|---|---|---|
| @@ -36,27 +36,14 @@ pub fn parse_trailers(message: &str, token: &str) -> Vec<String> { | |||
| 36 | // | 36 | // |
| 37 | // Walking from the end: skip trailing blank/whitespace-only lines, | 37 | // Walking from the end: skip trailing blank/whitespace-only lines, |
| 38 | // then collect lines until we hit a blank line. | 38 | // then collect lines until we hit a blank line. |
| 39 | let mut end = lines.len(); | 39 | let Some((start, end)) = final_paragraph_bounds(&lines) else { |
| 40 | while end > 0 && lines[end - 1].trim().is_empty() { | ||
| 41 | end -= 1; | ||
| 42 | } | ||
| 43 | if end == 0 { | ||
| 44 | return Vec::new(); | 40 | return Vec::new(); |
| 45 | } | 41 | }; |
| 46 | let mut start = end; | ||
| 47 | while start > 0 && !lines[start - 1].trim().is_empty() { | ||
| 48 | start -= 1; | ||
| 49 | } | ||
| 50 | let paragraph = &lines[start..end]; | 42 | let paragraph = &lines[start..end]; |
| 51 | 43 | ||
| 52 | // 3. Validate every non-empty line in the paragraph is trailer-shaped. | 44 | // 3. Validate every non-empty line in the paragraph is trailer-shaped. |
| 53 | for line in paragraph { | 45 | if !is_trailer_block(paragraph) { |
| 54 | if line.trim().is_empty() { | 46 | return Vec::new(); |
| 55 | continue; | ||
| 56 | } | ||
| 57 | if !is_trailer_shaped(line) { | ||
| 58 | return Vec::new(); | ||
| 59 | } | ||
| 60 | } | 47 | } |
| 61 | 48 | ||
| 62 | // 4. Extract the values whose key is `token`. | 49 | // 4. Extract the values whose key is `token`. |
| @@ -69,32 +56,109 @@ pub fn parse_trailers(message: &str, token: &str) -> Vec<String> { | |||
| 69 | out | 56 | out |
| 70 | } | 57 | } |
| 71 | 58 | ||
| 72 | /// Returns true if a line looks like a git trailer: `<token>: <value>`, where | 59 | /// The span of the final paragraph of `lines` — the tail run of non-blank |
| 73 | /// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at | 60 | /// lines, ignoring trailing blanks. `None` when there is no content at all. |
| 74 | /// least one non-whitespace character. | 61 | fn final_paragraph_bounds(lines: &[&str]) -> Option<(usize, usize)> { |
| 75 | fn is_trailer_shaped(line: &str) -> bool { | 62 | let mut end = lines.len(); |
| 63 | while end > 0 && lines[end - 1].trim().is_empty() { | ||
| 64 | end -= 1; | ||
| 65 | } | ||
| 66 | if end == 0 { | ||
| 67 | return None; | ||
| 68 | } | ||
| 69 | let mut start = end; | ||
| 70 | while start > 0 && !lines[start - 1].trim().is_empty() { | ||
| 71 | start -= 1; | ||
| 72 | } | ||
| 73 | Some((start, end)) | ||
| 74 | } | ||
| 75 | |||
| 76 | /// Whether every non-empty line of `paragraph` is trailer-shaped — git's rule | ||
| 77 | /// for a paragraph being a trailer block, and the one [`parse_trailers`] | ||
| 78 | /// enforces before reading anything out of it. | ||
| 79 | fn is_trailer_block(paragraph: &[&str]) -> bool { | ||
| 80 | paragraph | ||
| 81 | .iter() | ||
| 82 | .all(|line| line.trim().is_empty() || is_trailer_shaped(line)) | ||
| 83 | } | ||
| 84 | |||
| 85 | /// What the writer of a trailer needs to know about the message it is about to | ||
| 86 | /// append to. See [`final_paragraph`]. | ||
| 87 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 88 | pub struct FinalParagraph { | ||
| 89 | /// The final paragraph is also the first — a message with no blank line in | ||
| 90 | /// it, i.e. a bare subject. Appending to it would fold the trailer into the | ||
| 91 | /// subject line, since git takes the whole first paragraph as the subject. | ||
| 92 | pub starts_the_message: bool, | ||
| 93 | /// Every non-empty line in it is trailer-shaped, so a trailer appended | ||
| 94 | /// directly to it stays readable — *and* so do the trailers already there, | ||
| 95 | /// which starting a new paragraph instead would strand outside the final | ||
| 96 | /// paragraph and silently unlink. | ||
| 97 | pub is_trailer_block: bool, | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Describe the final paragraph of `message`, or `None` if it has no content. | ||
| 101 | /// | ||
| 102 | /// This exists so that whatever *writes* a trailer decides where to put it | ||
| 103 | /// using the same notion of "trailer block" that [`parse_trailers`] uses to | ||
| 104 | /// read it back. Those are two implementations of the same rule when they are | ||
| 105 | /// written separately, and two implementations of one rule is how a writer | ||
| 106 | /// starts emitting trailers the reader cannot see. | ||
| 107 | pub fn final_paragraph(message: &str) -> Option<FinalParagraph> { | ||
| 108 | let lines: Vec<&str> = message.lines().collect(); | ||
| 109 | let (start, end) = final_paragraph_bounds(&lines)?; | ||
| 110 | Some(FinalParagraph { | ||
| 111 | starts_the_message: start == 0, | ||
| 112 | is_trailer_block: is_trailer_block(&lines[start..end]), | ||
| 113 | }) | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Whether *any* line of `message`, in any paragraph, is a `<token>:` trailer | ||
| 117 | /// line. | ||
| 118 | /// | ||
| 119 | /// Deliberately laxer than [`parse_trailers`], and only ever used to decide not | ||
| 120 | /// to write: a `Patch:` line that the parser refuses — because it sits in a | ||
| 121 | /// paragraph with prose, or carries a value with interior whitespace — is still | ||
| 122 | /// a line the author wrote, and stapling a second one underneath it helps | ||
| 123 | /// nobody. "Already mentions it" is the right test for leaving a message alone; | ||
| 124 | /// "already parses" is not. | ||
| 125 | pub fn contains_trailer_line(message: &str, token: &str) -> bool { | ||
| 126 | message | ||
| 127 | .lines() | ||
| 128 | .any(|line| trailer_key(line).is_some_and(|key| key.eq_ignore_ascii_case(token))) | ||
| 129 | } | ||
| 130 | |||
| 131 | /// The key of a trailer-shaped line: `<token>: <value>`, where token starts | ||
| 132 | /// with a letter and consists of `[A-Za-z0-9-]`, and value is at least one | ||
| 133 | /// non-whitespace character. `None` if the line is not trailer-shaped. | ||
| 134 | fn trailer_key(line: &str) -> Option<&str> { | ||
| 76 | let trimmed = line.trim_start(); | 135 | let trimmed = line.trim_start(); |
| 77 | let Some(colon_pos) = trimmed.find(':') else { | 136 | let colon_pos = trimmed.find(':')?; |
| 78 | return false; | ||
| 79 | }; | ||
| 80 | // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE` | 137 | // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE` |
| 81 | // — matching what `match_trailer_line` does. Without this, the space before | 138 | // — matching what `match_trailer_line` does. Without this, the space before |
| 82 | // the colon would disqualify the line and make the whole paragraph fail | 139 | // the colon would disqualify the line and make the whole paragraph fail |
| 83 | // the trailer-shape check. | 140 | // the trailer-shape check. |
| 84 | let token = trimmed[..colon_pos].trim_end(); | 141 | let token = trimmed[..colon_pos].trim_end(); |
| 85 | if token.is_empty() { | 142 | if token.is_empty() { |
| 86 | return false; | 143 | return None; |
| 87 | } | 144 | } |
| 88 | let mut chars = token.chars(); | 145 | let mut chars = token.chars(); |
| 89 | let first = chars.next().unwrap(); | 146 | let first = chars.next().unwrap(); |
| 90 | if !first.is_ascii_alphabetic() { | 147 | if !first.is_ascii_alphabetic() { |
| 91 | return false; | 148 | return None; |
| 92 | } | 149 | } |
| 93 | if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') { | 150 | if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') { |
| 94 | return false; | 151 | return None; |
| 152 | } | ||
| 153 | if trimmed[colon_pos + 1..].trim().is_empty() { | ||
| 154 | return None; | ||
| 95 | } | 155 | } |
| 96 | let value = trimmed[colon_pos + 1..].trim(); | 156 | Some(token) |
| 97 | !value.is_empty() | 157 | } |
| 158 | |||
| 159 | /// Returns true if a line looks like a git trailer. | ||
| 160 | fn is_trailer_shaped(line: &str) -> bool { | ||
| 161 | trailer_key(line).is_some() | ||
| 98 | } | 162 | } |
| 99 | 163 | ||
| 100 | /// If `line` is a `<token>: <value>` trailer with exactly one non-whitespace | 164 | /// If `line` is a `<token>: <value>` trailer with exactly one non-whitespace |
tests/commit_msg_hook_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,692 @@ | |||
| 1 | //! Layer 1 of merge recording: the `commit-msg` hook that stamps `Patch: <id>`. | ||
| 2 | //! | ||
| 3 | //! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md | ||
| 4 | //! | ||
| 5 | //! Two things are being tested here and they are not the same thing: | ||
| 6 | //! | ||
| 7 | //! 1. **The round trip.** Whatever writes the trailer has to produce something | ||
| 8 | //! [`git_collab::trailer::parse_trailers`] reads back. Writer and reader are | ||
| 9 | //! separate implementations of "what is a trailer", and between them sits | ||
| 10 | //! git's own message cleanup — which strips comments, truncates at the | ||
| 11 | //! scissors line and collapses blank runs, and so can move our trailer into | ||
| 12 | //! a paragraph that no longer parses. So every round-trip test below drives | ||
| 13 | //! a *real* `git commit` through the *real* installed hook and reads the | ||
| 14 | //! stored message back out of the object database. A test that called the | ||
| 15 | //! stamping function with a hand-built string would agree with itself and | ||
| 16 | //! prove nothing about what git stores. | ||
| 17 | //! | ||
| 18 | //! 2. **Failing open.** A `commit-msg` hook that can block a commit gets | ||
| 19 | //! deleted by its user the same day. Every failure mode has to leave the | ||
| 20 | //! message byte-identical and let the commit through. | ||
| 21 | |||
| 22 | mod common; | ||
| 23 | |||
| 24 | use std::path::Path; | ||
| 25 | use std::process::{Command, Output}; | ||
| 26 | |||
| 27 | use serde_json::Value; | ||
| 28 | |||
| 29 | use common::TestRepo; | ||
| 30 | use git_collab::trailer::{parse_trailers, ISSUE_TOKEN, PATCH_TOKEN}; | ||
| 31 | |||
| 32 | // --------------------------------------------------------------------------- | ||
| 33 | // Harness | ||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | |||
| 36 | /// A repo with the hook installed, on branch `feature`, with exactly one open | ||
| 37 | /// patch recorded against that branch. Returns the repo and the patch's full | ||
| 38 | /// 40-char id. | ||
| 39 | /// | ||
| 40 | /// The first commit on the branch is made *before* the patch exists — that is | ||
| 41 | /// forced by `patch create`, which needs a commit to point at, and it is why | ||
| 42 | /// the hook can only ever stamp the commits that come after it. | ||
| 43 | fn repo_with_hook_and_patch() -> (TestRepo, String) { | ||
| 44 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 45 | repo.run_ok(&["init"]); | ||
| 46 | repo.git(&["checkout", "-b", "feature"]); | ||
| 47 | repo.commit_file("f.txt", "one", "first commit"); | ||
| 48 | let out = repo.run_ok(&["patch", "create", "-t", "feature work", "-B", "feature"]); | ||
| 49 | let short = out | ||
| 50 | .trim() | ||
| 51 | .strip_prefix("Created patch ") | ||
| 52 | .unwrap_or_else(|| panic!("unexpected patch create output: {}", out)) | ||
| 53 | .to_string(); | ||
| 54 | let id = full_id(&repo, &short); | ||
| 55 | (repo, id) | ||
| 56 | } | ||
| 57 | |||
| 58 | fn full_id(repo: &TestRepo, short: &str) -> String { | ||
| 59 | let json: Value = serde_json::from_str(&repo.run_ok(&["patch", "show", short, "--json"])).unwrap(); | ||
| 60 | json["id"].as_str().unwrap().to_string() | ||
| 61 | } | ||
| 62 | |||
| 63 | /// The path of the installed `commit-msg` hook. | ||
| 64 | fn hook_path(repo: &TestRepo) -> std::path::PathBuf { | ||
| 65 | repo.dir.path().join(".git/hooks/commit-msg") | ||
| 66 | } | ||
| 67 | |||
| 68 | /// Commit `file` with `message` and return the message git actually stored. | ||
| 69 | fn commit_and_read_back(repo: &TestRepo, file: &str, message: &str) -> String { | ||
| 70 | repo.commit_file(file, "content", message); | ||
| 71 | repo.git(&["log", "-1", "--format=%B"]) | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Assert the stored message carries exactly one `Patch:` trailer, that our | ||
| 75 | /// own parser reads it back, and that the value it reads names `id`. | ||
| 76 | /// | ||
| 77 | /// This is the round trip: git wrote the final message, our parser reads it. | ||
| 78 | fn assert_round_trips(stored: &str, id: &str) { | ||
| 79 | let found = parse_trailers(stored, PATCH_TOKEN); | ||
| 80 | assert_eq!( | ||
| 81 | found, | ||
| 82 | vec![id.to_string()], | ||
| 83 | "parse_trailers did not read back the trailer the hook wrote.\n\ | ||
| 84 | stored message was:\n---\n{}\n---", | ||
| 85 | stored | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | fn assert_no_patch_trailer(stored: &str) { | ||
| 90 | assert!( | ||
| 91 | parse_trailers(stored, PATCH_TOKEN).is_empty(), | ||
| 92 | "expected no Patch: trailer, message was:\n---\n{}\n---", | ||
| 93 | stored | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Write an editor script that prepends `message` to whatever git put in | ||
| 98 | /// COMMIT_EDITMSG — i.e. exactly what a human typing into `$EDITOR` produces, | ||
| 99 | /// comment block and scissors line and all. Returns its path. | ||
| 100 | fn editor_writing(repo: &TestRepo, message: &str) -> std::path::PathBuf { | ||
| 101 | let path = repo.dir.path().join(".git").join("test-editor.sh"); | ||
| 102 | std::fs::write( | ||
| 103 | &path, | ||
| 104 | format!( | ||
| 105 | "#!/bin/sh\nprintf '%s' '{}' > \"$1.new\"\ncat \"$1\" >> \"$1.new\"\nmv \"$1.new\" \"$1\"\n", | ||
| 106 | message.replace('\'', "'\\''") | ||
| 107 | ), | ||
| 108 | ) | ||
| 109 | .unwrap(); | ||
| 110 | let mut perms = std::fs::metadata(&path).unwrap().permissions(); | ||
| 111 | std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); | ||
| 112 | std::fs::set_permissions(&path, perms).unwrap(); | ||
| 113 | path | ||
| 114 | } | ||
| 115 | |||
| 116 | fn git_with_editor(repo: &TestRepo, editor: &Path, args: &[&str]) -> Output { | ||
| 117 | let mut command = Command::new("git"); | ||
| 118 | repo.apply_env(&mut command); | ||
| 119 | command | ||
| 120 | .env("GIT_EDITOR", editor) | ||
| 121 | .args(args) | ||
| 122 | .current_dir(repo.dir.path()) | ||
| 123 | .output() | ||
| 124 | .expect("failed to run git") | ||
| 125 | } | ||
| 126 | |||
| 127 | // --------------------------------------------------------------------------- | ||
| 128 | // Installation | ||
| 129 | // --------------------------------------------------------------------------- | ||
| 130 | |||
| 131 | #[test] | ||
| 132 | fn init_installs_the_commit_msg_hook() { | ||
| 133 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 134 | let out = repo.run_ok(&["init"]); | ||
| 135 | |||
| 136 | let path = hook_path(&repo); | ||
| 137 | assert!(path.exists(), "init did not install a hook:\n{}", out); | ||
| 138 | let mode = std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()); | ||
| 139 | assert!(mode & 0o111 != 0, "hook is not executable (mode {:o})", mode); | ||
| 140 | assert!( | ||
| 141 | out.contains("commit-msg hook"), | ||
| 142 | "init did not report the hook:\n{}", | ||
| 143 | out | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn init_installs_the_hook_even_with_no_remotes() { | ||
| 149 | // `init` returns early when there are no remotes. The hook has nothing to | ||
| 150 | // do with remotes, and a fresh local repo is exactly where someone runs | ||
| 151 | // `init` first. | ||
| 152 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 153 | assert!(repo.git(&["remote"]).trim().is_empty()); | ||
| 154 | repo.run_ok(&["init"]); | ||
| 155 | assert!(hook_path(&repo).exists(), "no hook in a repo with no remotes"); | ||
| 156 | } | ||
| 157 | |||
| 158 | #[test] | ||
| 159 | fn init_twice_leaves_exactly_one_hook_and_one_shim_line() { | ||
| 160 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 161 | repo.run_ok(&["init"]); | ||
| 162 | let first = std::fs::read_to_string(hook_path(&repo)).unwrap(); | ||
| 163 | let out = repo.run_ok(&["init"]); | ||
| 164 | let second = std::fs::read_to_string(hook_path(&repo)).unwrap(); | ||
| 165 | |||
| 166 | assert_eq!(first, second, "second init rewrote the hook"); | ||
| 167 | assert_eq!( | ||
| 168 | second.matches("run-commit-msg").count(), | ||
| 169 | 1, | ||
| 170 | "second init appended another shim line:\n{}", | ||
| 171 | second | ||
| 172 | ); | ||
| 173 | assert!( | ||
| 174 | out.contains("already installed"), | ||
| 175 | "second init did not report the hook as already installed:\n{}", | ||
| 176 | out | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | #[test] | ||
| 181 | fn init_refuses_to_touch_an_existing_foreign_hook() { | ||
| 182 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 183 | let path = hook_path(&repo); | ||
| 184 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 185 | let theirs = "#!/bin/sh\n# my own hook\nexit 0\n"; | ||
| 186 | std::fs::write(&path, theirs).unwrap(); | ||
| 187 | |||
| 188 | let out = repo.run_ok(&["init"]); | ||
| 189 | |||
| 190 | assert_eq!( | ||
| 191 | std::fs::read_to_string(&path).unwrap(), | ||
| 192 | theirs, | ||
| 193 | "init modified a hook it did not write" | ||
| 194 | ); | ||
| 195 | assert!( | ||
| 196 | out.contains("not installed") && out.contains("already exists"), | ||
| 197 | "init did not say clearly that it declined to install:\n{}", | ||
| 198 | out | ||
| 199 | ); | ||
| 200 | assert!( | ||
| 201 | out.contains("run-commit-msg"), | ||
| 202 | "init did not offer the line to add by hand:\n{}", | ||
| 203 | out | ||
| 204 | ); | ||
| 205 | } | ||
| 206 | |||
| 207 | #[test] | ||
| 208 | fn init_recognizes_a_foreign_hook_that_already_calls_git_collab() { | ||
| 209 | // Someone who took the refusal message's advice has a hook of their own | ||
| 210 | // that invokes us. Reporting that as "not installed" and re-offering the | ||
| 211 | // line would be wrong, and appending it would be worse. | ||
| 212 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 213 | let path = hook_path(&repo); | ||
| 214 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 215 | let theirs = "#!/bin/sh\ngit-collab hooks run-commit-msg \"$1\" >/dev/null 2>&1 || true\nexit 0\n"; | ||
| 216 | std::fs::write(&path, theirs).unwrap(); | ||
| 217 | |||
| 218 | let out = repo.run_ok(&["init"]); | ||
| 219 | |||
| 220 | assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs); | ||
| 221 | assert!( | ||
| 222 | out.contains("already invokes git-collab"), | ||
| 223 | "init did not recognize its own shim line in a foreign hook:\n{}", | ||
| 224 | out | ||
| 225 | ); | ||
| 226 | } | ||
| 227 | |||
| 228 | #[test] | ||
| 229 | fn hooks_install_honours_core_hooks_path() { | ||
| 230 | // git only runs hooks from core.hooksPath when it is set. Installing into | ||
| 231 | // .git/hooks anyway would leave a hook that never runs, and no symptom. | ||
| 232 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 233 | repo.git(&["config", "core.hooksPath", "my-hooks"]); | ||
| 234 | repo.run_ok(&["hooks", "install"]); | ||
| 235 | assert!( | ||
| 236 | repo.dir.path().join("my-hooks/commit-msg").exists(), | ||
| 237 | "hook was not installed into core.hooksPath" | ||
| 238 | ); | ||
| 239 | assert!( | ||
| 240 | !hook_path(&repo).exists(), | ||
| 241 | "hook was installed into .git/hooks, which git is not reading" | ||
| 242 | ); | ||
| 243 | } | ||
| 244 | |||
| 245 | #[test] | ||
| 246 | fn installing_from_a_linked_worktree_writes_to_the_main_hooks_dir() { | ||
| 247 | // A linked worktree's gitdir is `.git/worktrees/<name>`, and git runs hooks | ||
| 248 | // from the common dir regardless. Installing into the worktree's own gitdir | ||
| 249 | // would leave a hook that never runs — and worktrees are where this project | ||
| 250 | // does most of its work. | ||
| 251 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 252 | let elsewhere = tempfile::TempDir::new().unwrap(); | ||
| 253 | let tree = elsewhere.path().join("wt"); | ||
| 254 | repo.git(&["worktree", "add", tree.to_str().unwrap(), "-b", "side"]); | ||
| 255 | |||
| 256 | let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab")); | ||
| 257 | repo.apply_env(&mut command); | ||
| 258 | let out = command | ||
| 259 | .args(["hooks", "install"]) | ||
| 260 | .current_dir(&tree) | ||
| 261 | .output() | ||
| 262 | .unwrap(); | ||
| 263 | assert!( | ||
| 264 | out.status.success(), | ||
| 265 | "hooks install failed in a worktree: {}", | ||
| 266 | String::from_utf8_lossy(&out.stderr) | ||
| 267 | ); | ||
| 268 | |||
| 269 | assert!( | ||
| 270 | hook_path(&repo).exists(), | ||
| 271 | "hook did not land in the main repo's hooks dir; install said: {}", | ||
| 272 | String::from_utf8_lossy(&out.stdout) | ||
| 273 | ); | ||
| 274 | assert!( | ||
| 275 | !repo | ||
| 276 | .dir | ||
| 277 | .path() | ||
| 278 | .join(".git/worktrees/wt/hooks/commit-msg") | ||
| 279 | .exists(), | ||
| 280 | "hook landed in the worktree gitdir, where git never looks" | ||
| 281 | ); | ||
| 282 | } | ||
| 283 | |||
| 284 | #[test] | ||
| 285 | fn hooks_status_reports_what_the_hook_would_stamp() { | ||
| 286 | // The hook is silent by design, so "nothing happened" is its only symptom | ||
| 287 | // when something is wrong. This is the command that explains it. | ||
| 288 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 289 | let out = repo.run_ok(&["hooks", "status"]); | ||
| 290 | assert!(out.contains("installed"), "status did not report installation:\n{}", out); | ||
| 291 | assert!( | ||
| 292 | out.contains(&id[..8]), | ||
| 293 | "status did not name the patch it would stamp:\n{}", | ||
| 294 | out | ||
| 295 | ); | ||
| 296 | |||
| 297 | repo.git(&["checkout", "main"]); | ||
| 298 | let out = repo.run_ok(&["hooks", "status"]); | ||
| 299 | assert!( | ||
| 300 | out.contains("no open patch"), | ||
| 301 | "status did not explain why nothing would be stamped:\n{}", | ||
| 302 | out | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | |||
| 306 | // --------------------------------------------------------------------------- | ||
| 307 | // The round trip: writer vs reader, over real commit-message shapes | ||
| 308 | // --------------------------------------------------------------------------- | ||
| 309 | |||
| 310 | #[test] | ||
| 311 | fn round_trip_single_line_message() { | ||
| 312 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 313 | let stored = commit_and_read_back(&repo, "a.txt", "just a subject"); | ||
| 314 | assert_round_trips(&stored, &id); | ||
| 315 | assert!( | ||
| 316 | stored.starts_with("just a subject\n\n"), | ||
| 317 | "trailer was glued onto the subject paragraph:\n---\n{}\n---", | ||
| 318 | stored | ||
| 319 | ); | ||
| 320 | } | ||
| 321 | |||
| 322 | #[test] | ||
| 323 | fn round_trip_body_with_blank_lines() { | ||
| 324 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 325 | let stored = commit_and_read_back( | ||
| 326 | &repo, | ||
| 327 | "a.txt", | ||
| 328 | "subject\n\nfirst paragraph\n\nsecond paragraph", | ||
| 329 | ); | ||
| 330 | assert_round_trips(&stored, &id); | ||
| 331 | } | ||
| 332 | |||
| 333 | #[test] | ||
| 334 | fn round_trip_last_paragraph_is_prose() { | ||
| 335 | // The parser refuses a final paragraph containing any prose line, so the | ||
| 336 | // trailer must start a paragraph of its own here. | ||
| 337 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 338 | let stored = commit_and_read_back( | ||
| 339 | &repo, | ||
| 340 | "a.txt", | ||
| 341 | "subject\n\nThanks Bob. This explains the change.", | ||
| 342 | ); | ||
| 343 | assert_round_trips(&stored, &id); | ||
| 344 | } | ||
| 345 | |||
| 346 | #[test] | ||
| 347 | fn round_trip_message_already_ending_in_an_issue_trailer() { | ||
| 348 | // The trailer has to join the existing block, not start a new paragraph: | ||
| 349 | // a new paragraph would make `Issue:` stop being in the final paragraph, | ||
| 350 | // which silently breaks the commit-issue link the author wrote by hand. | ||
| 351 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 352 | let issue = repo.issue_open("something to fix"); | ||
| 353 | let stored = commit_and_read_back( | ||
| 354 | &repo, | ||
| 355 | "a.txt", | ||
| 356 | &format!("subject\n\nIssue: {}", issue), | ||
| 357 | ); | ||
| 358 | assert_round_trips(&stored, &id); | ||
| 359 | assert_eq!( | ||
| 360 | parse_trailers(&stored, ISSUE_TOKEN), | ||
| 361 | vec![issue], | ||
| 362 | "stamping the Patch: trailer broke the Issue: trailer:\n---\n{}\n---", | ||
| 363 | stored | ||
| 364 | ); | ||
| 365 | } | ||
| 366 | |||
| 367 | #[test] | ||
| 368 | fn round_trip_message_with_a_comment_block() { | ||
| 369 | // What `git commit` without -m actually hands the hook: the message, then | ||
| 370 | // a blank line, then git's own `#` commentary. | ||
| 371 | // | ||
| 372 | // The message ends in a trailer here on purpose. Without that, this test | ||
| 373 | // passes even if the trailer is appended *below* the commentary: the | ||
| 374 | // commentary vanishes, the blank line we wrote survives, and the trailer | ||
| 375 | // ends up in a paragraph of its own either way. It is the existing `Issue:` | ||
| 376 | // trailer that makes position observable — appended below the comments, our | ||
| 377 | // trailer is separated from it by the blank line that preceded them, which | ||
| 378 | // pushes `Issue:` out of the final paragraph and unlinks it. | ||
| 379 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 380 | let issue = repo.issue_open("something to fix"); | ||
| 381 | let editor = editor_writing( | ||
| 382 | &repo, | ||
| 383 | &format!("subject\n\nbody prose here\n\nIssue: {}\n", issue), | ||
| 384 | ); | ||
| 385 | std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap(); | ||
| 386 | repo.git(&["add", "a.txt"]); | ||
| 387 | let out = git_with_editor(&repo, &editor, &["commit"]); | ||
| 388 | assert!( | ||
| 389 | out.status.success(), | ||
| 390 | "commit failed: {}", | ||
| 391 | String::from_utf8_lossy(&out.stderr) | ||
| 392 | ); | ||
| 393 | let stored = repo.git(&["log", "-1", "--format=%B"]); | ||
| 394 | assert_round_trips(&stored, &id); | ||
| 395 | assert_eq!( | ||
| 396 | parse_trailers(&stored, ISSUE_TOKEN), | ||
| 397 | vec![issue], | ||
| 398 | "the Issue: trailer did not survive stamping:\n---\n{}\n---", | ||
| 399 | stored | ||
| 400 | ); | ||
| 401 | assert!( | ||
| 402 | !stored.contains('#'), | ||
| 403 | "commentary leaked into the stored message:\n---\n{}\n---", | ||
| 404 | stored | ||
| 405 | ); | ||
| 406 | } | ||
| 407 | |||
| 408 | #[test] | ||
| 409 | fn round_trip_message_with_a_scissors_line_and_a_diff() { | ||
| 410 | // `commit -v` puts a scissors line and a raw diff below the message. The | ||
| 411 | // trailer must land above the scissors, or git truncates it away. | ||
| 412 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 413 | let editor = editor_writing(&repo, "subject\n\nbody prose here\n"); | ||
| 414 | std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap(); | ||
| 415 | repo.git(&["add", "a.txt"]); | ||
| 416 | let out = git_with_editor(&repo, &editor, &["commit", "-v"]); | ||
| 417 | assert!( | ||
| 418 | out.status.success(), | ||
| 419 | "commit failed: {}", | ||
| 420 | String::from_utf8_lossy(&out.stderr) | ||
| 421 | ); | ||
| 422 | let stored = repo.git(&["log", "-1", "--format=%B"]); | ||
| 423 | assert_round_trips(&stored, &id); | ||
| 424 | assert!( | ||
| 425 | !stored.contains("diff --git"), | ||
| 426 | "the diff leaked into the stored message:\n---\n{}\n---", | ||
| 427 | stored | ||
| 428 | ); | ||
| 429 | } | ||
| 430 | |||
| 431 | #[test] | ||
| 432 | fn an_empty_message_still_aborts_the_commit() { | ||
| 433 | // Stamping an otherwise-empty message would make it non-empty, and git | ||
| 434 | // would commit something the author meant to abandon. Nothing else in | ||
| 435 | // this suite can catch that: the abort *is* the assertion. | ||
| 436 | let (repo, _id) = repo_with_hook_and_patch(); | ||
| 437 | let before = repo.git(&["rev-parse", "HEAD"]); | ||
| 438 | let editor = editor_writing(&repo, ""); | ||
| 439 | std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap(); | ||
| 440 | repo.git(&["add", "a.txt"]); | ||
| 441 | let out = git_with_editor(&repo, &editor, &["commit"]); | ||
| 442 | |||
| 443 | assert!( | ||
| 444 | !out.status.success(), | ||
| 445 | "an empty commit message was committed anyway:\n{}", | ||
| 446 | String::from_utf8_lossy(&out.stdout) | ||
| 447 | ); | ||
| 448 | assert_eq!( | ||
| 449 | before, | ||
| 450 | repo.git(&["rev-parse", "HEAD"]), | ||
| 451 | "HEAD moved despite the empty message" | ||
| 452 | ); | ||
| 453 | } | ||
| 454 | |||
| 455 | // --------------------------------------------------------------------------- | ||
| 456 | // Idempotence | ||
| 457 | // --------------------------------------------------------------------------- | ||
| 458 | |||
| 459 | #[test] | ||
| 460 | fn amending_a_stamped_commit_does_not_add_a_second_trailer() { | ||
| 461 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 462 | commit_and_read_back(&repo, "a.txt", "subject"); | ||
| 463 | repo.git(&["commit", "--amend", "--no-edit"]); | ||
| 464 | let stored = repo.git(&["log", "-1", "--format=%B"]); | ||
| 465 | |||
| 466 | assert_round_trips(&stored, &id); | ||
| 467 | assert_eq!( | ||
| 468 | stored.matches("Patch:").count(), | ||
| 469 | 1, | ||
| 470 | "amend added a second trailer:\n---\n{}\n---", | ||
| 471 | stored | ||
| 472 | ); | ||
| 473 | } | ||
| 474 | |||
| 475 | #[test] | ||
| 476 | fn a_hand_written_short_trailer_is_left_alone() { | ||
| 477 | // Someone who wrote `Patch: 2575fe16` by hand must not get a second, | ||
| 478 | // 40-char trailer stapled underneath it. | ||
| 479 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 480 | let stored = commit_and_read_back(&repo, "a.txt", &format!("subject\n\nPatch: {}", &id[..8])); | ||
| 481 | assert_eq!( | ||
| 482 | parse_trailers(&stored, PATCH_TOKEN), | ||
| 483 | vec![id[..8].to_string()], | ||
| 484 | "the hand-written trailer was not left alone:\n---\n{}\n---", | ||
| 485 | stored | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | |||
| 489 | // --------------------------------------------------------------------------- | ||
| 490 | // Which patch gets stamped | ||
| 491 | // --------------------------------------------------------------------------- | ||
| 492 | |||
| 493 | #[test] | ||
| 494 | fn a_branch_with_no_open_patch_is_not_stamped() { | ||
| 495 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 496 | repo.run_ok(&["init"]); | ||
| 497 | repo.git(&["checkout", "-b", "unrelated"]); | ||
| 498 | let stored = commit_and_read_back(&repo, "a.txt", "subject"); | ||
| 499 | assert_no_patch_trailer(&stored); | ||
| 500 | } | ||
| 501 | |||
| 502 | #[test] | ||
| 503 | fn two_open_patches_on_one_branch_stamp_nothing() { | ||
| 504 | // Ambiguous, so silence: guessing would record a merge of a patch that | ||
| 505 | // never landed, and there is no way to ask at commit time. | ||
| 506 | let (repo, _id) = repo_with_hook_and_patch(); | ||
| 507 | repo.commit_file("b.txt", "two", "second commit"); | ||
| 508 | repo.run_ok(&["patch", "create", "-t", "another patch", "-B", "feature"]); | ||
| 509 | let stored = commit_and_read_back(&repo, "c.txt", "subject"); | ||
| 510 | assert_no_patch_trailer(&stored); | ||
| 511 | } | ||
| 512 | |||
| 513 | #[test] | ||
| 514 | fn a_closed_patch_on_the_branch_is_not_stamped() { | ||
| 515 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 516 | repo.patch_close(&id[..8]); | ||
| 517 | let stored = commit_and_read_back(&repo, "a.txt", "subject"); | ||
| 518 | assert_no_patch_trailer(&stored); | ||
| 519 | } | ||
| 520 | |||
| 521 | #[test] | ||
| 522 | fn a_detached_head_is_not_stamped() { | ||
| 523 | let (repo, _id) = repo_with_hook_and_patch(); | ||
| 524 | let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 525 | repo.git(&["checkout", "--detach", &head]); | ||
| 526 | let stored = commit_and_read_back(&repo, "a.txt", "subject"); | ||
| 527 | assert_no_patch_trailer(&stored); | ||
| 528 | } | ||
| 529 | |||
| 530 | // --------------------------------------------------------------------------- | ||
| 531 | // Failing open | ||
| 532 | // --------------------------------------------------------------------------- | ||
| 533 | |||
| 534 | #[test] | ||
| 535 | fn a_commit_succeeds_when_the_git_collab_binary_is_gone() { | ||
| 536 | // Uninstalled, moved, or a PATH that a GUI git client never had. The hook | ||
| 537 | // outlives the binary and must not take the commit down with it. | ||
| 538 | let (repo, _id) = repo_with_hook_and_patch(); | ||
| 539 | let path = hook_path(&repo); | ||
| 540 | let script = std::fs::read_to_string(&path).unwrap(); | ||
| 541 | let broken = script.replace( | ||
| 542 | env!("CARGO_BIN_EXE_git-collab"), | ||
| 543 | "/nonexistent/bin/git-collab", | ||
| 544 | ); | ||
| 545 | assert_ne!(script, broken, "hook does not embed the binary path"); | ||
| 546 | std::fs::write(&path, broken).unwrap(); | ||
| 547 | |||
| 548 | // A PATH with git on it and nothing else — so `git commit` still runs, and | ||
| 549 | // the hook's fallback to a bare `git-collab` finds nothing. | ||
| 550 | let bin = tempfile::TempDir::new().unwrap(); | ||
| 551 | let git = String::from_utf8( | ||
| 552 | Command::new("sh") | ||
| 553 | .args(["-c", "command -v git"]) | ||
| 554 | .output() | ||
| 555 | .unwrap() | ||
| 556 | .stdout, | ||
| 557 | ) | ||
| 558 | .unwrap(); | ||
| 559 | std::os::unix::fs::symlink(git.trim(), bin.path().join("git")).unwrap(); | ||
| 560 | |||
| 561 | std::fs::write(repo.dir.path().join("a.txt"), "content").unwrap(); | ||
| 562 | repo.git(&["add", "a.txt"]); | ||
| 563 | let mut command = Command::new("git"); | ||
| 564 | repo.apply_env(&mut command); | ||
| 565 | let out = command | ||
| 566 | .env("PATH", bin.path()) | ||
| 567 | .args(["commit", "-m", "subject"]) | ||
| 568 | .current_dir(repo.dir.path()) | ||
| 569 | .output() | ||
| 570 | .unwrap(); | ||
| 571 | |||
| 572 | assert!( | ||
| 573 | out.status.success(), | ||
| 574 | "a missing binary blocked the commit:\n{}", | ||
| 575 | String::from_utf8_lossy(&out.stderr) | ||
| 576 | ); | ||
| 577 | assert_eq!( | ||
| 578 | repo.git(&["log", "-1", "--format=%B"]).trim(), | ||
| 579 | "subject", | ||
| 580 | "the message was not left byte-identical" | ||
| 581 | ); | ||
| 582 | } | ||
| 583 | |||
| 584 | #[test] | ||
| 585 | fn a_commit_succeeds_when_the_collab_refs_are_corrupt() { | ||
| 586 | let (repo, id) = repo_with_hook_and_patch(); | ||
| 587 | // Point the patch's event ref at an object that is not there at all. | ||
| 588 | let ref_file = repo | ||
| 589 | .dir | ||
| 590 | .path() | ||
| 591 | .join(".git/refs/collab/patches") | ||
| 592 | .join(&id) | ||
| 593 | .join("events"); | ||
| 594 | std::fs::create_dir_all(ref_file.parent().unwrap()).unwrap(); | ||
| 595 | std::fs::write(&ref_file, "0000000000000000000000000000000000000001\n").unwrap(); | ||
| 596 | |||
| 597 | let stored = commit_and_read_back(&repo, "a.txt", "subject"); | ||
| 598 | assert_eq!(stored.trim(), "subject", "corrupt refs changed the message"); | ||
| 599 | } | ||
| 600 | |||
| 601 | #[test] | ||
| 602 | fn the_hook_exits_zero_outside_a_git_repository() { | ||
| 603 | // git-collab itself exits 1 when it cannot open a repo. The shim has to | ||
| 604 | // swallow that, or a hook copied into a non-repo (or a repo whose gitdir | ||
| 605 | // has gone) blocks every commit. | ||
| 606 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 607 | repo.run_ok(&["init"]); | ||
| 608 | let script = std::fs::read_to_string(hook_path(&repo)).unwrap(); | ||
| 609 | |||
| 610 | let elsewhere = tempfile::TempDir::new().unwrap(); | ||
| 611 | let hook = elsewhere.path().join("commit-msg"); | ||
| 612 | std::fs::write(&hook, &script).unwrap(); | ||
| 613 | let msg = elsewhere.path().join("MSG"); | ||
| 614 | std::fs::write(&msg, "subject\n").unwrap(); | ||
| 615 | |||
| 616 | let out = Command::new("sh") | ||
| 617 | .arg(&hook) | ||
| 618 | .arg(&msg) | ||
| 619 | .current_dir(elsewhere.path()) | ||
| 620 | .env("HOME", elsewhere.path()) | ||
| 621 | .output() | ||
| 622 | .unwrap(); | ||
| 623 | |||
| 624 | assert!( | ||
| 625 | out.status.success(), | ||
| 626 | "hook exited {:?} outside a repo:\n{}", | ||
| 627 | out.status.code(), | ||
| 628 | String::from_utf8_lossy(&out.stderr) | ||
| 629 | ); | ||
| 630 | assert_eq!(std::fs::read_to_string(&msg).unwrap(), "subject\n"); | ||
| 631 | } | ||
| 632 | |||
| 633 | #[test] | ||
| 634 | fn the_hook_exits_zero_when_the_message_file_is_missing() { | ||
| 635 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 636 | repo.run_ok(&["init"]); | ||
| 637 | let out = Command::new("sh") | ||
| 638 | .arg(hook_path(&repo)) | ||
| 639 | .arg(repo.dir.path().join("no-such-file")) | ||
| 640 | .current_dir(repo.dir.path()) | ||
| 641 | .output() | ||
| 642 | .unwrap(); | ||
| 643 | assert!(out.status.success(), "a missing message file failed the hook"); | ||
| 644 | } | ||
| 645 | |||
| 646 | // --------------------------------------------------------------------------- | ||
| 647 | // End to end: the three layers connected | ||
| 648 | // --------------------------------------------------------------------------- | ||
| 649 | |||
| 650 | #[test] | ||
| 651 | fn a_hook_stamped_commit_is_recorded_as_merged_by_sync() { | ||
| 652 | // The whole point of layer 1. No hand-written trailer, no `patch merge`. | ||
| 653 | let bare = tempfile::TempDir::new().unwrap(); | ||
| 654 | let status = Command::new("git") | ||
| 655 | .args(["init", "--bare", "-b", "main"]) | ||
| 656 | .arg(bare.path()) | ||
| 657 | .status() | ||
| 658 | .unwrap(); | ||
| 659 | assert!(status.success()); | ||
| 660 | |||
| 661 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 662 | repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]); | ||
| 663 | repo.git(&["push", "-u", "origin", "main"]); | ||
| 664 | repo.run_ok(&["init"]); | ||
| 665 | |||
| 666 | repo.git(&["checkout", "-b", "feature"]); | ||
| 667 | repo.commit_file("f.txt", "one", "first commit"); | ||
| 668 | let out = repo.run_ok(&["patch", "create", "-t", "feature work", "-B", "feature"]); | ||
| 669 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 670 | |||
| 671 | // A commit made after the patch exists — the one the hook can stamp. | ||
| 672 | repo.commit_file("f.txt", "two", "more work"); | ||
| 673 | repo.run_ok(&["patch", "revise", &short, "-b", "second pass"]); | ||
| 674 | |||
| 675 | let stored = repo.git(&["log", "-1", "--format=%B"]); | ||
| 676 | assert!( | ||
| 677 | !parse_trailers(&stored, PATCH_TOKEN).is_empty(), | ||
| 678 | "the hook did not stamp the commit:\n---\n{}\n---", | ||
| 679 | stored | ||
| 680 | ); | ||
| 681 | |||
| 682 | repo.git(&["checkout", "main"]); | ||
| 683 | repo.git(&["merge", "--no-ff", "-m", "merge feature", "feature"]); | ||
| 684 | repo.run_ok(&["sync"]); | ||
| 685 | |||
| 686 | let json: Value = serde_json::from_str(&repo.run_ok(&["patch", "show", &short, "--json"])).unwrap(); | ||
| 687 | assert_eq!( | ||
| 688 | json["status"].as_str().unwrap().to_lowercase(), | ||
| 689 | "merged", | ||
| 690 | "sync did not record the merge the hook made possible" | ||
| 691 | ); | ||
| 692 | } | ||
tests/merge_recording_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,10 +4,10 @@ | |||
| 4 | //! | 4 | //! |
| 5 | //! Layer 3 (`patch merge`) and layer 2 (sync scanning `Patch:` trailers) are | 5 | //! Layer 3 (`patch merge`) and layer 2 (sync scanning `Patch:` trailers) are |
| 6 | //! covered here, along with the demotion of reachability detection to a hint | 6 | //! covered here, along with the demotion of reachability detection to a hint |
| 7 | //! that never writes. Layer 1 (stamping the trailer — the `commit-msg` hook | 7 | //! that never writes. Every test below writes the trailer by hand, which is |
| 8 | //! and `patch create --stamp`) is deliberately not implemented, so every test | 8 | //! exactly what a user without the hook does — and what everyone does for |
| 9 | //! below writes the trailer by hand, which is exactly what a user without the | 9 | //! commits that predate it, since `patch create --stamp` is still out. |
| 10 | //! hook does. | 10 | //! Layer 1, the `commit-msg` hook, is in `tests/commit_msg_hook_test.rs`. |
| 11 | 11 | ||
| 12 | mod common; | 12 | mod common; |
| 13 | 13 | ||