Skip to main content

lds_pack/
scan.rs

1//! Classification pass: decide, for every path under the project root, whether
2//! it travels in the pack, is dropped, or is merely reported.
3//!
4//! The scan is deliberately independent of `.gitignore`. A pack carries the
5//! whole project — tracked files, the `.git` directory itself, and the
6//! untracked local state that ordinarily never leaves the machine (a
7//! `workspace/` directory, journal databases, sandbox snapshots). Consulting
8//! ignore rules would drop exactly the material the pack exists to preserve.
9//!
10//! Four rules decide what does *not* travel verbatim:
11//!
12//! | rule | effect |
13//! |---|---|
14//! | cache directory | not packed, recorded; regenerable by definition |
15//! | secret file | not packed, reported; moving credentials is the operator's own business |
16//! | symlink | packed as a link, never dereferenced; recorded so restore can report dangles |
17//! | `.claude/` | packed verbatim, links aggregated rather than enumerated |
18
19use std::collections::BTreeSet;
20use std::path::{Component, Path, PathBuf};
21
22use walkdir::WalkDir;
23
24use crate::error::PackError;
25use crate::manifest::{ClaudeInfo, SkipRecord, SymlinkRecord, WorktreeRecord};
26use crate::rules::PackRules;
27
28/// File names dropped silently — OS debris that is neither cache nor content.
29const NOISE_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
30
31/// The `.claude/` directory name, handled as its own layer.
32const CLAUDE_DIR: &str = ".claude";
33
34/// What a scanned path is, for the purpose of writing the archive.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum EntryKind {
37    /// A regular file, packed by content.
38    File,
39    /// A directory, packed as an entry so empty directories survive.
40    Dir,
41    /// A symlink, packed as a link without following it.
42    Symlink,
43}
44
45/// One path that will be written into the archive.
46#[derive(Debug, Clone)]
47pub struct Entry {
48    /// Path relative to the project root, using `/` separators.
49    pub rel: String,
50    /// Absolute path on disk.
51    pub abs: PathBuf,
52    /// What kind of entry this is.
53    pub kind: EntryKind,
54    /// Size in bytes for regular files, `0` otherwise.
55    pub size: u64,
56}
57
58/// Result of classifying a project root.
59#[derive(Debug, Default)]
60pub struct Scan {
61    /// Everything that will be written to the archive, in walk order.
62    pub entries: Vec<Entry>,
63    /// Cache directories that were dropped.
64    pub skipped_cache: Vec<SkipRecord>,
65    /// Secret-looking files that were dropped and reported.
66    pub skipped_secret: Vec<SkipRecord>,
67    /// Symlinks outside `.claude/`, recorded individually.
68    pub symlinks: Vec<SymlinkRecord>,
69    /// Aggregate view of `.claude/`.
70    pub claude: ClaudeInfo,
71    /// Registered git worktrees discovered under `.git/worktrees/`.
72    pub worktrees: Vec<WorktreeRecord>,
73}
74
75impl Scan {
76    /// Total byte count of regular files to be packed.
77    pub fn total_bytes(&self) -> u64 {
78        self.entries.iter().map(|e| e.size).sum()
79    }
80
81    /// Number of regular files to be packed.
82    pub fn file_count(&self) -> u64 {
83        self.entries
84            .iter()
85            .filter(|e| e.kind == EntryKind::File)
86            .count() as u64
87    }
88
89    /// Number of symlinks to be packed.
90    pub fn symlink_count(&self) -> u64 {
91        self.entries
92            .iter()
93            .filter(|e| e.kind == EntryKind::Symlink)
94            .count() as u64
95    }
96}
97
98/// Classify every path under `root` using the default rules.
99///
100/// Convenience wrapper over [`scan_with`] for callers that do not customize
101/// classification.
102///
103/// # Errors
104///
105/// Same as [`scan_with`].
106pub fn scan(root: &Path) -> Result<Scan, PackError> {
107    scan_with(root, &PackRules::default())
108}
109
110/// Classify every path under `root`.
111///
112/// # Arguments
113///
114/// * `root` — Absolute path to the project root.
115/// * `rules` — Which names count as secrets and caches (see [`PackRules`]).
116///
117/// # Returns
118///
119/// A [`Scan`] listing what to pack and what was deliberately left out.
120///
121/// # Errors
122///
123/// - [`PackError::NotADirectory`] if `root` is not a directory.
124/// - [`PackError::Io`] if the tree cannot be walked or a link cannot be read.
125pub fn scan_with(root: &Path, rules: &PackRules) -> Result<Scan, PackError> {
126    if !root.is_dir() {
127        return Err(PackError::NotADirectory(root.to_path_buf()));
128    }
129    // Resolve the root up front so paths recorded by git (which may name the
130    // same directory through a different symlinked prefix) can be compared
131    // against it. Without this, a worktree inside the project reads as being
132    // outside it and its contents silently stop travelling.
133    let root = &canonicalize_or(root);
134
135    let mut scan = Scan::default();
136    let mut claude_link_targets: Vec<PathBuf> = Vec::new();
137
138    let walker = WalkDir::new(root)
139        .follow_links(false)
140        .min_depth(1)
141        .sort_by_file_name()
142        .into_iter();
143
144    // `filter_entry` prunes whole subtrees, so a skipped cache directory costs
145    // one stat rather than a full descent into it.
146    let it = walker.filter_entry(|e| {
147        let name = e.file_name().to_string_lossy();
148        // Never descend into a symlinked directory: it is packed as a link.
149        if e.file_type().is_symlink() {
150            return true;
151        }
152        if e.file_type().is_dir() && rules.is_cache_dir(name.as_ref()) {
153            return false;
154        }
155        true
156    });
157
158    // Cache directories are pruned above, which also hides them from the
159    // record; walk their parents separately so each dropped cache is named.
160    collect_cache_records(root, rules, &mut scan)?;
161
162    for next in it {
163        let entry = next?;
164        let abs = entry.path().to_path_buf();
165        let Some(rel) = rel_path(root, &abs) else {
166            continue;
167        };
168        let name = entry.file_name().to_string_lossy().to_string();
169
170        if NOISE_FILES.contains(&name.as_str()) {
171            continue;
172        }
173
174        let file_type = entry.file_type();
175        let in_claude = rel == CLAUDE_DIR || rel.starts_with(&format!("{CLAUDE_DIR}/"));
176
177        if file_type.is_symlink() {
178            let target = std::fs::read_link(&abs)?;
179            if in_claude {
180                // `.claude/` links are aggregated, not enumerated.
181                scan.claude.symlink_count += 1;
182                claude_link_targets.push(target);
183            } else {
184                scan.symlinks.push(SymlinkRecord {
185                    path: rel.clone(),
186                    target: target.to_string_lossy().into_owned(),
187                    outside_root: resolves_outside(root, &abs, &target),
188                });
189            }
190            scan.entries.push(Entry {
191                rel,
192                abs,
193                kind: EntryKind::Symlink,
194                size: 0,
195            });
196            continue;
197        }
198
199        if file_type.is_dir() {
200            if rel == CLAUDE_DIR {
201                scan.claude.present = true;
202            }
203            scan.entries.push(Entry {
204                rel,
205                abs,
206                kind: EntryKind::Dir,
207                size: 0,
208            });
209            continue;
210        }
211
212        if let Some(reason) = rules.secret_reason(&name) {
213            scan.skipped_secret.push(SkipRecord { path: rel, reason });
214            continue;
215        }
216
217        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
218        scan.entries.push(Entry {
219            rel,
220            abs,
221            kind: EntryKind::File,
222            size,
223        });
224    }
225
226    scan.claude.link_roots = summarize_link_roots(&claude_link_targets);
227    scan.worktrees = discover_worktrees(root)?;
228
229    Ok(scan)
230}
231
232/// Walk the tree a second time, shallowly, to name every pruned cache directory.
233///
234/// `filter_entry` removes cache directories before they are yielded, so they
235/// would otherwise vanish without a record. This pass descends normally but
236/// stops at each cache directory it names, so the cost stays proportional to
237/// the surviving tree.
238fn collect_cache_records(root: &Path, rules: &PackRules, scan: &mut Scan) -> Result<(), PackError> {
239    let walker = WalkDir::new(root)
240        .follow_links(false)
241        .min_depth(1)
242        .sort_by_file_name()
243        .into_iter();
244
245    let mut it = walker.filter_entry(|e| {
246        if e.file_type().is_symlink() {
247            return false;
248        }
249        if !e.file_type().is_dir() {
250            return false;
251        }
252        true
253    });
254
255    while let Some(next) = it.next() {
256        let entry = next?;
257        let name = entry.file_name().to_string_lossy().to_string();
258        if !rules.is_cache_dir(&name) {
259            continue;
260        }
261        if let Some(rel) = rel_path(root, entry.path()) {
262            scan.skipped_cache.push(SkipRecord {
263                path: rel,
264                reason: format!("cache directory: {name}"),
265            });
266        }
267        it.skip_current_dir();
268    }
269
270    Ok(())
271}
272
273/// Convert an absolute path to a `/`-separated path relative to `root`.
274fn rel_path(root: &Path, abs: &Path) -> Option<String> {
275    let rel = abs.strip_prefix(root).ok()?;
276    let s = rel
277        .components()
278        .map(|c| c.as_os_str().to_string_lossy())
279        .collect::<Vec<_>>()
280        .join("/");
281    if s.is_empty() { None } else { Some(s) }
282}
283
284/// Whether a link target escapes the project root.
285///
286/// Relative targets are resolved against the link's own directory. The result
287/// is resolved against the filesystem where possible, so a link written through
288/// one symlinked prefix is not mistaken for pointing outside a root named
289/// through another. A dangling target cannot be resolved and falls back to
290/// lexical normalization, which is enough to classify it.
291fn resolves_outside(root: &Path, link_path: &Path, target: &Path) -> bool {
292    let joined = if target.is_absolute() {
293        target.to_path_buf()
294    } else {
295        match link_path.parent() {
296            Some(parent) => parent.join(target),
297            None => return true,
298        }
299    };
300    !canonicalize_or(&joined).starts_with(canonicalize_or(root))
301}
302
303/// Resolve a path against the filesystem, falling back to lexical
304/// normalization when it does not exist.
305fn canonicalize_or(path: &Path) -> PathBuf {
306    std::fs::canonicalize(path).unwrap_or_else(|_| normalize(path))
307}
308
309/// Lexically normalize a path, collapsing `.` and `..` without touching disk.
310pub(crate) fn normalize(path: &Path) -> PathBuf {
311    let mut out = PathBuf::new();
312    for component in path.components() {
313        match component {
314            Component::ParentDir => {
315                out.pop();
316            }
317            Component::CurDir => {}
318            other => out.push(other.as_os_str()),
319        }
320    }
321    out
322}
323
324/// Reduce a set of link targets to the smallest useful set of roots.
325///
326/// When every target shares a deep prefix — the usual shape for a
327/// profile-managed `.claude/` — that single prefix is reported. When the
328/// targets scatter, their parent directories are reported instead, capped so
329/// the manifest cannot be flooded.
330fn summarize_link_roots(targets: &[PathBuf]) -> Vec<String> {
331    const MAX_ROOTS: usize = 10;
332    /// Below this depth a shared prefix is too generic to be informative
333    /// (`/`, `/Users`, `/Users/name`).
334    const MIN_SHARED_DEPTH: usize = 4;
335
336    let parents: BTreeSet<PathBuf> = targets
337        .iter()
338        .filter(|t| t.is_absolute())
339        .filter_map(|t| t.parent().map(normalize))
340        .collect();
341
342    if parents.is_empty() {
343        return Vec::new();
344    }
345
346    let parents: Vec<PathBuf> = parents.into_iter().collect();
347    if let Some(shared) = common_prefix(&parents)
348        && shared.components().count() >= MIN_SHARED_DEPTH
349    {
350        return vec![shared.to_string_lossy().into_owned()];
351    }
352
353    parents
354        .iter()
355        .take(MAX_ROOTS)
356        .map(|p| p.to_string_lossy().into_owned())
357        .collect()
358}
359
360/// Longest path prefix shared by every input, or `None` for an empty input.
361fn common_prefix(paths: &[PathBuf]) -> Option<PathBuf> {
362    let mut iter = paths.iter();
363    let mut prefix: Vec<_> = iter.next()?.components().collect();
364
365    for path in iter {
366        let comps: Vec<_> = path.components().collect();
367        let shared = prefix
368            .iter()
369            .zip(comps.iter())
370            .take_while(|(a, b)| a == b)
371            .count();
372        prefix.truncate(shared);
373        if prefix.is_empty() {
374            return None;
375        }
376    }
377
378    Some(prefix.iter().collect())
379}
380
381/// Read `.git/worktrees/` to learn which worktrees this repository has.
382///
383/// Each admin directory holds a `gitdir` file whose contents are the absolute
384/// path of the worktree's own `.git` file; the worktree root is that file's
385/// parent. Worktrees inside the project root travel with the pack, and their
386/// pointers are rewritten on restore. Worktrees outside it are recorded but
387/// their contents are not collected — reaching outside the root to pull in an
388/// arbitrary directory is a different decision than packing a project.
389fn discover_worktrees(root: &Path) -> Result<Vec<WorktreeRecord>, PackError> {
390    let admin = root.join(".git").join("worktrees");
391    if !admin.is_dir() {
392        return Ok(Vec::new());
393    }
394
395    let mut records = Vec::new();
396    let mut dirs: Vec<PathBuf> = std::fs::read_dir(&admin)?
397        .filter_map(|e| e.ok())
398        .map(|e| e.path())
399        .filter(|p| p.is_dir())
400        .collect();
401    dirs.sort();
402
403    for dir in dirs {
404        let Some(name) = dir.file_name().map(|n| n.to_string_lossy().into_owned()) else {
405            continue;
406        };
407        let gitdir_file = dir.join("gitdir");
408        let Ok(contents) = std::fs::read_to_string(&gitdir_file) else {
409            continue;
410        };
411        // `gitdir` holds the path of the worktree's `.git` file; its parent is
412        // the worktree root.
413        let dot_git = PathBuf::from(contents.trim());
414        let Some(worktree_root) = dot_git.parent() else {
415            continue;
416        };
417        // git records this path as it saw it, which need not match the resolved
418        // root; resolve both sides before deciding whether it lives inside.
419        let resolved = canonicalize_or(worktree_root);
420        let rel = rel_path(root, &resolved);
421        records.push(WorktreeRecord {
422            name,
423            included: rel.is_some(),
424            path: rel,
425            source_path: worktree_root.to_string_lossy().into_owned(),
426        });
427    }
428
429    Ok(records)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use std::fs;
436    use tempfile::TempDir;
437
438    fn touch(path: &Path) {
439        if let Some(parent) = path.parent() {
440            fs::create_dir_all(parent).expect("mkdir should succeed in test");
441        }
442        fs::write(path, b"x").expect("write should succeed in test");
443    }
444
445    fn rels(scan: &Scan) -> Vec<String> {
446        scan.entries.iter().map(|e| e.rel.clone()).collect()
447    }
448
449    // ------------------------------------------------------------------
450    // scan behaviour
451    // ------------------------------------------------------------------
452
453    /// Untracked local state travels; `.git` travels; caches and secrets do not.
454    #[test]
455    fn test_scan_partitions_tree() {
456        let dir = TempDir::new().expect("tempdir");
457        let root = dir.path();
458
459        touch(&root.join("src/main.rs"));
460        touch(&root.join(".git/HEAD"));
461        touch(&root.join("workspace/journal.md"));
462        touch(&root.join("workspace/.journal.db"));
463        touch(&root.join(".mcp.json"));
464        touch(&root.join("target/debug/binary"));
465        touch(&root.join("crates/inner/target/x.rlib"));
466        touch(&root.join(".env"));
467        touch(&root.join(".env.example"));
468        touch(&root.join("key.pem"));
469
470        let scan = scan(root).expect("scan should succeed");
471        let packed = rels(&scan);
472
473        assert!(packed.contains(&"src/main.rs".to_string()));
474        assert!(
475            packed.contains(&".git/HEAD".to_string()),
476            "`.git` must travel"
477        );
478        assert!(packed.contains(&"workspace/journal.md".to_string()));
479        assert!(
480            packed.contains(&"workspace/.journal.db".to_string()),
481            "journal database is exactly the local state a pack exists to carry"
482        );
483        assert!(packed.contains(&".mcp.json".to_string()));
484        assert!(packed.contains(&".env.example".to_string()));
485
486        assert!(
487            !packed.iter().any(|p| p.starts_with("target/")),
488            "cache tree must not be packed"
489        );
490        assert!(
491            !packed.iter().any(|p| p.contains("/target/")),
492            "nested cache tree must not be packed"
493        );
494        assert!(!packed.contains(&".env".to_string()));
495        assert!(!packed.contains(&"key.pem".to_string()));
496
497        let secrets: Vec<&str> = scan
498            .skipped_secret
499            .iter()
500            .map(|s| s.path.as_str())
501            .collect();
502        assert!(secrets.contains(&".env"));
503        assert!(secrets.contains(&"key.pem"));
504
505        let caches: Vec<&str> = scan.skipped_cache.iter().map(|s| s.path.as_str()).collect();
506        assert!(caches.contains(&"target"));
507        assert!(caches.contains(&"crates/inner/target"));
508    }
509
510    /// Symlinks outside `.claude/` are recorded individually and packed as links.
511    #[cfg(unix)]
512    #[test]
513    fn test_scan_records_symlinks_outside_claude() {
514        let dir = TempDir::new().expect("tempdir");
515        let root = dir.path();
516        let outside = TempDir::new().expect("tempdir");
517
518        touch(&root.join("real.txt"));
519        std::os::unix::fs::symlink(root.join("real.txt"), root.join("inside-link"))
520            .expect("symlink");
521        std::os::unix::fs::symlink(outside.path().join("far.txt"), root.join("outside-link"))
522            .expect("symlink");
523
524        let scan = scan(root).expect("scan should succeed");
525
526        assert_eq!(scan.symlinks.len(), 2);
527        let inside = scan
528            .symlinks
529            .iter()
530            .find(|s| s.path == "inside-link")
531            .expect("inside link recorded");
532        let outside_rec = scan
533            .symlinks
534            .iter()
535            .find(|s| s.path == "outside-link")
536            .expect("outside link recorded");
537        assert!(!inside.outside_root);
538        assert!(outside_rec.outside_root);
539
540        assert!(rels(&scan).contains(&"outside-link".to_string()));
541    }
542
543    /// `.claude/` links are counted and summarized, never enumerated.
544    #[cfg(unix)]
545    #[test]
546    fn test_scan_aggregates_claude_links() {
547        let dir = TempDir::new().expect("tempdir");
548        let root = dir.path();
549        let profiles = TempDir::new().expect("tempdir");
550        let agents = profiles.path().join("sets/coding/agents");
551        let rules = profiles.path().join("sets/base/rules");
552        fs::create_dir_all(&agents).expect("mkdir");
553        fs::create_dir_all(&rules).expect("mkdir");
554        touch(&agents.join("a.md"));
555        touch(&rules.join("b.md"));
556
557        fs::create_dir_all(root.join(".claude/agents")).expect("mkdir");
558        fs::create_dir_all(root.join(".claude/rules")).expect("mkdir");
559        std::os::unix::fs::symlink(agents.join("a.md"), root.join(".claude/agents/a.md"))
560            .expect("symlink");
561        std::os::unix::fs::symlink(rules.join("b.md"), root.join(".claude/rules/b.md"))
562            .expect("symlink");
563
564        let scan = scan(root).expect("scan should succeed");
565
566        assert!(scan.claude.present);
567        assert_eq!(scan.claude.symlink_count, 2);
568        assert!(
569            scan.symlinks.is_empty(),
570            "`.claude` links must not appear in the per-link list"
571        );
572        assert_eq!(
573            scan.claude.link_roots.len(),
574            1,
575            "a shared profiles root collapses to one entry, got {:?}",
576            scan.claude.link_roots
577        );
578        assert!(rels(&scan).contains(&".claude/agents/a.md".to_string()));
579    }
580
581    /// A missing `.git/worktrees` yields no worktree records.
582    #[test]
583    fn test_scan_without_worktrees() {
584        let dir = TempDir::new().expect("tempdir");
585        touch(&dir.path().join(".git/HEAD"));
586        let scan = scan(dir.path()).expect("scan should succeed");
587        assert!(scan.worktrees.is_empty());
588    }
589
590    /// A worktree inside the root is discovered and marked as included.
591    #[test]
592    fn test_scan_discovers_inside_worktree() {
593        let dir = TempDir::new().expect("tempdir");
594        let root = dir.path();
595        let wt = root.join(".worktrees/feature");
596        touch(&wt.join("file.txt"));
597        fs::write(wt.join(".git"), "gitdir: /ignored\n").expect("write");
598        let admin = root.join(".git/worktrees/feature");
599        fs::create_dir_all(&admin).expect("mkdir");
600        fs::write(
601            admin.join("gitdir"),
602            format!("{}\n", wt.join(".git").display()),
603        )
604        .expect("write");
605
606        let scan = scan(root).expect("scan should succeed");
607
608        assert_eq!(scan.worktrees.len(), 1);
609        let rec = &scan.worktrees[0];
610        assert_eq!(rec.name, "feature");
611        assert_eq!(rec.path.as_deref(), Some(".worktrees/feature"));
612        assert!(rec.included);
613        assert!(rels(&scan).contains(&".worktrees/feature/file.txt".to_string()));
614    }
615
616    /// A worktree outside the root is reported but its contents are not collected.
617    #[test]
618    fn test_scan_reports_outside_worktree_without_including_it() {
619        let dir = TempDir::new().expect("tempdir");
620        let root = dir.path();
621        let elsewhere = TempDir::new().expect("tempdir");
622        let wt = elsewhere.path().join("detached");
623        touch(&wt.join("file.txt"));
624
625        let admin = root.join(".git/worktrees/detached");
626        fs::create_dir_all(&admin).expect("mkdir");
627        fs::write(
628            admin.join("gitdir"),
629            format!("{}\n", wt.join(".git").display()),
630        )
631        .expect("write");
632
633        let scan = scan(root).expect("scan should succeed");
634
635        assert_eq!(scan.worktrees.len(), 1);
636        assert!(!scan.worktrees[0].included);
637        assert!(scan.worktrees[0].path.is_none());
638        assert!(!rels(&scan).iter().any(|p| p.contains("detached/file.txt")));
639    }
640
641    /// Scanning a file rather than a directory is an error.
642    #[test]
643    fn test_scan_rejects_non_directory() {
644        let dir = TempDir::new().expect("tempdir");
645        let file = dir.path().join("f.txt");
646        touch(&file);
647        assert!(matches!(scan(&file), Err(PackError::NotADirectory(_))));
648    }
649
650    // ------------------------------------------------------------------
651    // helpers
652    // ------------------------------------------------------------------
653
654    /// A deep shared prefix collapses to a single root.
655    #[test]
656    fn test_summarize_link_roots_collapses_shared_prefix() {
657        let targets = vec![
658            PathBuf::from("/home/u/.config/profiles/sets/coding/agents/a.md"),
659            PathBuf::from("/home/u/.config/profiles/sets/base/rules/b.md"),
660        ];
661        let roots = summarize_link_roots(&targets);
662        assert_eq!(roots, vec!["/home/u/.config/profiles/sets".to_string()]);
663    }
664
665    /// Scattered targets are listed by parent instead of collapsing to `/`.
666    #[test]
667    fn test_summarize_link_roots_keeps_scattered_parents() {
668        let targets = vec![PathBuf::from("/opt/a/x.md"), PathBuf::from("/srv/b/y.md")];
669        let roots = summarize_link_roots(&targets);
670        assert_eq!(roots.len(), 2);
671    }
672
673    /// Relative link targets are resolved against the link's own directory.
674    #[test]
675    fn test_resolves_outside_relative_target() {
676        let root = Path::new("/proj");
677        assert!(!resolves_outside(
678            root,
679            Path::new("/proj/sub/link"),
680            Path::new("../file.txt")
681        ));
682        assert!(resolves_outside(
683            root,
684            Path::new("/proj/sub/link"),
685            Path::new("../../escape.txt")
686        ));
687    }
688}