a73x

058018f9

Exclude a worktree-resident hook from git status

a73x   2026-08-19 08:08

Commit message
Exclude a worktree-resident hook from git status

With a relative core.hooksPath the installed commit-msg shim lands
inside the working tree, where it sits as permanently-untracked noise
in git status. The shim is machine-local — it embeds the installing
binary's absolute path — so it belongs in .git/info/exclude, the
repo-local ignore that never gets committed, not in the shared
.gitignore.

Best-effort by design: a hook that runs matters more than a clean
status, so failing to write the exclude never fails the install. The
default .git/hooks location is invisible to status already and writes
nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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 }