1use std::collections::{HashMap, HashSet};
22use std::path::Path;
23
24use crate::error::{Error, Result};
25use crate::git::sha::ShaResolver;
26use crate::interner::StringInterner;
27use crate::types::{InternedString, VanishedFile};
28
29#[derive(Debug, Default)]
31pub struct VanishedScan {
32 pub vanished: Vec<VanishedFile>,
35 pub commits_walked: u32,
37 pub truncated: bool,
39 pub anomalies: Vec<String>,
41}
42
43enum Terminal {
45 DeletedAt(git2::Oid),
48 RenamedTo(InternedString),
50}
51
52struct PathEvents {
54 added_in_range: bool,
55 terminal: Option<Terminal>,
56}
57
58pub struct VanishedDetector<'a> {
62 repo_path: &'a Path,
63 interner: &'a StringInterner,
64}
65
66impl<'a> VanishedDetector<'a> {
67 pub fn new(repo_path: &'a Path, interner: &'a StringInterner) -> Self {
69 Self {
70 repo_path,
71 interner,
72 }
73 }
74
75 pub fn detect_sync<F: Fn(&str) -> bool>(
83 &self,
84 base_sha: &str,
85 head_sha: &str,
86 matches: F,
87 pathspecs: &[String],
88 max_commits: u32,
89 ) -> Result<VanishedScan> {
90 let mut scan = VanishedScan::default();
91 if base_sha == head_sha {
92 return Ok(scan);
93 }
94
95 let repo = git2::Repository::open(self.repo_path)?;
96 let head_oid = git2::Oid::from_str(head_sha)?;
97 let head_tree = repo.find_commit(head_oid)?.tree()?;
98
99 let base_tree = if base_sha == ShaResolver::empty_tree_sha() {
104 None
105 } else {
106 let base_oid = git2::Oid::from_str(base_sha)?;
107 let base_commit = repo.find_commit(base_oid).map_err(|e| {
108 Error::Git(format!(
109 "base SHA {} not found (shallow clone or force push?): {}",
110 base_sha, e
111 ))
112 })?;
113 Some(base_commit.tree()?)
114 };
115
116 let mut walk = repo.revwalk()?;
117 walk.push(head_oid)?;
118 if base_tree.is_some() {
119 walk.hide(git2::Oid::from_str(base_sha)?)?;
120 }
121 walk.simplify_first_parent()?;
122 walk.set_sorting(git2::Sort::TOPOLOGICAL)?;
123
124 let mut events: HashMap<InternedString, PathEvents> = HashMap::new();
125 let mut deletion_order: HashMap<InternedString, u32> = HashMap::new();
126
127 for oid in walk {
128 if max_commits > 0 && scan.commits_walked >= max_commits {
129 scan.truncated = true;
130 break;
131 }
132 let oid = oid?;
133 scan.commits_walked += 1;
134
135 let commit = repo.find_commit(oid)?;
136 let commit_tree = commit.tree()?;
137 let parent = commit.parent(0).ok();
139 let parent_tree = match &parent {
140 Some(p) => Some(p.tree()?),
141 None => None,
142 };
143
144 let mut opts = git2::DiffOptions::new();
147 opts.ignore_submodules(true);
148 for p in pathspecs {
149 opts.pathspec(p);
150 }
151 let cheap =
152 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))?;
153
154 let needs_precise = cheap.deltas().any(|d| {
155 d.status() == git2::Delta::Deleted
156 && d.old_file()
157 .path()
158 .and_then(|p| p.to_str())
159 .is_some_and(&matches)
160 });
161
162 let parent_oid = parent.as_ref().map(|p| p.id());
168 let mut record = |delta: git2::DiffDelta<'_>| {
169 match delta.status() {
170 git2::Delta::Deleted => {
171 let Some(path) = delta.old_file().path().and_then(|p| p.to_str()) else {
172 return;
173 };
174 if !matches(path) {
175 return;
176 }
177 let key = self.interner.intern(path);
178 let entry = events.entry(key).or_insert(PathEvents {
179 added_in_range: false,
180 terminal: None,
181 });
182 if entry.terminal.is_none() {
183 if let Some(parent_oid) = parent_oid {
184 entry.terminal = Some(Terminal::DeletedAt(parent_oid));
185 deletion_order.insert(key, scan.commits_walked);
186 }
187 }
190 }
191 git2::Delta::Renamed => {
192 let (Some(old), Some(new)) = (
193 delta.old_file().path().and_then(|p| p.to_str()),
194 delta.new_file().path().and_then(|p| p.to_str()),
195 ) else {
196 return;
197 };
198 if !matches(old) {
199 return;
200 }
201 let key = self.interner.intern(old);
202 let entry = events.entry(key).or_insert(PathEvents {
203 added_in_range: false,
204 terminal: None,
205 });
206 if entry.terminal.is_none() {
207 entry.terminal = Some(Terminal::RenamedTo(self.interner.intern(new)));
208 }
209 }
210 git2::Delta::Added => {
211 let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) else {
212 return;
213 };
214 if !matches(path) {
215 return;
216 }
217 let key = self.interner.intern(path);
218 events
219 .entry(key)
220 .or_insert(PathEvents {
221 added_in_range: false,
222 terminal: None,
223 })
224 .added_in_range = true;
225 }
226 _ => {}
227 }
228 };
229
230 if needs_precise {
231 let mut full_opts = git2::DiffOptions::new();
232 full_opts.ignore_submodules(true);
233 let mut diff = repo.diff_tree_to_tree(
234 parent_tree.as_ref(),
235 Some(&commit_tree),
236 Some(&mut full_opts),
237 )?;
238 let mut find = git2::DiffFindOptions::new();
239 find.renames(true);
240 diff.find_similar(Some(&mut find))?;
241 for delta in diff.deltas() {
242 record(delta);
243 }
244 } else {
245 for delta in cheap.deltas() {
246 record(delta);
247 }
248 }
249 }
250
251 for (&path, ev) in &events {
256 if !ev.added_in_range {
257 continue;
258 }
259 let Some(path_str) = self.interner.resolve(path) else {
260 continue;
261 };
262 if head_tree.get_path(Path::new(path_str)).is_ok() {
263 continue; }
265 if let Some(base_tree) = &base_tree {
266 if base_tree.get_path(Path::new(path_str)).is_ok() {
267 continue; }
269 }
270
271 let mut cur = path;
272 let mut seen: HashSet<InternedString> = HashSet::from([path]);
273 loop {
274 match events.get(&cur).and_then(|e| e.terminal.as_ref()) {
275 Some(Terminal::DeletedAt(parent_oid)) => {
276 scan.vanished.push(VanishedFile {
277 path: cur,
278 last_seen_sha: self.interner.intern(&parent_oid.to_string()),
279 });
280 break;
281 }
282 Some(Terminal::RenamedTo(new_path)) => {
283 let Some(new_str) = self.interner.resolve(*new_path) else {
284 break;
285 };
286 if head_tree.get_path(Path::new(new_str)).is_ok() {
287 break; }
289 if !seen.insert(*new_path) {
290 scan.anomalies.push(format!(
291 "rename cycle detected while resolving '{}'",
292 path_str
293 ));
294 break;
295 }
296 cur = *new_path;
297 }
298 None => {
299 scan.anomalies.push(format!(
300 "'{}' was added in range and is absent at head, but no \
301 deletion was observed on the first-parent chain within \
302 the walked window",
303 path_str
304 ));
305 break;
306 }
307 }
308 }
309 }
310
311 scan.vanished.sort_by(|a, b| {
313 let oa = deletion_order.get(&a.path).copied().unwrap_or(u32::MAX);
314 let ob = deletion_order.get(&b.path).copied().unwrap_or(u32::MAX);
315 oa.cmp(&ob).then_with(|| {
316 self.interner
317 .resolve(a.path)
318 .cmp(&self.interner.resolve(b.path))
319 })
320 });
321
322 Ok(scan)
323 }
324}
325
326pub fn pathspec_prefixes(patterns: &[&str]) -> Vec<String> {
332 let mut prefixes: Vec<String> = Vec::new();
333 for pat in patterns {
334 let meta = pat.find(['*', '?', '[', '{']).unwrap_or(pat.len());
335 let literal = &pat[..meta];
336 let Some(slash) = literal.rfind('/') else {
337 return Vec::new(); };
339 let prefix = &literal[..slash];
340 if prefix.is_empty() {
341 return Vec::new();
342 }
343 if !prefixes.iter().any(|p| p == prefix) {
344 prefixes.push(prefix.to_string());
345 }
346 }
347 prefixes
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use std::fs;
354 use std::process::Command;
355 use tempfile::TempDir;
356
357 fn git(dir: &Path, args: &[&str]) -> String {
358 let out = Command::new("git")
359 .args(args)
360 .current_dir(dir)
361 .env("GIT_AUTHOR_NAME", "t")
362 .env("GIT_AUTHOR_EMAIL", "t@t")
363 .env("GIT_COMMITTER_NAME", "t")
364 .env("GIT_COMMITTER_EMAIL", "t@t")
365 .output()
366 .unwrap();
367 assert!(
368 out.status.success(),
369 "git {:?} failed: {}",
370 args,
371 String::from_utf8_lossy(&out.stderr)
372 );
373 String::from_utf8(out.stdout).unwrap().trim().to_string()
374 }
375
376 fn new_repo() -> TempDir {
377 let dir = TempDir::new().unwrap();
378 git(dir.path(), &["init", "-q", "-b", "main"]);
379 dir
380 }
381
382 fn write_and_commit(dir: &Path, files: &[(&str, &str)], msg: &str) -> String {
383 for (path, content) in files {
384 let full = dir.join(path);
385 fs::create_dir_all(full.parent().unwrap()).unwrap();
386 fs::write(full, content).unwrap();
387 }
388 git(dir, &["add", "-A"]);
389 git(dir, &["commit", "-qm", msg]);
390 git(dir, &["rev-parse", "HEAD"])
391 }
392
393 fn rm_and_commit(dir: &Path, paths: &[&str], msg: &str) -> String {
394 for p in paths {
395 git(dir, &["rm", "-rq", p]);
396 }
397 git(dir, &["commit", "-qm", msg]);
398 git(dir, &["rev-parse", "HEAD"])
399 }
400
401 fn matches_stacks(p: &str) -> bool {
402 p.starts_with("stacks/") && p.ends_with("Pulumi.yaml")
403 }
404
405 fn detect(
406 dir: &Path,
407 interner: &StringInterner,
408 base: &str,
409 head: &str,
410 max: u32,
411 ) -> VanishedScan {
412 VanishedDetector::new(dir, interner)
413 .detect_sync(base, head, matches_stacks, &["stacks".to_string()], max)
414 .unwrap()
415 }
416
417 fn resolve<'i>(interner: &'i StringInterner, scan: &VanishedScan) -> Vec<(&'i str, String)> {
418 scan.vanished
419 .iter()
420 .map(|v| {
421 (
422 interner.resolve(v.path).unwrap(),
423 interner.resolve(v.last_seen_sha).unwrap().to_string(),
424 )
425 })
426 .collect()
427 }
428
429 #[test]
430 fn test_basic_vanished() {
431 let dir = new_repo();
432 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
433 let add = write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "name: a")], "add");
434 let head = rm_and_commit(dir.path(), &["stacks/a"], "remove");
435
436 let interner = StringInterner::new();
437 let scan = detect(dir.path(), &interner, &base, &head, 0);
438 let got = resolve(&interner, &scan);
439 assert_eq!(got, vec![("stacks/a/Pulumi.yaml", add)]);
440 assert!(!scan.truncated);
441 assert!(scan.anomalies.is_empty());
442 }
443
444 #[test]
445 fn test_not_vanished_when_alive_at_head() {
446 let dir = new_repo();
447 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
448 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v1")], "add");
449 let head = write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v2")], "modify");
450
451 let interner = StringInterner::new();
452 let scan = detect(dir.path(), &interner, &base, &head, 0);
453 assert!(scan.vanished.is_empty());
454 }
455
456 #[test]
457 fn test_readded_at_head_not_vanished() {
458 let dir = new_repo();
459 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
460 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v1")], "add");
461 rm_and_commit(dir.path(), &["stacks/a"], "remove");
462 let head = write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v2")], "re-add");
463
464 let interner = StringInterner::new();
465 let scan = detect(dir.path(), &interner, &base, &head, 0);
466 assert!(scan.vanished.is_empty());
467 }
468
469 #[test]
470 fn test_add_remove_readd_remove_uses_newest_deletion() {
471 let dir = new_repo();
472 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
473 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v1")], "add1");
474 rm_and_commit(dir.path(), &["stacks/a"], "rm1");
475 let add2 = write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v2")], "add2");
476 let head = rm_and_commit(dir.path(), &["stacks/a"], "rm2");
477
478 let interner = StringInterner::new();
479 let scan = detect(dir.path(), &interner, &base, &head, 0);
480 let got = resolve(&interner, &scan);
481 assert_eq!(got, vec![("stacks/a/Pulumi.yaml", add2)]);
483 }
484
485 #[test]
486 fn test_rename_within_scope_not_vanished() {
487 let dir = new_repo();
488 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
489 write_and_commit(
490 dir.path(),
491 &[("stacks/a/Pulumi.yaml", "same-content")],
492 "add",
493 );
494 git(dir.path(), &["mv", "stacks/a", "stacks/b"]);
495 git(dir.path(), &["commit", "-qm", "rename"]);
496 let head = git(dir.path(), &["rev-parse", "HEAD"]);
497
498 let interner = StringInterner::new();
499 let scan = detect(dir.path(), &interner, &base, &head, 0);
500 assert!(
501 scan.vanished.is_empty(),
502 "renamed-and-alive must not vanish"
503 );
504 }
505
506 #[test]
507 fn test_rename_chain_then_delete() {
508 let dir = new_repo();
509 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
510 write_and_commit(
511 dir.path(),
512 &[("stacks/a/Pulumi.yaml", "same-content")],
513 "add",
514 );
515 git(dir.path(), &["mv", "stacks/a", "stacks/b"]);
516 git(dir.path(), &["commit", "-qm", "rename"]);
517 let renamed = git(dir.path(), &["rev-parse", "HEAD"]);
518 let head = rm_and_commit(dir.path(), &["stacks/b"], "remove");
519
520 let interner = StringInterner::new();
521 let scan = detect(dir.path(), &interner, &base, &head, 0);
522 let got = resolve(&interner, &scan);
523 assert_eq!(got, vec![("stacks/b/Pulumi.yaml", renamed)]);
525 }
526
527 #[test]
528 fn test_rename_out_of_scope_not_vanished() {
529 let dir = new_repo();
530 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
531 write_and_commit(
532 dir.path(),
533 &[("stacks/a/Pulumi.yaml", "same-content")],
534 "add",
535 );
536 fs::create_dir_all(dir.path().join("archive")).unwrap();
537 git(
538 dir.path(),
539 &["mv", "stacks/a/Pulumi.yaml", "archive/Pulumi.yaml"],
540 );
541 git(dir.path(), &["commit", "-qm", "archive"]);
542 let head = git(dir.path(), &["rev-parse", "HEAD"]);
543
544 let interner = StringInterner::new();
545 let scan = detect(dir.path(), &interner, &base, &head, 0);
546 assert!(
547 scan.vanished.is_empty(),
548 "rename out of the glob scope is a rename, not a deletion"
549 );
550 }
551
552 #[test]
553 fn test_existed_at_base_excluded() {
554 let dir = new_repo();
555 let base = write_and_commit(
556 dir.path(),
557 &[("stacks/a/Pulumi.yaml", "v0"), ("README.md", "r")],
558 "base-with-stack",
559 );
560 rm_and_commit(dir.path(), &["stacks/a"], "rm");
561 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "v1")], "re-add");
562 let head = rm_and_commit(dir.path(), &["stacks/a"], "rm-again");
563
564 let interner = StringInterner::new();
565 let scan = detect(dir.path(), &interner, &base, &head, 0);
566 assert!(
567 scan.vanished.is_empty(),
568 "existed at base -> endpoint diff reports Deleted; no double report"
569 );
570 }
571
572 #[test]
573 fn test_first_parent_scoping_ignores_side_branch() {
574 let dir = new_repo();
575 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
576 git(dir.path(), &["checkout", "-qb", "side"]);
578 write_and_commit(dir.path(), &[("stacks/side/Pulumi.yaml", "s")], "side-add");
579 rm_and_commit(dir.path(), &["stacks/side"], "side-rm");
580 git(dir.path(), &["checkout", "-q", "main"]);
581 write_and_commit(dir.path(), &[("mainline.txt", "m")], "mainline");
582 git(
583 dir.path(),
584 &["merge", "-q", "--no-ff", "-m", "merge side", "side"],
585 );
586 let head = git(dir.path(), &["rev-parse", "HEAD"]);
587
588 let interner = StringInterner::new();
589 let scan = detect(dir.path(), &interner, &base, &head, 0);
590 assert!(
591 scan.vanished.is_empty(),
592 "side-branch churn never existed on the first-parent mainline"
593 );
594 assert!(scan.commits_walked <= 3);
596 }
597
598 #[test]
599 fn test_commit_cap_truncation() {
600 let dir = new_repo();
601 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
602 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "a")], "add");
603 for i in 0..4 {
604 write_and_commit(dir.path(), &[("pad.txt", &format!("{i}"))], "pad");
605 }
606 let head = rm_and_commit(dir.path(), &["stacks/a"], "rm");
607
608 let interner = StringInterner::new();
609 let scan = detect(dir.path(), &interner, &base, &head, 2);
610 assert!(scan.truncated);
611 assert!(scan.vanished.is_empty());
614 }
615
616 #[test]
617 fn test_base_equals_head_empty() {
618 let dir = new_repo();
619 let sha = write_and_commit(dir.path(), &[("README.md", "r")], "base");
620 let interner = StringInterner::new();
621 let scan = detect(dir.path(), &interner, &sha, &sha, 0);
622 assert!(scan.vanished.is_empty());
623 assert_eq!(scan.commits_walked, 0);
624 }
625
626 #[test]
627 fn test_empty_tree_base_initial_push() {
628 let dir = new_repo();
629 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "a")], "add");
630 let head = rm_and_commit(dir.path(), &["stacks/a"], "rm");
631
632 let interner = StringInterner::new();
633 let scan = detect(
634 dir.path(),
635 &interner,
636 ShaResolver::empty_tree_sha(),
637 &head,
638 0,
639 );
640 assert_eq!(scan.vanished.len(), 1);
641 }
642
643 #[test]
644 fn test_missing_base_is_error() {
645 let dir = new_repo();
646 let head = write_and_commit(dir.path(), &[("README.md", "r")], "only");
647 let interner = StringInterner::new();
648 let err = VanishedDetector::new(dir.path(), &interner)
649 .detect_sync(
650 "1111111111111111111111111111111111111111",
651 &head,
652 matches_stacks,
653 &[],
654 0,
655 )
656 .unwrap_err();
657 assert!(err.to_string().contains("not found"));
658 }
659
660 #[test]
661 fn test_glob_filtering_ignores_non_matching() {
662 let dir = new_repo();
663 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
664 write_and_commit(dir.path(), &[("docs/temp.md", "d")], "add-doc");
665 git(dir.path(), &["rm", "-q", "docs/temp.md"]);
666 git(dir.path(), &["commit", "-qm", "rm-doc"]);
667 let head = git(dir.path(), &["rev-parse", "HEAD"]);
668
669 let interner = StringInterner::new();
670 let scan = detect(dir.path(), &interner, &base, &head, 0);
671 assert!(scan.vanished.is_empty());
672 }
673
674 #[test]
675 fn test_pathspec_prefixes() {
676 assert_eq!(
677 pathspec_prefixes(&["stacks/**/Pulumi.yaml"]),
678 vec!["stacks".to_string()]
679 );
680 assert_eq!(
681 pathspec_prefixes(&["stacks/**/Pulumi.yaml", "apps/prod/*.yaml"]),
682 vec!["stacks".to_string(), "apps/prod".to_string()]
683 );
684 assert!(pathspec_prefixes(&["*.md"]).is_empty());
686 assert!(pathspec_prefixes(&["stacks/**", "*.md"]).is_empty());
687 assert_eq!(
689 pathspec_prefixes(&["stacks/a/**", "stacks/b/**"]),
690 vec!["stacks/a".to_string(), "stacks/b".to_string()]
691 );
692 }
693
694 #[test]
695 fn test_multiple_vanished_ordered_newest_first() {
696 let dir = new_repo();
697 let base = write_and_commit(dir.path(), &[("README.md", "r")], "base");
698 write_and_commit(dir.path(), &[("stacks/a/Pulumi.yaml", "a")], "add-a");
699 let add_b = write_and_commit(dir.path(), &[("stacks/b/Pulumi.yaml", "b")], "add-b");
700 let rm_a_parent = add_b.clone(); rm_and_commit(dir.path(), &["stacks/a"], "rm-a");
702 let rm_b_parent = git(dir.path(), &["rev-parse", "HEAD"]);
703 let head = rm_and_commit(dir.path(), &["stacks/b"], "rm-b");
704
705 let interner = StringInterner::new();
706 let scan = detect(dir.path(), &interner, &base, &head, 0);
707 let got = resolve(&interner, &scan);
708 assert_eq!(
710 got,
711 vec![
712 ("stacks/b/Pulumi.yaml", rm_b_parent),
713 ("stacks/a/Pulumi.yaml", rm_a_parent),
714 ]
715 );
716 }
717}