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