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 commit = build_commit(manifest_dir);
28    let build_version = commit
29        .as_ref()
30        .and_then(|sha| short_sha(sha.clone()))
31        .map(|sha| format!("{package_version} ({sha})"))
32        .unwrap_or_else(|| package_version.to_string());
33
34    println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
35    if let Some(commit) = commit {
36        println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
37    }
38}
39
40/// Tell Cargo to invalidate the cached build script output when `HEAD`
41/// moves, so the embedded short-SHA stays in sync with the checkout.
42///
43/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
44/// `git commit` on the current branch updates the underlying ref file
45/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
46/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
47/// also watch the resolved target and `packed-refs`. Linked worktrees keep
48/// `HEAD` in a private gitdir but store branch refs in the shared common gitdir,
49/// so the symbolic target must be watched from that common directory. A
50/// non-existent `rerun-if-changed` path is treated as "always changed" by
51/// Cargo, which covers the loose→packed transition.
52fn declare_git_head_rerun(manifest_dir: &Path) {
53    let workspace_root = manifest_dir.join("..").join("..");
54    let git_meta = workspace_root.join(".git");
55
56    let gitdir = if git_meta.is_dir() {
57        git_meta
58    } else if git_meta.is_file() {
59        // Worktree pointer file: watch it directly, then follow `gitdir:`.
60        println!("cargo:rerun-if-changed={}", git_meta.display());
61        let Ok(contents) = std::fs::read_to_string(&git_meta) else {
62            return;
63        };
64        let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
65            return;
66        };
67        let trimmed = rest.trim();
68        if Path::new(trimmed).is_absolute() {
69            PathBuf::from(trimmed)
70        } else {
71            workspace_root.join(trimmed)
72        }
73    } else {
74        return;
75    };
76
77    let head = gitdir.join("HEAD");
78    println!("cargo:rerun-if-changed={}", head.display());
79
80    if let Ok(contents) = std::fs::read_to_string(&head)
81        && let Some(target) = parse_symbolic_ref(&contents)
82    {
83        let common_gitdir = git_common_dir(&gitdir);
84        println!(
85            "cargo:rerun-if-changed={}",
86            common_gitdir.join(target).display()
87        );
88        println!(
89            "cargo:rerun-if-changed={}",
90            common_gitdir.join("packed-refs").display()
91        );
92    }
93}
94
95/// Resolve the shared ref store for a normal repository or a linked worktree.
96/// Git writes `commondir` in a linked worktree's private gitdir; its value is
97/// relative to that directory unless Git supplied an absolute path.
98fn git_common_dir(gitdir: &Path) -> PathBuf {
99    let commondir = gitdir.join("commondir");
100    let Ok(contents) = std::fs::read_to_string(commondir) else {
101        return gitdir.to_path_buf();
102    };
103    let trimmed = contents.trim();
104    if trimmed.is_empty() {
105        return gitdir.to_path_buf();
106    }
107    let path = Path::new(trimmed);
108    if path.is_absolute() {
109        path.to_path_buf()
110    } else {
111        gitdir.join(path)
112    }
113}
114
115/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
116/// target ref path. Returns `None` for a detached HEAD (raw SHA).
117fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
118    head_contents
119        .lines()
120        .next()
121        .and_then(|line| line.strip_prefix("ref:"))
122        .map(str::trim)
123        .filter(|s| !s.is_empty())
124}
125
126fn build_commit(manifest_dir: &Path) -> Option<String> {
127    env_commit("DEEPSEEK_BUILD_SHA")
128        .or_else(|| env_commit("GITHUB_SHA"))
129        .or_else(|| git_commit(manifest_dir))
130}
131
132fn env_commit(name: &str) -> Option<String> {
133    std::env::var(name).ok().and_then(full_sha)
134}
135
136fn git_commit(manifest_dir: &Path) -> Option<String> {
137    let top_level_output = Command::new("git")
138        .args(["-C"])
139        .arg(manifest_dir)
140        .args(["rev-parse", "--show-toplevel"])
141        .output()
142        .ok()?;
143    if !top_level_output.status.success() {
144        return None;
145    }
146    let top_level = PathBuf::from(String::from_utf8_lossy(&top_level_output.stdout).trim());
147    if !top_level.join("Cargo.toml").is_file() || !top_level.join("crates/tui").is_dir() {
148        return None;
149    }
150
151    let output = Command::new("git")
152        .args(["-C"])
153        .arg(top_level)
154        .args(["rev-parse", "HEAD"])
155        .output()
156        .ok()?;
157    if !output.status.success() {
158        return None;
159    }
160
161    full_sha(String::from_utf8_lossy(&output.stdout).to_string())
162}
163
164fn full_sha(value: String) -> Option<String> {
165    let trimmed = value.trim().to_ascii_lowercase();
166    if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
167        return None;
168    }
169    Some(trimmed)
170}
171
172fn short_sha(value: String) -> Option<String> {
173    let trimmed = value.trim();
174    if trimmed.is_empty() {
175        return None;
176    }
177    Some(trimmed.chars().take(12).collect())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{full_sha, git_common_dir, parse_symbolic_ref, short_sha};
183    use std::{
184        fs,
185        time::{SystemTime, UNIX_EPOCH},
186    };
187
188    #[test]
189    fn symbolic_ref_strips_prefix_and_whitespace() {
190        assert_eq!(
191            parse_symbolic_ref("ref: refs/heads/main\n"),
192            Some("refs/heads/main")
193        );
194    }
195
196    #[test]
197    fn symbolic_ref_handles_no_trailing_newline() {
198        assert_eq!(
199            parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
200            Some("refs/heads/work/v0.8.26-security")
201        );
202    }
203
204    #[test]
205    fn full_commit_requires_exact_forty_hex_characters() {
206        assert_eq!(
207            full_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
208            Some("abcdef0123456789abcdef0123456789abcdef01".to_string())
209        );
210        assert_eq!(full_sha("abc123".to_string()), None);
211        assert_eq!(
212            full_sha("gggggggggggggggggggggggggggggggggggggggg".to_string()),
213            None
214        );
215        assert_eq!(
216            short_sha("abcdef0123456789abcdef0123456789abcdef01".to_string()),
217            Some("abcdef012345".to_string())
218        );
219    }
220
221    #[test]
222    fn detached_head_is_not_a_symbolic_ref() {
223        assert_eq!(
224            parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
225            None
226        );
227    }
228
229    #[test]
230    fn empty_input_returns_none() {
231        assert_eq!(parse_symbolic_ref(""), None);
232        assert_eq!(parse_symbolic_ref("ref: \n"), None);
233    }
234
235    #[test]
236    fn linked_worktree_uses_the_common_ref_store() {
237        let unique = SystemTime::now()
238            .duration_since(UNIX_EPOCH)
239            .expect("clock before epoch")
240            .as_nanos();
241        let root = std::env::temp_dir().join(format!(
242            "codewhale-build-support-{}-{unique}",
243            std::process::id()
244        ));
245        let common = root.join(".git");
246        let worktree_gitdir = common.join("worktrees/candidate");
247        fs::create_dir_all(&worktree_gitdir).expect("create worktree gitdir");
248        fs::write(worktree_gitdir.join("commondir"), "../..\n").expect("write commondir");
249
250        assert_eq!(
251            fs::canonicalize(git_common_dir(&worktree_gitdir)).expect("canonical common gitdir"),
252            fs::canonicalize(&common).expect("canonical expected gitdir")
253        );
254
255        fs::remove_dir_all(root).expect("remove isolated test directory");
256    }
257}