Skip to main content

codewhale_build_support/
lib.rs

1//! Shared build-script helpers for the `codewhale-cli` and `codewhale-tui`
2//! build scripts: rerun-condition declarations and the embedded
3//! `DEEPSEEK_BUILD_VERSION` metadata. Only call these functions from a build
4//! script — they emit `cargo:` directives on stdout.
5
6use std::{
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11/// Declare the rerun conditions for the build-metadata directives: the
12/// SHA-override environment variables plus the git files that track `HEAD`.
13///
14/// `manifest_dir` is the calling build script's `CARGO_MANIFEST_DIR`.
15pub fn declare_rerun_conditions(manifest_dir: &Path) {
16    println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
17    println!("cargo:rerun-if-env-changed=GITHUB_SHA");
18    declare_git_head_rerun(manifest_dir);
19}
20
21/// Emit `cargo:rustc-env=DEEPSEEK_BUILD_VERSION=...` — the package version,
22/// suffixed with the short build SHA when one can be determined.
23///
24/// `manifest_dir` and `package_version` are the calling build script's
25/// `CARGO_MANIFEST_DIR` and `CARGO_PKG_VERSION`.
26pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
27    let build_version = build_sha(manifest_dir)
28        .map(|sha| format!("{package_version} ({sha})"))
29        .unwrap_or_else(|| package_version.to_string());
30
31    println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
32}
33
34/// Tell Cargo to invalidate the cached build script output when `HEAD`
35/// moves, so the embedded short-SHA stays in sync with the checkout.
36///
37/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
38/// `git commit` on the current branch updates the underlying ref file
39/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
40/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
41/// also watch the resolved target and `packed-refs`. Linked worktrees keep
42/// `HEAD` in a private gitdir but store branch refs in the shared common gitdir,
43/// so the symbolic target must be watched from that common directory. A
44/// non-existent `rerun-if-changed` path is treated as "always changed" by
45/// Cargo, which covers the loose→packed transition.
46fn declare_git_head_rerun(manifest_dir: &Path) {
47    let workspace_root = manifest_dir.join("..").join("..");
48    let git_meta = workspace_root.join(".git");
49
50    let gitdir = if git_meta.is_dir() {
51        git_meta
52    } else if git_meta.is_file() {
53        // Worktree pointer file: watch it directly, then follow `gitdir:`.
54        println!("cargo:rerun-if-changed={}", git_meta.display());
55        let Ok(contents) = std::fs::read_to_string(&git_meta) else {
56            return;
57        };
58        let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
59            return;
60        };
61        let trimmed = rest.trim();
62        if Path::new(trimmed).is_absolute() {
63            PathBuf::from(trimmed)
64        } else {
65            workspace_root.join(trimmed)
66        }
67    } else {
68        return;
69    };
70
71    let head = gitdir.join("HEAD");
72    println!("cargo:rerun-if-changed={}", head.display());
73
74    if let Ok(contents) = std::fs::read_to_string(&head)
75        && let Some(target) = parse_symbolic_ref(&contents)
76    {
77        let common_gitdir = git_common_dir(&gitdir);
78        println!(
79            "cargo:rerun-if-changed={}",
80            common_gitdir.join(target).display()
81        );
82        println!(
83            "cargo:rerun-if-changed={}",
84            common_gitdir.join("packed-refs").display()
85        );
86    }
87}
88
89/// Resolve the shared ref store for a normal repository or a linked worktree.
90/// Git writes `commondir` in a linked worktree's private gitdir; its value is
91/// relative to that directory unless Git supplied an absolute path.
92fn git_common_dir(gitdir: &Path) -> PathBuf {
93    let commondir = gitdir.join("commondir");
94    let Ok(contents) = std::fs::read_to_string(commondir) else {
95        return gitdir.to_path_buf();
96    };
97    let trimmed = contents.trim();
98    if trimmed.is_empty() {
99        return gitdir.to_path_buf();
100    }
101    let path = Path::new(trimmed);
102    if path.is_absolute() {
103        path.to_path_buf()
104    } else {
105        gitdir.join(path)
106    }
107}
108
109/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
110/// target ref path. Returns `None` for a detached HEAD (raw SHA).
111fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
112    head_contents
113        .lines()
114        .next()
115        .and_then(|line| line.strip_prefix("ref:"))
116        .map(str::trim)
117        .filter(|s| !s.is_empty())
118}
119
120fn build_sha(manifest_dir: &Path) -> Option<String> {
121    env_sha("DEEPSEEK_BUILD_SHA")
122        .or_else(|| env_sha("GITHUB_SHA"))
123        .or_else(|| git_sha(manifest_dir))
124}
125
126fn env_sha(name: &str) -> Option<String> {
127    std::env::var(name).ok().and_then(short_sha)
128}
129
130fn git_sha(manifest_dir: &Path) -> Option<String> {
131    let top_level_output = Command::new("git")
132        .args(["-C"])
133        .arg(manifest_dir)
134        .args(["rev-parse", "--show-toplevel"])
135        .output()
136        .ok()?;
137    if !top_level_output.status.success() {
138        return None;
139    }
140    let top_level = PathBuf::from(String::from_utf8_lossy(&top_level_output.stdout).trim());
141    if !top_level.join("Cargo.toml").is_file() || !top_level.join("crates/tui").is_dir() {
142        return None;
143    }
144
145    let output = Command::new("git")
146        .args(["-C"])
147        .arg(top_level)
148        .args(["rev-parse", "--short=12", "HEAD"])
149        .output()
150        .ok()?;
151    if !output.status.success() {
152        return None;
153    }
154
155    short_sha(String::from_utf8_lossy(&output.stdout).to_string())
156}
157
158fn short_sha(value: String) -> Option<String> {
159    let trimmed = value.trim();
160    if trimmed.is_empty() {
161        return None;
162    }
163    Some(trimmed.chars().take(12).collect())
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{git_common_dir, parse_symbolic_ref};
169    use std::{
170        fs,
171        time::{SystemTime, UNIX_EPOCH},
172    };
173
174    #[test]
175    fn symbolic_ref_strips_prefix_and_whitespace() {
176        assert_eq!(
177            parse_symbolic_ref("ref: refs/heads/main\n"),
178            Some("refs/heads/main")
179        );
180    }
181
182    #[test]
183    fn symbolic_ref_handles_no_trailing_newline() {
184        assert_eq!(
185            parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
186            Some("refs/heads/work/v0.8.26-security")
187        );
188    }
189
190    #[test]
191    fn detached_head_is_not_a_symbolic_ref() {
192        assert_eq!(
193            parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
194            None
195        );
196    }
197
198    #[test]
199    fn empty_input_returns_none() {
200        assert_eq!(parse_symbolic_ref(""), None);
201        assert_eq!(parse_symbolic_ref("ref: \n"), None);
202    }
203
204    #[test]
205    fn linked_worktree_uses_the_common_ref_store() {
206        let unique = SystemTime::now()
207            .duration_since(UNIX_EPOCH)
208            .expect("clock before epoch")
209            .as_nanos();
210        let root = std::env::temp_dir().join(format!(
211            "codewhale-build-support-{}-{unique}",
212            std::process::id()
213        ));
214        let common = root.join(".git");
215        let worktree_gitdir = common.join("worktrees/candidate");
216        fs::create_dir_all(&worktree_gitdir).expect("create worktree gitdir");
217        fs::write(worktree_gitdir.join("commondir"), "../..\n").expect("write commondir");
218
219        assert_eq!(
220            fs::canonicalize(git_common_dir(&worktree_gitdir)).expect("canonical common gitdir"),
221            fs::canonicalize(&common).expect("canonical expected gitdir")
222        );
223
224        fs::remove_dir_all(root).expect("remove isolated test directory");
225    }
226}