1use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Read, Write};
5use std::path::{Component, Path, PathBuf};
6use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
7use std::time::SystemTime;
8
9use fallow_config::WorkspaceInfo;
10
11use crate::{EngineError, EngineResult};
12
13const RAW_MATERIALIZATION_MARKER: &str = "fallow-raw-materialized-v1";
14
15#[derive(Debug, Clone)]
17pub struct ResolvedAuditBase {
18 pub git_ref: String,
20 pub description: Option<String>,
22}
23
24#[derive(Debug)]
26pub struct TemporaryBaseWorktree {
27 repo_root: PathBuf,
28 path: PathBuf,
29}
30
31impl TemporaryBaseWorktree {
32 pub fn create(repo_root: &Path, base_ref: &str) -> EngineResult<Self> {
39 let path = base_worktree_path()?;
40 create_detached_base_worktree(repo_root, &path, base_ref)?;
41 Ok(Self {
42 repo_root: repo_root.to_path_buf(),
43 path,
44 })
45 }
46
47 #[must_use]
49 pub fn path(&self) -> &Path {
50 &self.path
51 }
52}
53
54pub fn create_detached_base_worktree(
68 repo_root: &Path,
69 destination: &Path,
70 base_ref: &str,
71) -> EngineResult<()> {
72 if !destination.is_absolute() {
73 return Err(EngineError::new(format!(
74 "base worktree destination must be absolute: {}",
75 destination.display()
76 )));
77 }
78
79 register_no_checkout_worktree(repo_root, destination, base_ref)?;
80 let result = make_worktree_root_private(destination)
81 .and_then(|()| resolve_registered_commit(destination, base_ref))
82 .and_then(|commit| {
83 populate_worktree_index(destination, &commit)?;
84 materialize_committed_tree(repo_root, destination, &commit)
85 })
86 .and_then(|()| write_raw_materialization_marker(destination));
87 if let Err(error) = result {
88 remove_registered_worktree(repo_root, destination);
89 let _ = fs::remove_dir_all(destination);
90 return Err(error);
91 }
92 Ok(())
93}
94
95#[cfg(unix)]
96fn make_worktree_root_private(destination: &Path) -> EngineResult<()> {
97 use std::os::unix::fs::PermissionsExt as _;
98
99 fs::set_permissions(destination, fs::Permissions::from_mode(0o700)).map_err(|error| {
100 EngineError::new(format!(
101 "could not make base worktree private at `{}`: {error}",
102 destination.display()
103 ))
104 })
105}
106
107#[cfg(not(unix))]
108#[expect(
109 clippy::unnecessary_wraps,
110 reason = "shared cross-platform signature; Unix applies privacy permissions"
111)]
112fn make_worktree_root_private(_destination: &Path) -> EngineResult<()> {
113 Ok(())
114}
115
116#[must_use]
122pub fn detached_base_worktree_is_raw_materialized(worktree_root: &Path) -> bool {
123 raw_materialization_marker_path(worktree_root).is_ok_and(|path| path.is_file())
124}
125
126fn write_raw_materialization_marker(worktree_root: &Path) -> EngineResult<()> {
127 let marker = raw_materialization_marker_path(worktree_root)?;
128 fs::write(&marker, b"raw-v1\n").map_err(|error| {
129 EngineError::new(format!(
130 "could not record raw base-worktree materialization at `{}`: {error}",
131 marker.display()
132 ))
133 })
134}
135
136fn raw_materialization_marker_path(worktree_root: &Path) -> EngineResult<PathBuf> {
137 let marker = run_git(
138 worktree_root,
139 &["rev-parse", "--git-path", RAW_MATERIALIZATION_MARKER],
140 )
141 .ok_or_else(|| EngineError::new("could not resolve base-worktree materialization marker"))?;
142 let marker = PathBuf::from(marker.trim());
143 if marker.is_absolute() {
144 Ok(marker)
145 } else {
146 Ok(worktree_root.join(marker))
147 }
148}
149
150fn register_no_checkout_worktree(
151 repo_root: &Path,
152 destination: &Path,
153 base_ref: &str,
154) -> EngineResult<()> {
155 let mut command = git_command(repo_root);
156 command.args([
157 "worktree",
158 "add",
159 "--detach",
160 "--quiet",
161 "--no-checkout",
162 "--",
163 ]);
164 command.arg(destination).arg(base_ref);
165 let output = command.output().map_err(|error| {
166 EngineError::new(format!(
167 "could not create a temporary worktree for base ref `{base_ref}`: {error}"
168 ))
169 })?;
170 if !output.status.success() {
171 return Err(EngineError::new(format!(
172 "could not create a temporary worktree for base ref `{base_ref}`: {}",
173 String::from_utf8_lossy(&output.stderr).trim()
174 )));
175 }
176 Ok(())
177}
178
179fn resolve_registered_commit(destination: &Path, base_ref: &str) -> EngineResult<String> {
180 run_git(destination, &["rev-parse", "--verify", "HEAD^{commit}"])
181 .map(|commit| commit.trim().to_owned())
182 .ok_or_else(|| {
183 EngineError::new(format!(
184 "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
185 ))
186 })
187}
188
189fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
190 let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
191 let output = git_command(destination)
192 .env("GIT_CONFIG_COUNT", "2")
193 .env("GIT_CONFIG_KEY_0", "core.hooksPath")
194 .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
195 .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
196 .env("GIT_CONFIG_VALUE_1", "false")
197 .args(["read-tree", "--reset", commit])
198 .output()
199 .map_err(|error| {
200 EngineError::new(format!("could not populate base worktree index: {error}"))
201 })?;
202 if !output.status.success() {
203 return Err(EngineError::new(format!(
204 "could not populate base worktree index: {}",
205 String::from_utf8_lossy(&output.stderr).trim()
206 )));
207 }
208 Ok(())
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum TreeEntryKind {
213 Regular,
214 Executable,
215 Symlink,
216 Gitlink,
217}
218
219#[derive(Debug)]
220struct TreeEntry {
221 kind: TreeEntryKind,
222 object_id: String,
223 path: PathBuf,
224}
225
226fn materialize_committed_tree(
227 repo_root: &Path,
228 destination: &Path,
229 commit: &str,
230) -> EngineResult<()> {
231 let entries = committed_tree_entries(repo_root, commit)?;
232 let mut blobs = BatchBlobReader::spawn(repo_root)?;
233 let mut symlinks = Vec::new();
234
235 for entry in entries {
236 create_safe_parent_directories(destination, &entry.path)?;
237 let output_path = destination.join(&entry.path);
238 match entry.kind {
239 TreeEntryKind::Regular | TreeEntryKind::Executable => {
240 let mut file = OpenOptions::new()
241 .create_new(true)
242 .write(true)
243 .open(&output_path)
244 .map_err(|error| materialization_error(&entry.path, error))?;
245 blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
246 set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
247 }
248 TreeEntryKind::Symlink => {
249 let target = blobs.read_blob(&entry.object_id, &entry.path)?;
250 symlinks.push((entry.path, target));
251 }
252 TreeEntryKind::Gitlink => {
253 fs::create_dir(&output_path)
254 .map_err(|error| materialization_error(&entry.path, error))?;
255 }
256 }
257 }
258
259 blobs.finish()?;
260 for (path, target) in symlinks {
261 create_safe_parent_directories(destination, &path)?;
262 create_materialized_symlink(&destination.join(&path), &target)
263 .map_err(|error| materialization_error(&path, error))?;
264 }
265 Ok(())
266}
267
268fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
269 let output = git_command(repo_root)
270 .args(["ls-tree", "-r", "-z", "--full-tree", commit])
271 .output()
272 .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
273 if !output.status.success() {
274 return Err(EngineError::new(format!(
275 "could not read base commit tree: {}",
276 String::from_utf8_lossy(&output.stderr).trim()
277 )));
278 }
279
280 output
281 .stdout
282 .split(|byte| *byte == 0)
283 .filter(|record| !record.is_empty())
284 .map(parse_tree_entry)
285 .collect()
286}
287
288fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
289 let tab = record
290 .iter()
291 .position(|byte| *byte == b'\t')
292 .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
293 let header = std::str::from_utf8(&record[..tab])
294 .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
295 let mut fields = header.split_ascii_whitespace();
296 let mode = fields.next().unwrap_or_default();
297 let object_type = fields.next().unwrap_or_default();
298 let object_id = fields.next().unwrap_or_default();
299 if fields.next().is_some() || object_id.is_empty() {
300 return Err(EngineError::new(format!(
301 "could not parse Git tree header `{header}`"
302 )));
303 }
304 let kind = match (mode, object_type) {
305 ("100644", "blob") => TreeEntryKind::Regular,
306 ("100755", "blob") => TreeEntryKind::Executable,
307 ("120000", "blob") => TreeEntryKind::Symlink,
308 ("160000", "commit") => TreeEntryKind::Gitlink,
309 _ => {
310 return Err(EngineError::new(format!(
311 "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
312 )));
313 }
314 };
315 let path = git_path_from_bytes(&record[tab + 1..])?;
316 validate_materialized_path(&path)?;
317 Ok(TreeEntry {
318 kind,
319 object_id: object_id.to_owned(),
320 path,
321 })
322}
323
324#[cfg(unix)]
325#[expect(
326 clippy::unnecessary_wraps,
327 reason = "shared cross-platform signature; non-Unix path decoding is fallible"
328)]
329fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
330 use std::os::unix::ffi::OsStringExt as _;
331
332 Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
333}
334
335#[cfg(not(unix))]
336fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
337 String::from_utf8(bytes.to_vec())
338 .map(PathBuf::from)
339 .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
340}
341
342fn validate_materialized_path(path: &Path) -> EngineResult<()> {
343 if path.as_os_str().is_empty() || path.is_absolute() {
344 return Err(unsafe_tree_path(path));
345 }
346
347 let mut saw_component = false;
348 for component in path.components() {
349 let Component::Normal(segment) = component else {
350 return Err(unsafe_tree_path(path));
351 };
352 saw_component = true;
353 if segment.to_str().is_some_and(is_git_admin_alias) {
354 return Err(unsafe_tree_path(path));
355 }
356 }
357 if !saw_component {
358 return Err(unsafe_tree_path(path));
359 }
360 Ok(())
361}
362
363fn is_git_admin_alias(segment: &str) -> bool {
364 let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
365 normalized == ".git" || normalized == "git~1"
366}
367
368fn unsafe_tree_path(path: &Path) -> EngineError {
369 EngineError::new(format!(
370 "refusing to materialize unsafe Git tree path `{}`",
371 path.display()
372 ))
373}
374
375fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
376 let Some(parent) = relative.parent() else {
377 return Ok(());
378 };
379 let mut current = root.to_path_buf();
380 for component in parent.components() {
381 let Component::Normal(segment) = component else {
382 return Err(unsafe_tree_path(relative));
383 };
384 current.push(segment);
385 match fs::symlink_metadata(¤t) {
386 Ok(metadata) if metadata.file_type().is_dir() => {}
387 Ok(_) => {
388 return Err(EngineError::new(format!(
389 "refusing to materialize through non-directory path `{}`",
390 current.display()
391 )));
392 }
393 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
394 fs::create_dir(¤t).map_err(|error| materialization_error(relative, error))?;
395 }
396 Err(error) => return Err(materialization_error(relative, error)),
397 }
398 }
399 Ok(())
400}
401
402fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
403 EngineError::new(format!(
404 "could not materialize base commit path `{}`: {error}",
405 path.display()
406 ))
407}
408
409#[cfg(unix)]
410fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
411 use std::os::unix::fs::PermissionsExt as _;
412
413 let mode = if executable { 0o755 } else { 0o644 };
414 let permissions = fs::Permissions::from_mode(mode);
415 fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
416}
417
418#[cfg(not(unix))]
419#[expect(
420 clippy::unnecessary_wraps,
421 reason = "shared cross-platform signature; Unix permission updates are fallible"
422)]
423fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
424 Ok(())
425}
426
427#[cfg(unix)]
428fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
429 use std::os::unix::ffi::OsStringExt as _;
430
431 std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
432}
433
434#[cfg(windows)]
435fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
436 let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
437 let resolved_target = path
438 .parent()
439 .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
440 let result = if resolved_target.is_dir() {
441 std::os::windows::fs::symlink_dir(&target_path, path)
442 } else {
443 std::os::windows::fs::symlink_file(&target_path, path)
444 };
445 result.or_else(|_| fs::write(path, target))
446}
447
448#[cfg(not(any(unix, windows)))]
449fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
450 fs::write(path, target)
451}
452
453struct BatchBlobReader {
454 child: Option<Child>,
455 stdin: Option<ChildStdin>,
456 stdout: BufReader<ChildStdout>,
457}
458
459impl BatchBlobReader {
460 fn spawn(repo_root: &Path) -> EngineResult<Self> {
461 let mut command = git_command(repo_root);
462 command
463 .args(["cat-file", "--batch"])
464 .stdin(Stdio::piped())
465 .stdout(Stdio::piped())
466 .stderr(Stdio::null());
467 let mut child = command.spawn().map_err(|error| {
468 EngineError::new(format!("could not start Git object reader: {error}"))
469 })?;
470 let stdin = child
471 .stdin
472 .take()
473 .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
474 let stdout = child
475 .stdout
476 .take()
477 .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
478 Ok(Self {
479 child: Some(child),
480 stdin: Some(stdin),
481 stdout: BufReader::new(stdout),
482 })
483 }
484
485 fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
486 let size = self.request_blob(object_id, path)?;
487 let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
488 .map_err(|error| materialization_error(path, error))?;
489 if copied != size {
490 return Err(EngineError::new(format!(
491 "Git object reader returned {copied} of {size} bytes for `{}`",
492 path.display()
493 )));
494 }
495 self.consume_blob_terminator(path)
496 }
497
498 fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
499 let size = self.request_blob(object_id, path)?;
500 let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
501 let mut bytes = vec![0; size];
502 self.stdout
503 .read_exact(&mut bytes)
504 .map_err(|error| materialization_error(path, error))?;
505 self.consume_blob_terminator(path)?;
506 Ok(bytes)
507 }
508
509 fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
510 let stdin = self
511 .stdin
512 .as_mut()
513 .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
514 writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
515 stdin
516 .flush()
517 .map_err(|error| materialization_error(path, error))?;
518
519 let mut header = Vec::new();
520 self.stdout
521 .read_until(b'\n', &mut header)
522 .map_err(|error| materialization_error(path, error))?;
523 let header = std::str::from_utf8(&header)
524 .map_err(|error| materialization_error(path, error))?
525 .trim_end();
526 let mut fields = header.split_ascii_whitespace();
527 let returned_id = fields.next().unwrap_or_default();
528 let object_type = fields.next().unwrap_or_default();
529 let size = fields.next().unwrap_or_default();
530 if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
531 return Err(EngineError::new(format!(
532 "unexpected Git object response `{header}` for `{}`",
533 path.display()
534 )));
535 }
536 size.parse::<u64>()
537 .map_err(|error| materialization_error(path, error))
538 }
539
540 fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
541 let mut terminator = [0; 1];
542 self.stdout
543 .read_exact(&mut terminator)
544 .map_err(|error| materialization_error(path, error))?;
545 if terminator != [b'\n'] {
546 return Err(EngineError::new(format!(
547 "Git object response for `{}` had no terminator",
548 path.display()
549 )));
550 }
551 Ok(())
552 }
553
554 fn finish(mut self) -> EngineResult<()> {
555 self.stdin.take();
556 let status = self
557 .child
558 .take()
559 .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
560 .wait()
561 .map_err(|error| {
562 EngineError::new(format!("could not wait for Git object reader: {error}"))
563 })?;
564 if !status.success() {
565 return Err(EngineError::new(format!(
566 "Git object reader exited with status {status}"
567 )));
568 }
569 Ok(())
570 }
571}
572
573impl Drop for BatchBlobReader {
574 fn drop(&mut self) {
575 self.stdin.take();
576 if let Some(mut child) = self.child.take() {
577 let _ = child.kill();
578 let _ = child.wait();
579 }
580 }
581}
582
583fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
584 let _ = git_command(repo_root)
585 .args(["worktree", "remove", "--force"])
586 .arg(destination)
587 .output();
588}
589
590impl Drop for TemporaryBaseWorktree {
591 fn drop(&mut self) {
592 let mut command = git_command(&self.repo_root);
593 command
594 .arg("worktree")
595 .arg("remove")
596 .arg("--force")
597 .arg(&self.path);
598 let _ = command.output();
599 let _ = std::fs::remove_dir_all(&self.path);
600 }
601}
602
603#[must_use]
605pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
606 let Some(git_root) = git_toplevel(current_root) else {
607 return base_worktree_root.to_path_buf();
608 };
609 let current_root =
610 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
611 match current_root.strip_prefix(&git_root) {
612 Ok(relative) => base_worktree_root.join(relative),
613 Err(_) => base_worktree_root.to_path_buf(),
614 }
615}
616
617#[must_use]
619pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
620 if let Some(upstream) = git_upstream_ref(root) {
621 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
622 return Some(ResolvedAuditBase {
623 git_ref: sha,
624 description: Some(format!("merge-base with {upstream}")),
625 });
626 }
627 return Some(ResolvedAuditBase {
628 description: Some(format!("{upstream} (tip)")),
629 git_ref: upstream,
630 });
631 }
632
633 if let Some(remote_ref) = detect_remote_default_ref(root) {
634 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
635 return Some(ResolvedAuditBase {
636 git_ref: sha,
637 description: Some(format!("merge-base with {remote_ref}")),
638 });
639 }
640 return Some(ResolvedAuditBase {
641 description: Some(format!("{remote_ref} (tip)")),
642 git_ref: remote_ref,
643 });
644 }
645
646 for candidate in ["main", "master"] {
647 if git_ref_exists(root, candidate) {
648 return Some(ResolvedAuditBase {
649 git_ref: candidate.to_string(),
650 description: Some(format!("local {candidate}")),
651 });
652 }
653 }
654
655 None
656}
657
658#[must_use]
660pub fn short_head_sha(root: &Path) -> Option<String> {
661 run_git(root, &["rev-parse", "--short", "HEAD"])
662}
663
664#[must_use]
669pub fn default_workspace_ref(root: &Path) -> Option<String> {
670 let workspaces = crate::discover::discover_workspace_packages(root);
671 default_workspace_ref_for_workspaces(root, &workspaces)
672}
673
674#[must_use]
676pub fn default_workspace_ref_for_workspaces(
677 root: &Path,
678 workspaces: &[WorkspaceInfo],
679) -> Option<String> {
680 if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
681 return None;
682 }
683 if let Some(reference) = run_git(
684 root,
685 &[
686 "symbolic-ref",
687 "--quiet",
688 "--short",
689 "refs/remotes/origin/HEAD",
690 ],
691 ) {
692 let reference = reference.trim();
693 if !reference.is_empty() {
694 return Some(reference.to_owned());
695 }
696 }
697 ["origin/main", "origin/master"]
698 .into_iter()
699 .find(|candidate| git_ref_exists(root, candidate))
700 .map(str::to_owned)
701}
702
703#[must_use]
708pub fn current_user_identities(root: &Path) -> Vec<String> {
709 let mut ids = Vec::new();
710 if let Some(email) = read_git_config(root, "user.email") {
711 if let Some((local, _)) = email.split_once('@') {
712 ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
713 }
714 ids.push(email);
715 }
716 if let Some(name) = read_git_config(root, "user.name") {
717 ids.push(name);
718 }
719 ids
720}
721
722fn read_git_config(root: &Path, key: &str) -> Option<String> {
723 let value = run_git(root, &["config", "--get", key])?;
724 let trimmed = value.trim();
725 (!trimmed.is_empty()).then(|| trimmed.to_owned())
726}
727
728fn git_ref_exists(root: &Path, reference: &str) -> bool {
729 run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
730}
731
732fn git_toplevel(root: &Path) -> Option<PathBuf> {
733 run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
734}
735
736fn git_upstream_ref(root: &Path) -> Option<String> {
737 run_git(
738 root,
739 &[
740 "rev-parse",
741 "--abbrev-ref",
742 "--symbolic-full-name",
743 "@{upstream}",
744 ],
745 )
746}
747
748fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
749 run_git(root, &["merge-base", a, b])
750}
751
752fn detect_remote_default_ref(root: &Path) -> Option<String> {
753 if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
754 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
755 {
756 return Some(format!("origin/{branch}"));
757 }
758 ["origin/main", "origin/master"]
759 .into_iter()
760 .find(|candidate| git_ref_exists(root, candidate))
761 .map(str::to_string)
762}
763
764fn base_worktree_path() -> EngineResult<PathBuf> {
765 let nanos = SystemTime::now()
766 .duration_since(SystemTime::UNIX_EPOCH)
767 .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
768 .as_nanos();
769 Ok(std::env::temp_dir().join(format!("fallow-audit-base-{}-{nanos}", std::process::id())))
770}
771
772#[expect(
773 clippy::disallowed_methods,
774 reason = "canonical engine-owned git spawn wrapper for repository refs"
775)]
776fn git_command(root: &Path) -> Command {
777 let mut command = Command::new("git");
778 crate::changed_files::clear_ambient_git_env(&mut command);
779 command.arg("-C").arg(root);
780 command
781}
782
783fn run_git(root: &Path, args: &[&str]) -> Option<String> {
784 let output = git_command(root).args(args).output().ok()?;
785 if !output.status.success() {
786 return None;
787 }
788 String::from_utf8(output.stdout).ok()
789}
790
791#[cfg(test)]
792mod tests {
793 use std::fs;
794 use std::path::PathBuf;
795 use std::process::Command;
796
797 use super::*;
798
799 fn git(root: &Path, args: &[&str]) -> String {
800 let output = Command::new("git")
801 .args(args)
802 .current_dir(root)
803 .env_remove("GIT_DIR")
804 .env_remove("GIT_WORK_TREE")
805 .output()
806 .expect("git command starts");
807 assert!(
808 output.status.success(),
809 "git {args:?} failed: {}",
810 String::from_utf8_lossy(&output.stderr)
811 );
812 String::from_utf8_lossy(&output.stdout).trim().to_owned()
813 }
814
815 fn init_repo(root: &Path) {
816 fs::create_dir_all(root).expect("create repo");
817 git(root, &["init", "-b", "main"]);
818 git(root, &["config", "user.name", "Test User"]);
819 git(root, &["config", "user.email", "test@example.com"]);
820 git(root, &["config", "commit.gpgsign", "false"]);
821 }
822
823 fn commit_all(root: &Path, message: &str) {
824 git(root, &["add", "."]);
825 git(root, &["commit", "-m", message]);
826 }
827
828 #[cfg(unix)]
829 fn write_executable(path: &Path, source: &str) {
830 use std::os::unix::fs::PermissionsExt as _;
831
832 fs::write(path, source).expect("write executable");
833 let mut permissions = fs::metadata(path)
834 .expect("executable metadata")
835 .permissions();
836 permissions.set_mode(0o755);
837 fs::set_permissions(path, permissions).expect("set executable mode");
838 }
839
840 #[test]
841 fn default_workspace_ref_skips_projects_without_workspaces() {
842 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
843 }
844
845 #[test]
846 fn default_workspace_ref_skips_non_git_workspace_projects() {
847 let workspace = WorkspaceInfo {
848 root: PathBuf::from("/repo/packages/app"),
849 name: "app".to_owned(),
850 is_internal_dependency: false,
851 };
852
853 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
854 }
855
856 #[test]
857 fn current_user_identities_empty_when_git_config_is_unavailable() {
858 assert!(current_user_identities(Path::new("/repo")).is_empty());
859 }
860
861 #[cfg(unix)]
862 #[test]
863 fn temporary_base_worktree_does_not_run_post_checkout_hook() {
864 let temp = tempfile::tempdir().expect("temp dir");
865 let repo = temp.path().join("repo");
866 init_repo(&repo);
867 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
868 commit_all(&repo, "initial");
869
870 let sentinel = temp.path().join("post-checkout-ran");
871 write_executable(
872 &repo.join(".git/hooks/post-checkout"),
873 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
874 );
875
876 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
877 .expect("temporary worktree should be created");
878
879 assert_eq!(
880 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
881 "committed\n"
882 );
883 assert!(
884 !sentinel.exists(),
885 "creating a base view must not execute post-checkout hooks"
886 );
887 }
888
889 #[cfg(unix)]
890 #[test]
891 fn temporary_base_worktree_does_not_run_post_index_change_hook() {
892 let temp = tempfile::tempdir().expect("temp dir");
893 let repo = temp.path().join("repo");
894 init_repo(&repo);
895 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
896 commit_all(&repo, "initial");
897
898 let sentinel = temp.path().join("post-index-change-ran");
899 write_executable(
900 &repo.join(".git/hooks/post-index-change"),
901 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
902 );
903
904 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
905 .expect("temporary worktree should be created");
906
907 assert_eq!(
908 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
909 "committed\n"
910 );
911 assert!(
912 !sentinel.exists(),
913 "creating a base view must not execute post-index-change hooks"
914 );
915 }
916
917 #[cfg(unix)]
918 #[test]
919 fn temporary_base_worktree_does_not_run_smudge_filter() {
920 let temp = tempfile::tempdir().expect("temp dir");
921 let repo = temp.path().join("repo");
922 init_repo(&repo);
923 fs::write(
924 repo.join(".gitattributes"),
925 "filtered.txt filter=sentinel\n",
926 )
927 .expect("write attributes");
928 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
929 commit_all(&repo, "initial");
930
931 let sentinel = temp.path().join("smudge-ran");
932 let filter = temp.path().join("smudge-filter.sh");
933 write_executable(
934 &filter,
935 &format!(
936 "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
937 sentinel.display()
938 ),
939 );
940 git(
941 &repo,
942 &[
943 "config",
944 "filter.sentinel.smudge",
945 filter.to_str().expect("filter path is UTF-8"),
946 ],
947 );
948
949 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
950 .expect("temporary worktree should be created");
951
952 assert_eq!(
953 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
954 b"committed raw bytes\n"
955 );
956 assert!(
957 !sentinel.exists(),
958 "creating a base view must not execute smudge filters"
959 );
960 }
961
962 #[cfg(unix)]
963 #[test]
964 fn temporary_base_worktree_does_not_start_process_filter() {
965 let temp = tempfile::tempdir().expect("temp dir");
966 let repo = temp.path().join("repo");
967 init_repo(&repo);
968 fs::write(
969 repo.join(".gitattributes"),
970 "filtered.txt filter=sentinel\n",
971 )
972 .expect("write attributes");
973 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
974 commit_all(&repo, "initial");
975
976 let sentinel = temp.path().join("process-filter-ran");
977 let filter = temp.path().join("process-filter.sh");
978 write_executable(
979 &filter,
980 &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
981 );
982 git(
983 &repo,
984 &[
985 "config",
986 "filter.sentinel.process",
987 filter.to_str().expect("filter path is UTF-8"),
988 ],
989 );
990
991 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
992 .expect("temporary worktree should be created");
993
994 assert_eq!(
995 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
996 b"committed raw bytes\n"
997 );
998 assert!(
999 !sentinel.exists(),
1000 "creating a base view must not start process filters"
1001 );
1002 }
1003
1004 #[test]
1005 fn failed_registration_does_not_remove_existing_worktree() {
1006 let temp = tempfile::tempdir().expect("temp dir");
1007 let repo = temp.path().join("repo");
1008 init_repo(&repo);
1009 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1010 commit_all(&repo, "initial");
1011 let destination = temp.path().join("base");
1012
1013 create_detached_base_worktree(&repo, &destination, "HEAD")
1014 .expect("first worktree should be created");
1015 let second = create_detached_base_worktree(&repo, &destination, "HEAD");
1016
1017 assert!(second.is_err(), "duplicate destination must fail");
1018 assert!(
1019 destination.join("tracked.txt").is_file(),
1020 "failed registration must not remove the existing worktree"
1021 );
1022 assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1023
1024 remove_registered_worktree(&repo, &destination);
1025 let _ = fs::remove_dir_all(destination);
1026 }
1027
1028 #[cfg(unix)]
1029 #[test]
1030 fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1031 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1032
1033 let temp = tempfile::tempdir().expect("temp dir");
1034 let repo = temp.path().join("repo");
1035 init_repo(&repo);
1036 fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1037 let executable = repo.join("run.sh");
1038 fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1039 let mut permissions = fs::metadata(&executable)
1040 .expect("executable metadata")
1041 .permissions();
1042 permissions.set_mode(0o755);
1043 fs::set_permissions(&executable, permissions).expect("set executable mode");
1044 std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1045 .expect("create symlink");
1046 commit_all(&repo, "files");
1047
1048 let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1049 git(
1050 &repo,
1051 &[
1052 "update-index",
1053 "--add",
1054 "--cacheinfo",
1055 &format!("160000,{gitlink_commit},vendor/submodule"),
1056 ],
1057 );
1058 git(&repo, &["commit", "-m", "gitlink"]);
1059
1060 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1061 .expect("temporary worktree should be created");
1062 let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1063 .expect("regular metadata")
1064 .mode();
1065 let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1066 .expect("executable metadata")
1067 .mode();
1068
1069 assert_eq!(regular_mode & 0o111, 0);
1070 assert_ne!(executable_mode & 0o111, 0);
1071 assert_eq!(
1072 fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1073 PathBuf::from("regular.txt")
1074 );
1075 let gitlink = worktree.path().join("vendor/submodule");
1076 assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1077 assert!(
1078 fs::read_dir(gitlink)
1079 .expect("read gitlink directory")
1080 .next()
1081 .is_none(),
1082 "an uninitialized gitlink directory must remain empty"
1083 );
1084 assert!(
1085 git(
1086 worktree.path(),
1087 &["ls-files", "--stage", "vendor/submodule"]
1088 )
1089 .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1090 "the linked worktree index must retain the gitlink object id"
1091 );
1092
1093 let path = worktree.path().to_path_buf();
1094 drop(worktree);
1095 assert!(!path.exists(), "temporary worktree must clean up on drop");
1096 }
1097
1098 #[test]
1099 fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1100 for path in [
1101 Path::new("../escape"),
1102 Path::new("/absolute"),
1103 Path::new(".git/config"),
1104 Path::new("nested/.GIT/config"),
1105 Path::new("nested/.git. /config"),
1106 Path::new("nested/git~1/config"),
1107 ] {
1108 assert!(
1109 validate_materialized_path(path).is_err(),
1110 "unsafe path should be rejected: {}",
1111 path.display()
1112 );
1113 }
1114 assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1115 }
1116
1117 #[cfg(unix)]
1118 #[test]
1119 fn parent_directory_creation_refuses_symlink_traversal() {
1120 let temp = tempfile::tempdir().expect("temp dir");
1121 let root = temp.path().join("root");
1122 let outside = temp.path().join("outside");
1123 fs::create_dir(&root).expect("create root");
1124 fs::create_dir(&outside).expect("create outside");
1125 std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1126
1127 let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1128
1129 assert!(result.is_err(), "symlink parent must be rejected");
1130 assert!(!outside.join("escaped.txt").exists());
1131 }
1132}