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//! Determinism guarantee: detection is purely path-based. Renames are NOT
16//! detected — `git mv a b` is a delete of `a` plus an add of `b`, and each path
17//! is judged on its own exact repo-relative string. There is no content-
18//! similarity heuristic, so the result depends only on which paths were added
19//! and removed, never on how alike two files happen to be. This is essential
20//! for path-keyed deployment: a stack whose file is gone at head must have its
21//! path's state destroyed even if a lookalike stack was added elsewhere in the
22//! same range.
23//!
24//! Concurrency: the walk is single-threaded by necessity — `git2::Repository`,
25//! `Tree`, and `Diff` are `!Send`, and after pathspec filtering the per-commit
26//! delta sets are tiny (usually empty), so shipping paths across a rayon
27//! boundary would cost more than the glob checks it saves. The feature is
28//! zero-cost when disabled: one bool branch in the pipeline.
29
30use 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/// Result of a vanished-file scan
39#[derive(Debug, Default)]
40pub struct VanishedScan {
41    /// Vanished files, ordered newest-deletion-first (the first entry's
42    /// `last_seen_sha` is the natural group-level reconstruction commit)
43    pub vanished: Vec<VanishedFile>,
44    /// Commits visited on the first-parent chain
45    pub commits_walked: u32,
46    /// True when the vanished_max_commits cap was hit (results may be partial)
47    pub truncated: bool,
48    /// Soft anomalies (e.g. rename cycle) — caller maps these to diagnostics
49    pub anomalies: Vec<String>,
50}
51
52/// Per-path event record (newest event wins — the walk is newest-first).
53///
54/// Detection is deterministic and purely path-based: a path is tracked by its
55/// exact repo-relative string, never by content similarity. Renames are
56/// intentionally NOT detected — `git mv a b` is seen as a delete of `a` plus an
57/// add of `b`, and each path is judged on its own. For a path-keyed deployment
58/// (each stack's state lives at a fixed backend path), a file absent at head but
59/// present intra-range must have its path destroyed regardless of whether some
60/// other path gained similar content; content-similarity rename pairing would
61/// wrongly suppress that destroy — the exact bug this design avoids.
62struct PathEvents {
63    added_in_range: bool,
64    /// First parent of the deleting commit (the last commit where the file
65    /// existed); set on the newest deletion observed for this path.
66    deleted_at: Option<git2::Oid>,
67}
68
69/// First-parent history walker detecting files added then removed within
70/// base..head. Holds the repo path (git2 objects are `!Send`) and opens the
71/// repository once per `detect_sync` call, mirroring `ShaResolver`.
72pub struct VanishedDetector<'a> {
73    repo_path: &'a Path,
74    interner: &'a StringInterner,
75}
76
77impl<'a> VanishedDetector<'a> {
78    /// Create a new detector rooted at the repository path
79    pub fn new(repo_path: &'a Path, interner: &'a StringInterner) -> Self {
80        Self {
81            repo_path,
82            interner,
83        }
84    }
85
86    /// Walk base..head (first-parent) and report files that were added in the
87    /// range, match `matches`, and no longer exist in the head tree.
88    ///
89    /// `matches` is a statically-dispatched predicate over repo-relative
90    /// paths. `pathspecs` are coarse literal prefixes (see
91    /// [`pathspec_prefixes`]) used to keep the common per-commit diff cheap;
92    /// correctness never depends on them. `max_commits` of 0 means unlimited.
93    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        // Initial-push base (the empty tree SHA) has no commit to hide; the
111        // walk then covers head's whole first-parent history, bounded by the
112        // cap. A missing/unreachable base (shallow clone, force push) is a
113        // hard error here — the caller soft-fails it into a diagnostic.
114        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            // Root commit diffs against the empty tree
149            let parent = commit.parent(0).ok();
150            let parent_tree = match &parent {
151                Some(p) => Some(p.tree()?),
152                None => None,
153            };
154
155            // Single pathspec-filtered diff against the first parent. Rename
156            // detection is intentionally OFF (no `find_similar`): `git mv a b`
157            // yields Delete(a) + Add(b), and each path is judged on its own.
158            // Determinism over guessing — content-similarity rename pairing would
159            // suppress the destroy of a path whose file merely moved, or whose
160            // lookalike was added elsewhere, which is wrong for path-keyed state.
161            // Commits touching nothing under the prefixes produce an empty delta
162            // set for near-zero cost.
163            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                        // Newest deletion wins (walk is newest-first). A deletion
187                        // in a root commit is impossible (nothing existed before
188                        // it), so parent_oid is always Some here.
189                        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        // Resolution: a path vanished when it was added in-range, is absent from
218        // the head tree, did not exist at base (the endpoint diff already reports
219        // base-existing deletions), and a deletion was observed for it on the
220        // first-parent chain. Every path stands on its own — no rename chains, no
221        // content similarity, no guessing.
222        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; // alive at head (re-added or never really gone)
231            }
232            if let Some(base_tree) = &base_tree {
233                if base_tree.get_path(Path::new(path_str)).is_ok() {
234                    continue; // existed at base -> endpoint Deleted covers it
235                }
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        // Deterministic order: newest deletion first, path as tie-break.
253        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
267/// Derive coarse literal pathspec prefixes from glob patterns: the substring
268/// up to the first glob metacharacter (`*?[{`), truncated at the last `/`.
269/// A pattern with no derivable prefix yields no entry; if ANY pattern lacks a
270/// prefix the result is empty (meaning: diff everything — pathspec filtering
271/// is an optimization, never a correctness gate).
272pub 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(); // pattern can match at repo root: no filter
279        };
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        // last_seen must be add2 (parent of the NEWEST deletion), content v2
423        assert_eq!(got, vec![("stacks/a/Pulumi.yaml", add2)]);
424    }
425
426    #[test]
427    fn test_rename_within_scope_vanishes_old_path() {
428        // Path-exact & deterministic: `git mv a b` = delete of a + add of b.
429        // a was added in range and is gone at head -> a vanishes (its path's
430        // deployed state must be destroyed). b is alive at head -> deployed, not
431        // vanished. Content similarity between a and b is irrelevant.
432        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        // last_seen for a = parent of the rename commit = the add commit.
447        assert_eq!(got, vec![("stacks/a/Pulumi.yaml", add)]);
448    }
449
450    #[test]
451    fn test_rename_chain_then_delete_vanishes_every_path() {
452        // add a -> rename a->b -> delete b. Both stacks/a and stacks/b held a
453        // matching file that is gone at head, and both were added in range, so
454        // both paths vanish (each may have deployed state to destroy). Ordered
455        // newest-deletion-first: b (deleted last) before a.
456        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        // Moving the file out of the glob scope removes it from its deploy path;
483        // path-exact treats that as a deletion of stacks/a (destroy its state).
484        // The out-of-scope add (archive/) does not match and is ignored.
485        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        // side branch adds AND deletes a matching file, then merges
531        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        // mainline: merge + mainline + base-excluded => walked visits merge and mainline commits only
549        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        // The deletion IS seen (newest commits first) but the Add is beyond
566        // the cap -> no vanished entry, plus no anomaly (added_in_range false)
567        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        // A rootless pattern disables filtering entirely (correctness first)
639        assert!(pathspec_prefixes(&["*.md"]).is_empty());
640        assert!(pathspec_prefixes(&["stacks/**", "*.md"]).is_empty());
641        // Dedup
642        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(); // a deleted next -> parent is add-b commit
655        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        // b deleted last (newest) -> first in the list
663        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}