Skip to content

Commit 7345296

Browse files
authored
Fix Nix build by not using Git in Cargo build scripts (#3551)
# Description of Changes When building under Nix, Git metadata is not available within the sandbox, as we use `lib.cleanSource` on our source directory. This is important because it avoids spurious rebuilds and/or determinism hazards. The build was broken due to our new `spacetime init` template system accessing Git metadata in the CLI's build.rs to filter out non-git-tracked files from the templates. The Flake sandbox does this automatically (even without `lib.cleanSource`!), so when building under Nix it's unnecessary to do twice. (I remain unconvinced that it's necessary to do in non-Nix builds either, as CI builds should have a clean checkout and local dev builds don't need clean templates, but the behavior was already in master and I didn't feel comfortable removing it.) As an enhancement, I've also found a Nix-ey way to embed our Git commit hash in builds. Previously, builds under Nix had the empty string instead of a commit hash, because we included the `git` CLI tool but scrubbed the necessary metadata. Now, we inject an environment variable from the Nix flake, and don't make the `git` CLI tool available at all. This has the convenient upside of allowing Nix builds to reference `dirtyRev` in builds with a dirty worktree, which should reduce confusion. # API and ABI breaking changes N/a # Expected complexity level and risk 3? I didn't have a strong understanding of what the CLI build script was doing, and to what extent it was doing things intentionally versus for convenience. As such, it's possible that I've inadvertently damaged something load-bearing. # Testing - [x] Built with `nix build`, ran `spacetime init`, chose the `basic-rust` template, and got a reasonable-looking template instantiation. - [ ] Hopefully we have automated tests for this?
1 parent c1032f4 commit 7345296

File tree

3 files changed

+179
-24
lines changed

3 files changed

+179
-24
lines changed

crates/cli/build.rs

Lines changed: 141 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,59 @@ use std::process::Command;
66
use toml::Value;
77

88
fn main() {
9-
let output = Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap();
10-
let git_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
9+
let git_hash = find_git_hash();
1110
println!("cargo:rustc-env=GIT_HASH={git_hash}");
1211

1312
generate_template_files();
1413
}
1514

15+
fn nix_injected_commit_hash() -> Option<String> {
16+
use std::env::VarError;
17+
// Our flake.nix sets this environment variable to be our git commit hash during the build.
18+
// This is important because git metadata is otherwise not available within the nix build sandbox,
19+
// and we don't install the git command-line tool in our build.
20+
match std::env::var("SPACETIMEDB_NIX_BUILD_GIT_COMMIT") {
21+
Ok(commit_sha) => {
22+
// Var is set, we're building under Nix.
23+
Some(commit_sha)
24+
}
25+
26+
Err(VarError::NotPresent) => {
27+
// Var is not set, we're not in Nix.
28+
None
29+
}
30+
Err(VarError::NotUnicode(gross)) => {
31+
// Var is set but is invalid unicode, something is very wrong.
32+
panic!("Injected commit hash is not valid unicode: {gross:?}")
33+
}
34+
}
35+
}
36+
37+
fn is_nix_build() -> bool {
38+
nix_injected_commit_hash().is_some()
39+
}
40+
41+
fn find_git_hash() -> String {
42+
nix_injected_commit_hash().unwrap_or_else(|| {
43+
// When we're *not* building in Nix, we can assume that git metadata is still present in the filesystem,
44+
// and that the git command-line tool is installed.
45+
let output = Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap();
46+
String::from_utf8(output.stdout).unwrap().trim().to_string()
47+
})
48+
}
49+
50+
fn get_manifest_dir() -> PathBuf {
51+
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
52+
}
53+
1654
// This method generates functions with data used in `spacetime init`:
1755
//
1856
// * `get_templates_json` - returns contents of the JSON file with the list of templates
1957
// * `get_template_files` - returns a HashMap with templates contents based on the
2058
// templates list at crates/cli/templates/templates-list.json
2159
// * `get_cursorrules` - returns contents of a cursorrules file
2260
fn generate_template_files() {
23-
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
61+
let manifest_dir = get_manifest_dir();
2462
let manifest_path = Path::new(&manifest_dir);
2563
let templates_json_path = manifest_path.join("templates/templates-list.json");
2664
let out_dir = std::env::var("OUT_DIR").unwrap();
@@ -121,7 +159,7 @@ fn generate_template_files() {
121159
write_if_changed(&dest_path, generated_code.as_bytes()).expect("Failed to write embedded_templates.rs");
122160
}
123161

124-
fn generate_template_entry(code: &mut String, template_path: &Path, source: &str, manifest_dir: &str) {
162+
fn generate_template_entry(code: &mut String, template_path: &Path, source: &str, manifest_dir: &Path) {
125163
let (git_files, resolved_base) = get_git_tracked_files(template_path, manifest_dir);
126164

127165
if git_files.is_empty() {
@@ -221,26 +259,103 @@ fn generate_template_entry(code: &mut String, template_path: &Path, source: &str
221259
code.push_str(" }\n\n");
222260
}
223261

224-
// Get a list of files tracked by git from a given directory
225-
fn get_git_tracked_files(path: &Path, manifest_dir: &str) -> (Vec<PathBuf>, PathBuf) {
226-
let full_path = Path::new(manifest_dir).join(path);
262+
/// Get a list of files tracked by git from a given directory
263+
fn get_git_tracked_files(path: &Path, manifest_dir: &Path) -> (Vec<PathBuf>, PathBuf) {
264+
if is_nix_build() {
265+
// When building in Nix, we already know that there are no untracked files in our source tree,
266+
// so we just list all of the files.
267+
list_all_files(path, manifest_dir)
268+
} else {
269+
// When building outside of Nix, we invoke `git` to list all the tracked files.
270+
get_git_tracked_files_via_cli(path, manifest_dir)
271+
}
272+
}
273+
274+
fn list_all_files(path: &Path, manifest_dir: &Path) -> (Vec<PathBuf>, PathBuf) {
275+
let manifest_dir = manifest_dir.canonicalize().unwrap_or_else(|err| {
276+
panic!(
277+
"Failed to canonicalize manifest_dir path {}: {err:#?}",
278+
manifest_dir.display()
279+
)
280+
});
281+
282+
let template_root_absolute = get_full_path_within_manifest_dir(path, &manifest_dir);
227283

228284
let repo_root = get_repo_root();
229-
let repo_canonical = repo_root.canonicalize().unwrap();
230285

231-
let canonical = full_path.canonicalize().unwrap_or_else(|e| {
286+
let mut files = Vec::new();
287+
ls_recursively(&template_root_absolute, &repo_root, &mut files);
288+
289+
(files, make_repo_root_relative(&template_root_absolute, &repo_root))
290+
}
291+
292+
/// Get all the paths of files within `root_dir`,
293+
/// transform them into paths relative to `repo_root`,
294+
/// and insert them into `out`.
295+
fn ls_recursively(root_dir: &Path, repo_root: &Path, out: &mut Vec<PathBuf>) {
296+
for dir_ent in std::fs::read_dir(root_dir).unwrap_or_else(|err| {
297+
panic!(
298+
"Failed to read_dir from template directory {}: {err:#?}",
299+
root_dir.display()
300+
)
301+
}) {
302+
let dir_ent = dir_ent.unwrap_or_else(|err| {
303+
panic!(
304+
"Got error during read_dir from template directory {}: {err:#?}",
305+
root_dir.display(),
306+
)
307+
});
308+
let file_path = dir_ent.path();
309+
let file_type = dir_ent.file_type().unwrap_or_else(|err| {
310+
panic!(
311+
"Failed to get file_type for template file {}: {err:#?}",
312+
file_path.display(),
313+
)
314+
});
315+
if file_type.is_dir() {
316+
ls_recursively(&file_path, repo_root, out);
317+
} else {
318+
out.push(make_repo_root_relative(&file_path, repo_root));
319+
}
320+
}
321+
}
322+
323+
/// Treat `relative_path` as a relative path within `manifest_dir`
324+
/// and transform it into an absolute, canonical path.
325+
fn get_full_path_within_manifest_dir(relative_path: &Path, manifest_dir: &Path) -> PathBuf {
326+
let full_path = manifest_dir.join(relative_path);
327+
328+
full_path.canonicalize().unwrap_or_else(|e| {
232329
panic!("Failed to canonicalize path {}: {}", full_path.display(), e);
233-
});
234-
let resolved_path = canonical
235-
.strip_prefix(&repo_canonical)
330+
})
331+
}
332+
333+
/// Transform `full_path` into a relative path within `repo_root`.
334+
///
335+
/// `full_path` and `repo_root` should both be canonical paths, as by [`Path::canonicalize`].
336+
fn make_repo_root_relative(full_path: &Path, repo_root: &Path) -> PathBuf {
337+
full_path
338+
.strip_prefix(repo_root)
236339
.map(|p| p.to_path_buf())
237340
.unwrap_or_else(|_| {
238341
panic!(
239342
"Path {} is outside repo root {}",
240-
canonical.display(),
241-
repo_canonical.display()
343+
full_path.display(),
344+
repo_root.display()
242345
)
243-
});
346+
})
347+
}
348+
349+
fn get_git_tracked_files_via_cli(path: &Path, manifest_dir: &Path) -> (Vec<PathBuf>, PathBuf) {
350+
let repo_root = get_repo_root();
351+
let repo_root = repo_root.canonicalize().unwrap_or_else(|err| {
352+
panic!(
353+
"Failed to canonicalize repo_root path {}: {err:#?}",
354+
repo_root.display(),
355+
)
356+
});
357+
358+
let resolved_path = make_repo_root_relative(&get_full_path_within_manifest_dir(path, manifest_dir), &repo_root);
244359

245360
let output = Command::new("git")
246361
.args(["ls-files", resolved_path.to_str().unwrap()])
@@ -263,12 +378,17 @@ fn get_git_tracked_files(path: &Path, manifest_dir: &str) -> (Vec<PathBuf>, Path
263378
}
264379

265380
fn get_repo_root() -> PathBuf {
266-
let output = Command::new("git")
267-
.args(["rev-parse", "--show-toplevel"])
268-
.output()
269-
.expect("Failed to get git repo root");
270-
let path = String::from_utf8(output.stdout).unwrap().trim().to_string();
271-
PathBuf::from(path)
381+
let manifest_dir = get_manifest_dir();
382+
// Cargo doesn't expose a way to get the workspace root, AFAICT (pgoldman 2025-10-31).
383+
// We don't want to query git metadata for this, as that will break in Nix builds.
384+
// We happen to know our own directory structure, so we can just walk the tree to get to the root.
385+
let repo_root = manifest_dir.join("..").join("..");
386+
repo_root.canonicalize().unwrap_or_else(|err| {
387+
panic!(
388+
"Failed to canonicalize repo_root path {}: {err:#?}",
389+
repo_root.display()
390+
)
391+
})
272392
}
273393

274394
fn extract_workspace_metadata(path: &Path) -> io::Result<(String, BTreeMap<String, String>)> {

crates/lib/build.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,37 @@ use std::process::Command;
33
// https://stackoverflow.com/questions/43753491/include-git-commit-hash-as-string-into-rust-program
44
#[allow(clippy::disallowed_macros)]
55
fn main() {
6-
let output = Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap();
7-
let git_hash = String::from_utf8(output.stdout).unwrap();
6+
let git_hash = find_git_hash();
87
println!("cargo:rustc-env=GIT_HASH={git_hash}");
98
}
9+
10+
fn nix_injected_commit_hash() -> Option<String> {
11+
use std::env::VarError;
12+
// Our flake.nix sets this environment variable to be our git commit hash during the build.
13+
// This is important because git metadata is otherwise not available within the nix build sandbox,
14+
// and we don't install the git command-line tool in our build.
15+
match std::env::var("SPACETIMEDB_NIX_BUILD_GIT_COMMIT") {
16+
Ok(commit_sha) => {
17+
// Var is set, we're building under Nix.
18+
Some(commit_sha)
19+
}
20+
21+
Err(VarError::NotPresent) => {
22+
// Var is not set, we're not in Nix.
23+
None
24+
}
25+
Err(VarError::NotUnicode(gross)) => {
26+
// Var is set but is invalid unicode, something is very wrong.
27+
panic!("Injected commit hash is not valid unicode: {gross:?}")
28+
}
29+
}
30+
}
31+
32+
fn find_git_hash() -> String {
33+
nix_injected_commit_hash().unwrap_or_else(|| {
34+
// When we're *not* building in Nix, we can assume that git metadata is still present in the filesystem,
35+
// and that the git command-line tool is installed.
36+
let output = Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap();
37+
String::from_utf8(output.stdout).unwrap().trim().to_string()
38+
})
39+
}

flake.nix

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525

2626
inherit (pkgs) lib;
2727

28+
# Inject git commit in an env var around the build so that we can embed it in the binary
29+
# without calling into `git` during our build.
30+
# Note that `self.rev` is not set for builds with a dirty worktree, in which case we instead use `self.dirtyRev`.
31+
gitCommit = if (self ? rev) then self.rev else self.dirtyRev;
32+
2833
librusty_v8 = if pkgs.stdenv.isDarwin then
2934
# Building on MacOS, we've seen errors building rusty_v8 with a local RUSTY_V8_ARCHIVE:
3035
# https://github.com/clockworklabs/SpacetimeDB/pull/3422#issuecomment-3416972711 .
@@ -65,7 +70,6 @@
6570
pkgs.perl
6671
pkgs.python3
6772
pkgs.cmake
68-
pkgs.git
6973
pkgs.pkg-config
7074
];
7175
# buildInputs are libraries that wind up in the target build.
@@ -80,6 +84,7 @@
8084

8185
# Include our precompiled V8.
8286
RUSTY_V8_ARCHIVE = librusty_v8;
87+
SPACETIMEDB_NIX_BUILD_GIT_COMMIT = gitCommit;
8388
};
8489

8590
# Build a separate derivation containing our dependencies,

0 commit comments

Comments
 (0)