Skip to content

Commit f60e8bc

Browse files
Merge branch 'main' into ultracite
2 parents ce3d47e + a0e90cb commit f60e8bc

File tree

9 files changed

+253
-30
lines changed

9 files changed

+253
-30
lines changed

crates/turborepo-hash/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub struct TaskHashable<'a> {
6060
pub global_hash: &'a str,
6161
pub task_dependency_hashes: Vec<String>,
6262
pub hash_of_files: &'a str,
63-
pub external_deps_hash: Option<String>,
63+
pub external_deps_hash: Option<&'a str>,
6464

6565
// task
6666
pub package_dir: Option<turbopath::RelativeUnixPathBuf>,
@@ -320,7 +320,7 @@ impl From<TaskHashable<'_>> for Builder<HeapAllocator> {
320320

321321
builder.set_hash_of_files(task_hashable.hash_of_files);
322322
if let Some(external_deps_hash) = task_hashable.external_deps_hash {
323-
builder.set_external_deps_hash(&external_deps_hash);
323+
builder.set_external_deps_hash(external_deps_hash);
324324
}
325325

326326
builder.set_task(task_hashable.task);
@@ -512,7 +512,7 @@ mod test {
512512
task_dependency_hashes: vec!["task_dependency_hash".to_string()],
513513
package_dir: Some(turbopath::RelativeUnixPathBuf::new("package_dir").unwrap()),
514514
hash_of_files: "hash_of_files",
515-
external_deps_hash: Some("external_deps_hash".to_string()),
515+
external_deps_hash: Some("external_deps_hash"),
516516
task: "task",
517517
outputs: TaskOutputs {
518518
inclusions: vec!["inclusions".to_string()],

crates/turborepo-lib/src/task_graph/visitor/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ impl<'a> Visitor<'a> {
138138
is_watch: bool,
139139
micro_frontends_configs: Option<&'a MicrofrontendsConfigs>,
140140
) -> Self {
141-
let task_hasher = TaskHasher::new(
141+
let mut task_hasher = TaskHasher::new(
142142
package_inputs_hashes,
143143
run_opts,
144144
env_at_execution_start,
@@ -147,6 +147,8 @@ impl<'a> Visitor<'a> {
147147
global_env_patterns,
148148
);
149149

150+
task_hasher.precompute_external_deps_hashes(package_graph.packages());
151+
150152
let sink = Self::sink(run_opts);
151153
let color_cache = ColorSelector::default();
152154
// Set up correct size for underlying pty

crates/turborepo-run-summary/src/scm.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ impl SCMState {
4343
}
4444
}
4545

46-
// Fall back to using `git`
46+
// Fall back to using git. Combined call opens the repo once via
47+
// libgit2 instead of spawning two git subprocesses.
4748
if state.branch.is_none() && state.sha.is_none() {
49+
let (branch, sha) = scm.get_current_branch_and_sha(dir);
4850
if state.branch.is_none() {
49-
state.branch = scm.get_current_branch(dir).ok();
51+
state.branch = branch;
5052
}
5153
if state.sha.is_none() {
52-
state.sha = scm.get_current_sha(dir).ok();
54+
state.sha = sha;
5355
}
5456
}
5557

crates/turborepo-scm/src/git.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ impl SCM {
3737
}
3838
}
3939

40+
/// Get both branch and SHA with a single libgit2 Repository::open,
41+
/// avoiding the overhead of opening the repo twice and spawning two
42+
/// git subprocesses. Falls back to subprocess calls if libgit2 fails.
43+
pub fn get_current_branch_and_sha(
44+
&self,
45+
_path: &AbsoluteSystemPath,
46+
) -> (Option<String>, Option<String>) {
47+
match self {
48+
#[cfg(feature = "git2")]
49+
Self::Git(git) => {
50+
if let Some((branch, sha)) = git.get_current_branch_and_sha() {
51+
return (Some(branch), Some(sha));
52+
}
53+
(git.get_current_branch().ok(), git.get_current_sha().ok())
54+
}
55+
#[cfg(not(feature = "git2"))]
56+
Self::Git(git) => (git.get_current_branch().ok(), git.get_current_sha().ok()),
57+
Self::Manual => (None, None),
58+
}
59+
}
60+
4061
/// get the actual changed files between two git refs
4162
pub fn changed_files(
4263
&self,
@@ -214,6 +235,27 @@ impl GitRepo {
214235
Ok(output.trim().to_owned())
215236
}
216237

238+
/// Get branch and SHA in a single libgit2 Repository::open call.
239+
#[cfg(feature = "git2")]
240+
fn get_current_branch_and_sha(&self) -> Option<(String, String)> {
241+
let repo = git2::Repository::open(self.root.as_std_path()).ok()?;
242+
let head = repo.head().ok()?;
243+
244+
let branch = if head.is_branch() {
245+
head.shorthand().unwrap_or("").to_owned()
246+
} else {
247+
String::new()
248+
};
249+
250+
let commit = head.peel_to_commit().ok()?;
251+
let mut hex_buf = [0u8; 40];
252+
hex::encode_to_slice(commit.id().as_bytes(), &mut hex_buf).ok()?;
253+
// SAFETY: hex output is always valid ASCII
254+
let sha = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_owned();
255+
256+
Some((branch, sha))
257+
}
258+
217259
/// for GitHub Actions environment variables, see: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
218260
pub fn get_github_base_ref(base_ref_env: CIEnv) -> Option<String> {
219261
// make sure we're running in a CI environment

crates/turborepo-scm/src/hash_object.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ pub(crate) fn hash_objects(
6363
AnchoredSystemPathBuf::relative_path_between(pkg_path, &full_file_path)
6464
.to_unix()
6565
});
66-
Ok(Some((package_relative_path, hash.to_string())))
66+
let mut hex_buf = [0u8; 40];
67+
hex::encode_to_slice(hash.as_bytes(), &mut hex_buf).unwrap();
68+
// SAFETY: hex output is always valid ASCII
69+
let hash_str = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_string();
70+
Ok(Some((package_relative_path, hash_str)))
6771
}
6872
Err(e) => {
6973
if e.class() == git2::ErrorClass::Os

crates/turborepo-scm/src/ls_tree.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,22 @@ impl GitRepo {
5757
.map_err(|e| Error::git2_error_context(e, "peeling HEAD to tree".into()))?;
5858

5959
let mut hashes = Vec::new();
60+
let mut path_buf = String::with_capacity(128);
61+
let mut hex_buf = [0u8; 40];
6062
tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
61-
// Only collect blob entries (files), skip trees (directories)
6263
if entry.kind() == Some(git2::ObjectType::Blob) {
6364
let name = match entry.name() {
6465
Some(n) => n,
6566
None => return git2::TreeWalkResult::Ok,
6667
};
67-
let path_str = if dir.is_empty() {
68-
name.to_string()
69-
} else {
70-
format!("{dir}{name}")
71-
};
72-
if let Ok(path) = RelativeUnixPathBuf::new(path_str) {
73-
hashes.push((path, entry.id().to_string()));
68+
path_buf.clear();
69+
path_buf.push_str(dir);
70+
path_buf.push_str(name);
71+
if let Ok(path) = RelativeUnixPathBuf::new(path_buf.clone()) {
72+
hex::encode_to_slice(entry.id().as_bytes(), &mut hex_buf).unwrap();
73+
// SAFETY: hex output is always valid ASCII
74+
let hash = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_string();
75+
hashes.push((path, hash));
7476
}
7577
}
7678
git2::TreeWalkResult::Ok

crates/turborepo-scm/src/package_deps.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,11 @@ impl GitRepo {
267267
// Include globs can find files not in the git index (e.g. gitignored files
268268
// that a user explicitly wants to track). Walk the filesystem for these
269269
// files but skip re-hashing any already known from the index.
270+
let pkg_prefix = package_path.to_unix();
271+
270272
if !includes.is_empty() {
271273
let full_pkg_path = turbo_root.resolve(package_path);
272-
let package_unix_path_buf = package_path.to_unix();
273-
let package_unix_path = package_unix_path_buf.as_str();
274+
let package_unix_path = pkg_prefix.as_str();
274275

275276
static CONFIG_FILES: &[&str] = &["package.json", "turbo.json", "turbo.jsonc"];
276277
let mut inclusions = Vec::with_capacity(includes.len() + CONFIG_FILES.len());
@@ -304,12 +305,10 @@ impl GitRepo {
304305
let mut to_hash = Vec::new();
305306
for entry in &files {
306307
let git_relative = self.root.anchor(entry)?.to_unix();
307-
let pkg_relative = turbopath::RelativeUnixPath::strip_prefix(
308-
&git_relative,
309-
&package_unix_path_buf,
310-
)
311-
.ok()
312-
.map(|s| s.to_owned());
308+
let pkg_relative =
309+
turbopath::RelativeUnixPath::strip_prefix(&git_relative, &pkg_prefix)
310+
.ok()
311+
.map(|s| s.to_owned());
313312

314313
let already_known = pkg_relative
315314
.as_ref()

crates/turborepo-scm/src/repo_index.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ use crate::{Error, GitHashes, GitRepo, ls_tree::SortedGitHashes, status::RepoSta
99
/// and `git status` so they can be filtered per-package without spawning
1010
/// additional subprocesses.
1111
///
12-
/// Uses a sorted `Vec` for the ls-tree data so that per-package lookups can
13-
/// use `partition_point` (binary search) for range queries. This gives the
14-
/// same O(log n) asymptotic cost as a `BTreeMap` but with better cache
15-
/// locality on the contiguous memory.
12+
/// Both collections are sorted by path so that per-package lookups can use
13+
/// `partition_point` (binary search) for range queries. This gives O(log n)
14+
/// lookup cost with good cache locality on contiguous memory.
1615
pub struct RepoGitIndex {
1716
ls_tree_hashes: SortedGitHashes,
17+
/// Sorted by path so per-package filtering can use binary-search range
18+
/// queries instead of linear scans. With P packages and S status entries
19+
/// the cost drops from O(P*S) to O(P * log S).
1820
status_entries: Vec<RepoStatusEntry>,
1921
}
2022

@@ -259,6 +261,48 @@ mod tests {
259261
assert_eq!(to_hash, vec![path("new.ts")]);
260262
}
261263

264+
#[test]
265+
fn test_sorted_status_binary_search_matches_linear_scan() {
266+
let status = vec![
267+
("apps/docs/new.ts", false),
268+
("apps/web/changed.ts", false),
269+
("apps/web-admin/added.ts", false),
270+
("apps/web/deleted.ts", true),
271+
("packages/ui/modified.ts", false),
272+
("root-new.ts", false),
273+
];
274+
275+
let index = make_index(
276+
vec![
277+
("apps/docs/index.ts", "aaa"),
278+
("apps/web/index.ts", "bbb"),
279+
("apps/web/deleted.ts", "ccc"),
280+
("apps/web-admin/index.ts", "ddd"),
281+
("packages/ui/button.tsx", "eee"),
282+
],
283+
status,
284+
);
285+
286+
// "apps/web" must match apps/web/* but NOT apps/web-admin/*
287+
let (hashes, to_hash) = index.get_package_hashes(&path("apps/web")).unwrap();
288+
assert_eq!(
289+
hashes.len(),
290+
1,
291+
"only index.ts should remain (deleted.ts removed)"
292+
);
293+
assert!(hashes.contains_key(&path("index.ts")));
294+
assert!(!hashes.contains_key(&path("deleted.ts")));
295+
assert_eq!(to_hash, vec![path("apps/web/changed.ts")]);
296+
297+
// "apps/web-admin" should get its own status entries only
298+
let (_, to_hash) = index.get_package_hashes(&path("apps/web-admin")).unwrap();
299+
assert_eq!(to_hash, vec![path("apps/web-admin/added.ts")]);
300+
301+
// empty prefix collects everything
302+
let (_, to_hash) = index.get_package_hashes(&path("")).unwrap();
303+
assert_eq!(to_hash.len(), 5); // all non-delete status entries
304+
}
305+
262306
// Verifies that BTreeMap range queries produce correct results for
263307
// prefix-based package filtering. This captures the exact behavior that
264308
// must be preserved when switching to a sorted Vec with partition_point.

0 commit comments

Comments
 (0)