Skip to main content

weavatrix_git/
worktree_safety.rs

1//! Worktree safety summary for consumers that must not invent Git state.
2
3use std::{collections::BTreeSet, fs};
4
5use crate::{
6    Repository, Result, StatusKind, gitignore::IgnoreStack, worktree_walk::count_untracked,
7};
8
9/// How the repository is attached to a working tree.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum WorktreeKind {
12    /// No worktree.
13    Bare,
14    /// Primary checkout whose `.git` is a directory.
15    Primary,
16    /// Linked worktree whose `.git` is a `gitdir:` file.
17    Linked,
18}
19
20/// Coarse safety bucket. Ignored-only is not dirty.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum WorktreeSafetyLevel {
23    /// No tracked changes and no untracked files.
24    Clean,
25    /// Tracked state is clean; extras are ignored only.
26    IgnoredOnly,
27    /// Untracked, non-ignored files exist.
28    HasUntracked,
29    /// Index or worktree tracked files differ from HEAD.
30    DirtyTracked,
31    /// Safety could not be determined (bare, I/O, submodule limits).
32    Unknown,
33}
34
35/// One inspectable reason behind the summary.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum WorktreeEvidence {
38    /// Tracked path with a non-clean status.
39    TrackedDirty {
40        /// Exact Git path bytes.
41        path: Vec<u8>,
42        /// Index versus HEAD.
43        index: StatusKind,
44        /// Worktree versus index.
45        worktree: StatusKind,
46    },
47    /// Sample untracked path.
48    Untracked {
49        /// Exact relative path bytes.
50        path: Vec<u8>,
51    },
52    /// Sample ignored path.
53    Ignored {
54        /// Exact relative path bytes.
55        path: Vec<u8>,
56    },
57    /// A gitlink was present and was not inspected.
58    Submodule {
59        /// Exact relative path bytes.
60        path: Vec<u8>,
61    },
62}
63
64/// Portable worktree safety contract for cleaners and agent worktrees.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct WorktreeSafety {
67    /// Tracked files differ from HEAD in the index or worktree.
68    pub tracked_dirty: bool,
69    /// Index differs from HEAD.
70    pub staged_dirty: bool,
71    /// Untracked, non-ignored paths.
72    pub untracked_count: u64,
73    /// Ignored untracked paths (directory roots counted once).
74    pub ignored_count: u64,
75    /// At least one gitlink exists; submodule contents were not scanned.
76    pub submodule_unknown: bool,
77    /// Worktree layout.
78    pub kind: WorktreeKind,
79    /// Coarse bucket derived from the counts.
80    pub level: WorktreeSafetyLevel,
81    /// Bounded evidence samples.
82    pub evidence: Vec<WorktreeEvidence>,
83}
84
85impl WorktreeSafety {
86    fn from_parts(
87        kind: WorktreeKind,
88        tracked_dirty: bool,
89        staged_dirty: bool,
90        untracked_count: u64,
91        ignored_count: u64,
92        submodule_unknown: bool,
93        evidence: Vec<WorktreeEvidence>,
94    ) -> Self {
95        let level = if kind == WorktreeKind::Bare {
96            WorktreeSafetyLevel::Unknown
97        } else if tracked_dirty {
98            WorktreeSafetyLevel::DirtyTracked
99        } else if untracked_count > 0 {
100            WorktreeSafetyLevel::HasUntracked
101        } else if ignored_count > 0 {
102            WorktreeSafetyLevel::IgnoredOnly
103        } else {
104            WorktreeSafetyLevel::Clean
105        };
106        Self {
107            tracked_dirty,
108            staged_dirty,
109            untracked_count,
110            ignored_count,
111            submodule_unknown,
112            kind,
113            level,
114            evidence,
115        }
116    }
117}
118
119impl Repository {
120    /// Classify the worktree without launching Git.
121    ///
122    /// Bare repositories return [`WorktreeSafetyLevel::Unknown`]. Tracked
123    /// status is reused from [`Self::status`]; untracked and ignored paths are
124    /// counted from a symlink-free walk that honors `.gitignore` and
125    /// `$GIT_DIR/info/exclude`.
126    pub fn worktree_safety(&self) -> Result<WorktreeSafety> {
127        let kind = worktree_kind(self);
128        let Some(root) = self.work_dir() else {
129            return Ok(WorktreeSafety::from_parts(
130                kind,
131                false,
132                false,
133                0,
134                0,
135                false,
136                Vec::new(),
137            ));
138        };
139        let status = self.status()?;
140        let mut staged_dirty = false;
141        let mut tracked_dirty = false;
142        let mut evidence = Vec::new();
143        for entry in &status {
144            let staged = entry.index != StatusKind::Unmodified;
145            let dirty = staged || entry.worktree != StatusKind::Unmodified;
146            staged_dirty |= staged;
147            tracked_dirty |= dirty;
148            if dirty && evidence.len() < 32 {
149                evidence.push(WorktreeEvidence::TrackedDirty {
150                    path: entry.path.clone(),
151                    index: entry.index,
152                    worktree: entry.worktree,
153                });
154            }
155        }
156        let index = self.index_shared()?;
157        let mut submodule_unknown = false;
158        let mut indexed = BTreeSet::new();
159        for entry in index.entries() {
160            indexed.insert(entry.path.clone());
161            if entry.mode & 0o170_000 == 0o160_000 {
162                submodule_unknown = true;
163                evidence.push(WorktreeEvidence::Submodule {
164                    path: entry.path.clone(),
165                });
166            }
167        }
168        let mut ignore = IgnoreStack::default();
169        if let Ok(text) = fs::read_to_string(self.git_dir().join("info").join("exclude")) {
170            ignore.push_file("", &text);
171        }
172        let counts = count_untracked(root, &indexed, &ignore, self.limits().max_index_entries)?;
173        evidence.extend(
174            counts
175                .untracked_samples
176                .into_iter()
177                .map(|path| WorktreeEvidence::Untracked { path }),
178        );
179        evidence.extend(
180            counts
181                .ignored_samples
182                .into_iter()
183                .map(|path| WorktreeEvidence::Ignored { path }),
184        );
185        Ok(WorktreeSafety::from_parts(
186            kind,
187            tracked_dirty,
188            staged_dirty,
189            counts.untracked,
190            counts.ignored,
191            submodule_unknown,
192            evidence,
193        ))
194    }
195}
196
197fn worktree_kind(repository: &Repository) -> WorktreeKind {
198    let Some(work_dir) = repository.work_dir() else {
199        return WorktreeKind::Bare;
200    };
201    if work_dir.join(".git").is_file() {
202        WorktreeKind::Linked
203    } else {
204        WorktreeKind::Primary
205    }
206}
207
208impl WorktreeSafety {
209    /// Unknown safety used when the caller cannot open the repository.
210    #[must_use]
211    pub fn unknown() -> Self {
212        Self::from_parts(WorktreeKind::Bare, false, false, 0, 0, false, Vec::new())
213    }
214}