058018f9
Exclude a worktree-resident hook from git status
a73x 2026-08-19 08:08
Commit message
src/hooks.rs
| Old | New | ||
|---|---|---|---|
| @@ -190,6 +190,7 @@ pub fn install(repo: &Repository) -> Result<InstallOutcome, Error> { | |||
| 190 | // older version of this script). Refreshing it is safe precisely | 190 | // older version of this script). Refreshing it is safe precisely |
| 191 | // because the marker says nobody else's work is in there. | 191 | // because the marker says nobody else's work is in there. |
| 192 | write_executable(&path, &script)?; | 192 | write_executable(&path, &script)?; |
| 193 | exclude_from_status(repo, &path); | ||
| 193 | return Ok(InstallOutcome::Installed(path)); | 194 | return Ok(InstallOutcome::Installed(path)); |
| 194 | } | 195 | } |
| 195 | if existing.contains(SHIM_SUBCOMMAND) { | 196 | if existing.contains(SHIM_SUBCOMMAND) { |
| @@ -202,9 +203,52 @@ pub fn install(repo: &Repository) -> Result<InstallOutcome, Error> { | |||
| 202 | std::fs::create_dir_all(parent)?; | 203 | std::fs::create_dir_all(parent)?; |
| 203 | } | 204 | } |
| 204 | write_executable(&path, &script)?; | 205 | write_executable(&path, &script)?; |
| 206 | exclude_from_status(repo, &path); | ||
| 205 | Ok(InstallOutcome::Installed(path)) | 207 | Ok(InstallOutcome::Installed(path)) |
| 206 | } | 208 | } |
| 207 | 209 | ||
| 210 | /// Keep a worktree-resident hook out of `git status`. | ||
| 211 | /// | ||
| 212 | /// With a relative `core.hooksPath` the shim lands inside the working tree, | ||
| 213 | /// where it would sit as permanently-untracked noise. It is machine-local — | ||
| 214 | /// it embeds this binary's absolute path — so it belongs in | ||
| 215 | /// `.git/info/exclude`, the repo-local ignore that never gets committed, not | ||
| 216 | /// in the shared `.gitignore`. | ||
| 217 | /// | ||
| 218 | /// Best-effort by design: a hook that runs matters more than a clean status, | ||
| 219 | /// so nothing here can fail the install. | ||
| 220 | fn exclude_from_status(repo: &Repository, hook: &Path) { | ||
| 221 | // A hook under the git dir (the default `.git/hooks`) is invisible to | ||
| 222 | // status already; only one in the working tree proper needs excluding. | ||
| 223 | if hook.starts_with(repo.path()) || hook.starts_with(common_dir(repo)) { | ||
| 224 | return; | ||
| 225 | } | ||
| 226 | let Some(workdir) = repo.workdir() else { | ||
| 227 | return; | ||
| 228 | }; | ||
| 229 | let Ok(relative) = hook.strip_prefix(workdir) else { | ||
| 230 | return; | ||
| 231 | }; | ||
| 232 | let Some(pattern) = relative.to_str() else { | ||
| 233 | return; | ||
| 234 | }; | ||
| 235 | |||
| 236 | let exclude = common_dir(repo).join("info").join("exclude"); | ||
| 237 | let mut content = std::fs::read_to_string(&exclude).unwrap_or_default(); | ||
| 238 | if content.lines().any(|line| line == pattern) { | ||
| 239 | return; | ||
| 240 | } | ||
| 241 | if !content.is_empty() && !content.ends_with('\n') { | ||
| 242 | content.push('\n'); | ||
| 243 | } | ||
| 244 | content.push_str(pattern); | ||
| 245 | content.push('\n'); | ||
| 246 | if let Some(parent) = exclude.parent() { | ||
| 247 | let _ = std::fs::create_dir_all(parent); | ||
| 248 | } | ||
| 249 | let _ = std::fs::write(&exclude, content); | ||
| 250 | } | ||
| 251 | |||
| 208 | fn write_executable(path: &Path, contents: &str) -> Result<(), Error> { | 252 | fn write_executable(path: &Path, contents: &str) -> Result<(), Error> { |
| 209 | std::fs::write(path, contents)?; | 253 | std::fs::write(path, contents)?; |
| 210 | #[cfg(unix)] | 254 | #[cfg(unix)] |
tests/commit_msg_hook_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -690,3 +690,91 @@ fn a_hook_stamped_commit_is_recorded_as_merged_by_sync() { | |||
| 690 | "sync did not record the merge the hook made possible" | 690 | "sync did not record the merge the hook made possible" |
| 691 | ); | 691 | ); |
| 692 | } | 692 | } |
| 693 | |||
| 694 | // --------------------------------------------------------------------------- | ||
| 695 | // Keeping a worktree-resident hook out of `git status` | ||
| 696 | // --------------------------------------------------------------------------- | ||
| 697 | |||
| 698 | /// When `core.hooksPath` points inside the working tree, the installed shim | ||
| 699 | /// lands in the working tree — and it is machine-local (it embeds the binary | ||
| 700 | /// path), so it belongs in `.git/info/exclude`, never in the shared | ||
| 701 | /// `.gitignore`. The oracle is git itself: after install, `git status` must | ||
| 702 | /// not see the file. | ||
| 703 | #[test] | ||
| 704 | fn installing_into_a_worktree_hooks_path_excludes_the_hook_from_status() { | ||
| 705 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 706 | repo.git(&["config", "core.hooksPath", ".githooks"]); | ||
| 707 | repo.run_ok(&["hooks", "install"]); | ||
| 708 | |||
| 709 | assert!( | ||
| 710 | repo.dir.path().join(".githooks/commit-msg").exists(), | ||
| 711 | "hook was not installed into core.hooksPath" | ||
| 712 | ); | ||
| 713 | |||
| 714 | let exclude = | ||
| 715 | std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default(); | ||
| 716 | assert!( | ||
| 717 | exclude.lines().any(|l| l == ".githooks/commit-msg"), | ||
| 718 | "install did not add the hook to .git/info/exclude:\n{exclude}" | ||
| 719 | ); | ||
| 720 | |||
| 721 | let status = repo.git(&["status", "--porcelain"]); | ||
| 722 | assert!( | ||
| 723 | !status.contains(".githooks"), | ||
| 724 | "the installed hook shows up as untracked:\n{status}" | ||
| 725 | ); | ||
| 726 | } | ||
| 727 | |||
| 728 | /// A second install must not duplicate the exclude line. | ||
| 729 | #[test] | ||
| 730 | fn installing_twice_writes_the_exclude_line_once() { | ||
| 731 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 732 | repo.git(&["config", "core.hooksPath", ".githooks"]); | ||
| 733 | repo.run_ok(&["hooks", "install"]); | ||
| 734 | repo.run_ok(&["hooks", "install"]); | ||
| 735 | |||
| 736 | let exclude = | ||
| 737 | std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default(); | ||
| 738 | let count = exclude | ||
| 739 | .lines() | ||
| 740 | .filter(|l| *l == ".githooks/commit-msg") | ||
| 741 | .count(); | ||
| 742 | assert_eq!(count, 1, "exclude line duplicated:\n{exclude}"); | ||
| 743 | } | ||
| 744 | |||
| 745 | /// The default install goes to `.git/hooks`, which git never shows in status; | ||
| 746 | /// nothing should be written to the exclude file for it. | ||
| 747 | #[test] | ||
| 748 | fn installing_into_the_default_hooks_dir_leaves_exclude_alone() { | ||
| 749 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 750 | repo.run_ok(&["hooks", "install"]); | ||
| 751 | |||
| 752 | let exclude = | ||
| 753 | std::fs::read_to_string(repo.dir.path().join(".git/info/exclude")).unwrap_or_default(); | ||
| 754 | assert!( | ||
| 755 | !exclude.contains("commit-msg"), | ||
| 756 | "a .git/hooks install polluted the exclude file:\n{exclude}" | ||
| 757 | ); | ||
| 758 | } | ||
| 759 | |||
| 760 | /// The exclude file is the user's; adding our line must not clobber theirs. | ||
| 761 | #[test] | ||
| 762 | fn a_pre_existing_exclude_keeps_its_content() { | ||
| 763 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 764 | let info = repo.dir.path().join(".git/info"); | ||
| 765 | std::fs::create_dir_all(&info).unwrap(); | ||
| 766 | std::fs::write(info.join("exclude"), "scratch.log\n").unwrap(); | ||
| 767 | |||
| 768 | repo.git(&["config", "core.hooksPath", ".githooks"]); | ||
| 769 | repo.run_ok(&["hooks", "install"]); | ||
| 770 | |||
| 771 | let exclude = std::fs::read_to_string(info.join("exclude")).unwrap(); | ||
| 772 | assert!( | ||
| 773 | exclude.lines().any(|l| l == "scratch.log"), | ||
| 774 | "pre-existing exclude content was lost:\n{exclude}" | ||
| 775 | ); | ||
| 776 | assert!( | ||
| 777 | exclude.lines().any(|l| l == ".githooks/commit-msg"), | ||
| 778 | "hook line missing from a pre-existing exclude:\n{exclude}" | ||
| 779 | ); | ||
| 780 | } | ||