diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 4a65efed..7b204ac3 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -17,32 +17,98 @@ fn main() { } /// Tell Cargo to invalidate the cached build script output when `HEAD` -/// moves, so the embedded short-SHA stays in sync with the checkout. With -/// only `rerun-if-env-changed` lines, Cargo otherwise caches the script -/// across commits and the SHA goes stale. +/// moves, so the embedded short-SHA stays in sync with the checkout. +/// +/// `.git/HEAD` only changes on branch switches and detached-HEAD moves — +/// `git commit` on the current branch updates the underlying ref file +/// (loose `refs/heads/`, or `packed-refs` after `git pack-refs`) +/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we +/// also watch the resolved target and `packed-refs`. A non-existent +/// `rerun-if-changed` path is treated as "always changed" by Cargo, which +/// covers the loose→packed transition. fn declare_git_head_rerun() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace_root = manifest_dir.join("..").join(".."); let git_meta = workspace_root.join(".git"); - if git_meta.is_dir() { - println!("cargo:rerun-if-changed={}", git_meta.join("HEAD").display()); + + let gitdir = if git_meta.is_dir() { + git_meta } else if git_meta.is_file() { - // Worktree checkout: `.git` is a pointer file with `gitdir: `. + // Worktree pointer file: watch it directly, then follow `gitdir:`. println!("cargo:rerun-if-changed={}", git_meta.display()); - if let Ok(contents) = std::fs::read_to_string(&git_meta) { - for line in contents.lines() { - if let Some(rest) = line.strip_prefix("gitdir:") { - let trimmed = rest.trim(); - let gitdir = if Path::new(trimmed).is_absolute() { - PathBuf::from(trimmed) - } else { - workspace_root.join(trimmed) - }; - println!("cargo:rerun-if-changed={}", gitdir.join("HEAD").display()); - break; - } - } + let Ok(contents) = std::fs::read_to_string(&git_meta) else { + return; + }; + let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else { + return; + }; + let trimmed = rest.trim(); + if Path::new(trimmed).is_absolute() { + PathBuf::from(trimmed) + } else { + workspace_root.join(trimmed) } + } else { + return; + }; + + let head = gitdir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head.display()); + + if let Ok(contents) = std::fs::read_to_string(&head) + && let Some(target) = parse_symbolic_ref(&contents) + { + println!("cargo:rerun-if-changed={}", gitdir.join(target).display()); + println!( + "cargo:rerun-if-changed={}", + gitdir.join("packed-refs").display() + ); + } +} + +/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the +/// target ref path. Returns `None` for a detached HEAD (raw SHA). +fn parse_symbolic_ref(head_contents: &str) -> Option<&str> { + head_contents + .lines() + .next() + .and_then(|line| line.strip_prefix("ref:")) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::parse_symbolic_ref; + + #[test] + fn symbolic_ref_strips_prefix_and_whitespace() { + assert_eq!( + parse_symbolic_ref("ref: refs/heads/main\n"), + Some("refs/heads/main") + ); + } + + #[test] + fn symbolic_ref_handles_no_trailing_newline() { + assert_eq!( + parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"), + Some("refs/heads/work/v0.8.26-security") + ); + } + + #[test] + fn detached_head_is_not_a_symbolic_ref() { + assert_eq!( + parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"), + None + ); + } + + #[test] + fn empty_input_returns_none() { + assert_eq!(parse_symbolic_ref(""), None); + assert_eq!(parse_symbolic_ref("ref: \n"), None); } } diff --git a/crates/tui/build.rs b/crates/tui/build.rs index d5952323..8611287c 100644 --- a/crates/tui/build.rs +++ b/crates/tui/build.rs @@ -18,32 +18,98 @@ fn main() { } /// Tell Cargo to invalidate the cached build script output when `HEAD` -/// moves, so the embedded short-SHA stays in sync with the checkout. With -/// only `rerun-if-env-changed` lines, Cargo otherwise caches the script -/// across commits and the SHA goes stale. +/// moves, so the embedded short-SHA stays in sync with the checkout. +/// +/// `.git/HEAD` only changes on branch switches and detached-HEAD moves — +/// `git commit` on the current branch updates the underlying ref file +/// (loose `refs/heads/`, or `packed-refs` after `git pack-refs`) +/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we +/// also watch the resolved target and `packed-refs`. A non-existent +/// `rerun-if-changed` path is treated as "always changed" by Cargo, which +/// covers the loose→packed transition. fn declare_git_head_rerun() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace_root = manifest_dir.join("..").join(".."); let git_meta = workspace_root.join(".git"); - if git_meta.is_dir() { - println!("cargo:rerun-if-changed={}", git_meta.join("HEAD").display()); + + let gitdir = if git_meta.is_dir() { + git_meta } else if git_meta.is_file() { - // Worktree checkout: `.git` is a pointer file with `gitdir: `. + // Worktree pointer file: watch it directly, then follow `gitdir:`. println!("cargo:rerun-if-changed={}", git_meta.display()); - if let Ok(contents) = std::fs::read_to_string(&git_meta) { - for line in contents.lines() { - if let Some(rest) = line.strip_prefix("gitdir:") { - let trimmed = rest.trim(); - let gitdir = if Path::new(trimmed).is_absolute() { - PathBuf::from(trimmed) - } else { - workspace_root.join(trimmed) - }; - println!("cargo:rerun-if-changed={}", gitdir.join("HEAD").display()); - break; - } - } + let Ok(contents) = std::fs::read_to_string(&git_meta) else { + return; + }; + let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else { + return; + }; + let trimmed = rest.trim(); + if Path::new(trimmed).is_absolute() { + PathBuf::from(trimmed) + } else { + workspace_root.join(trimmed) } + } else { + return; + }; + + let head = gitdir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head.display()); + + if let Ok(contents) = std::fs::read_to_string(&head) + && let Some(target) = parse_symbolic_ref(&contents) + { + println!("cargo:rerun-if-changed={}", gitdir.join(target).display()); + println!( + "cargo:rerun-if-changed={}", + gitdir.join("packed-refs").display() + ); + } +} + +/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the +/// target ref path. Returns `None` for a detached HEAD (raw SHA). +fn parse_symbolic_ref(head_contents: &str) -> Option<&str> { + head_contents + .lines() + .next() + .and_then(|line| line.strip_prefix("ref:")) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::parse_symbolic_ref; + + #[test] + fn symbolic_ref_strips_prefix_and_whitespace() { + assert_eq!( + parse_symbolic_ref("ref: refs/heads/main\n"), + Some("refs/heads/main") + ); + } + + #[test] + fn symbolic_ref_handles_no_trailing_newline() { + assert_eq!( + parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"), + Some("refs/heads/work/v0.8.26-security") + ); + } + + #[test] + fn detached_head_is_not_a_symbolic_ref() { + assert_eq!( + parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"), + None + ); + } + + #[test] + fn empty_input_returns_none() { + assert_eq!(parse_symbolic_ref(""), None); + assert_eq!(parse_symbolic_ref("ref: \n"), None); } }