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