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