diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 7955772a6..7e503b109 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -4,16 +4,20 @@ use std::env; use std::path::{Path, PathBuf}; +#[path = "build_support/git.rs"] +mod git; + const PROTO_REL: &str = "../../proto"; fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + // --- Git-derived version --- // Compute a version from `git describe` for local builds. In Docker/CI // builds where .git is absent, this silently does nothing and the binary // falls back to CARGO_PKG_VERSION (which is already sed-patched by the // build pipeline). - println!("cargo:rerun-if-changed=../../.git/HEAD"); - println!("cargo:rerun-if-changed=../../.git/refs/tags"); + git::emit_rerun_if_changed(&manifest_dir); if let Some(version) = git_version() { println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}"); @@ -33,7 +37,6 @@ fn main() -> Result<(), Box> { env::set_var("PROTOC", protobuf_src::protoc()); } - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let proto_root = manifest_dir.join(PROTO_REL); let mut proto_files = Vec::new(); diff --git a/crates/openshell-core/build_support/git.rs b/crates/openshell-core/build_support/git.rs new file mode 100644 index 000000000..99c877083 --- /dev/null +++ b/crates/openshell-core/build_support/git.rs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const WATCH_PATHS: [&str; 2] = ["HEAD", "refs/tags"]; + +#[cfg(not(test))] +pub fn emit_rerun_if_changed(manifest_dir: &Path) { + for path in watch_paths(manifest_dir) { + println!("cargo:rerun-if-changed={}", path.display()); + } +} + +pub fn watch_paths(manifest_dir: &Path) -> Vec { + WATCH_PATHS + .into_iter() + .filter_map(|path| resolve_existing_git_path(manifest_dir, path)) + .collect::>() + .into_iter() + .collect() +} + +fn resolve_existing_git_path(manifest_dir: &Path, path: &str) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--git-path", path]) + .current_dir(manifest_dir) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path = PathBuf::from(String::from_utf8(output.stdout).ok()?.trim()); + let path = if path.is_absolute() { + path + } else { + manifest_dir.join(path) + }; + + path.exists().then_some(path) +} diff --git a/crates/openshell-core/tests/git_watch_paths.rs b/crates/openshell-core/tests/git_watch_paths.rs new file mode 100644 index 000000000..b2769e433 --- /dev/null +++ b/crates/openshell-core/tests/git_watch_paths.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[path = "../build_support/git.rs"] +mod git; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn run_git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("git output is UTF-8") + .trim() + .to_owned() +} + +fn init_repository() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("create temp directory"); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).expect("create repository directory"); + + run_git(&repo, &["init", "--quiet"]); + run_git(&repo, &["config", "user.name", "OpenShell Test"]); + run_git( + &repo, + &["config", "user.email", "openshell@example.invalid"], + ); + fs::write(repo.join("README.md"), "test\n").expect("write tracked file"); + run_git(&repo, &["add", "README.md"]); + run_git(&repo, &["commit", "--quiet", "-m", "initial"]); + run_git(&repo, &["tag", "v0.0.1"]); + + (temp, repo) +} + +fn git_path(repo: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(run_git(repo, &["rev-parse", "--git-path", path])); + if path.is_absolute() { + path + } else { + repo.join(path) + } +} + +#[test] +fn resolves_metadata_paths_in_normal_checkout() { + let (_temp, repo) = init_repository(); + + let paths = git::watch_paths(&repo); + + assert!(paths.contains(&git_path(&repo, "HEAD"))); + assert!(paths.contains(&git_path(&repo, "refs/tags"))); + assert!(paths.iter().all(|path| path.exists())); +} + +#[test] +fn resolves_metadata_paths_in_linked_worktree() { + let (temp, repo) = init_repository(); + let worktree = temp.path().join("linked"); + let worktree_arg = worktree.to_str().expect("worktree path is UTF-8"); + run_git( + &repo, + &["worktree", "add", "--quiet", "-b", "linked", worktree_arg], + ); + + let paths = git::watch_paths(&worktree); + let head = git_path(&worktree, "HEAD"); + + assert!(paths.contains(&head)); + assert_ne!(head, worktree.join(".git/HEAD")); + assert!(paths.contains(&git_path(&worktree, "refs/tags"))); + assert!(paths.iter().all(|path| path.exists())); +} + +#[test] +fn ignores_source_tree_without_git_metadata() { + let temp = tempfile::tempdir().expect("create temp directory"); + + assert!(git::watch_paths(temp.path()).is_empty()); +}