Skip to main content

lechange_core/git/
vanished.rs

1//! Vanished-file detection: first-parent history walk over base..head.
2//!
3//! The endpoint diff (`diff_tree_to_tree(base, head)`) cannot see a file that
4//! was added in some commit of a PR and removed again before head — it exists
5//! in neither endpoint tree. This walker finds those files and records the
6//! last commit where each existed, so consumers can reconstruct content
7//! (e.g. to destroy infrastructure the file defined).
8//!
9//! Scoping guarantee: the revwalk pushes head, hides base, and simplifies to
10//! first parents — only commits introduced by this base..head range are ever
11//! visited, never another branch's work. Side-branch-internal churn (a file
12//! added and deleted entirely within a merged side branch) is invisible by
13//! design: it never existed on the mainline.
14//!
15//! Concurrency: the walk is single-threaded by necessity — `git2::Repository`,
16//! `Tree`, and `Diff` are `!Send`, and after pathspec filtering the per-commit
17//! delta sets are tiny (usually empty), so shipping paths across a rayon
18//! boundary would cost more than the glob checks it saves. The feature is
19//! zero-cost when disabled: one bool branch in the pipeline.
20
21use 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/// Result of a vanished-file scan
30#[derive(Debug, Default)]
31pub struct VanishedScan {
32    /// Vanished files, ordered newest-deletion-first (the first entry's
33    /// `last_seen_sha` is the natural group-level reconstruction commit)
34    pub vanished: Vec<VanishedFile>,
35    /// Commits visited on the first-parent chain
36    pub commits_walked: u32,
37    /// True when the vanished_max_commits cap was hit (results may be partial)
38    pub truncated: bool,
39    /// Soft anomalies (e.g. rename cycle) — caller maps these to diagnostics
40    pub anomalies: Vec<String>,
41}
42
43/// Terminal event observed for a path while walking newest -> oldest
44enum Terminal {
45    /// Deleted; payload = first parent of the deleting commit (last commit
46    /// where the file existed)
47    DeletedAt(git2::Oid),
48    /// Renamed away to another path
49    RenamedTo(InternedString),
50}
51
52/// Per-path event record (newest event wins — the walk is newest-first)
53struct PathEvents {
54    added_in_range: bool,
55    terminal: Option<Terminal>,
56}
57
58/// First-parent history walker detecting files added then removed within
59/// base..head. Holds the repo path (git2 objects are `!Send`) and opens the
60/// repository once per `detect_sync` call, mirroring `ShaResolver`.
61pub struct VanishedDetector<'a> {
62    repo_path: &'a Path,
63    interner: &'a StringInterner,
64}
65
66impl<'a> VanishedDetector<'a> {
67    /// Create a new detector rooted at the repository path
68    pub fn new(repo_path: &'a Path, interner: &'a StringInterner) -> Self {
69        Self {
70            repo_path,
71            interner,
72        }
73    }
74
75    /// Walk base..head (first-parent) and report files that were added in the
76    /// range, match `matches`, and no longer exist in the head tree.
77    ///
78    /// `matches` is a statically-dispatched predicate over repo-relative
79    /// paths. `pathspecs` are coarse literal prefixes (see
80    /// [`pathspec_prefixes`]) used to keep the common per-commit diff cheap;
81    /// correctness never depends on them. `max_commits` of 0 means unlimited.
82    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        // Initial-push base (the empty tree SHA) has no commit to hide; the
100        // walk then covers head's whole first-parent history, bounded by the
101        // cap. A missing/unreachable base (shallow clone, force push) is a
102        // hard error here — the caller soft-fails it into a diagnostic.
103        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            // Root commit diffs against the empty tree
138            let parent = commit.parent(0).ok();
139            let parent_tree = match &parent {
140                Some(p) => Some(p.tree()?),
141                None => None,
142            };
143
144            // Cheap pass: pathspec-filtered. Commits touching nothing under
145            // the prefixes produce an empty delta set for near-zero cost.
146            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            // Precise pass only when a matching deletion appeared: re-diff
163            // WITHOUT pathspec and with rename detection, so a delete that is
164            // actually a rename (possibly out of the prefixes) is classified
165            // Renamed — pathspec filtering would have hidden the Add side.
166            // Static dispatch: one closure applied to whichever diff is live.
167            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                            // A deletion in a root commit is impossible
188                            // (nothing existed before it) — ignore.
189                        }
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        // Resolution: a path is vanished when it was added in-range, is absent
252        // from the head tree, did not exist at base (the endpoint diff already
253        // reports those as Deleted), and its newest terminal event — following
254        // rename chains — is a deletion.
255        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; // alive at head (re-added or never really gone)
264            }
265            if let Some(base_tree) = &base_tree {
266                if base_tree.get_path(Path::new(path_str)).is_ok() {
267                    continue; // existed at base -> endpoint Deleted covers it
268                }
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; // renamed and alive at head -> not vanished
288                        }
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        // Deterministic order: newest deletion first, path as tie-break.
312        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
326/// Derive coarse literal pathspec prefixes from glob patterns: the substring
327/// up to the first glob metacharacter (`*?[{`), truncated at the last `/`.
328/// A pattern with no derivable prefix yields no entry; if ANY pattern lacks a
329/// prefix the result is empty (meaning: diff everything — pathspec filtering
330/// is an optimization, never a correctness gate).
331pub 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(); // pattern can match at repo root: no filter
338        };
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        // last_seen must be add2 (parent of the NEWEST deletion), content v2
482        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        // vanishes under its FINAL name, reconstructable at the rename commit
524        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        // side branch adds AND deletes a matching file, then merges
577        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        // mainline: merge + mainline + base-excluded => walked visits merge and mainline commits only
595        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        // The deletion IS seen (newest commits first) but the Add is beyond
612        // the cap -> no vanished entry, plus no anomaly (added_in_range false)
613        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        // A rootless pattern disables filtering entirely (correctness first)
685        assert!(pathspec_prefixes(&["*.md"]).is_empty());
686        assert!(pathspec_prefixes(&["stacks/**", "*.md"]).is_empty());
687        // Dedup
688        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(); // a deleted next -> parent is add-b commit
701        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        // b deleted last (newest) -> first in the list
709        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}