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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
use std::sync::atomic::AtomicBool;
use super::{Baseline, Error, Limits, Report, Untracked};
use crate::Repository;
impl Repository {
/// Compares a baseline tree with the index, then verifies literal working-tree content.
///
/// This is a **raw-content** API: attributes, filters, EOL conversion, ignores and config
/// overrides of executable/symlink behavior are not applied. Every result records this policy.
/// Use [`Untracked::Omit`] for tracked-only inspection, or explicitly request raw untracked
/// leaves. Assume-valid and stat equality never skip verification. No filesystem writes or
/// index refresh occur; ordinary reads may update access times according to the filesystem.
///
/// Intent-to-add and skip-worktree entries are refused pending explicit consumer policy.
///
/// macOS/Linux traversal opens components relative to directory descriptors with no-follow
/// flags. It never follows symlink ancestors, enters `.git` components or descends into
/// gitlinks/nested repositories. Exact directory names must match index bytes, including case.
/// macOS non-ASCII names are unsupported; Linux retains non-UTF-8 bytes. Both reject NUL,
/// backslashes, colons, empty/dot/parent components and case-insensitive `.git` components.
/// Windows and other
/// platforms return unsupported before accessing storage. Worktree files must live on a
/// filesystem supporting native POSIX modes and symlinks; emulation/normalization is excluded.
///
/// File identity, size, mode, mtime and ctime are compared around reads. Directory metadata is
/// checked around traversal; HEAD (when requested) and index are reread before returning.
/// Observed changes fail. These checks cannot detect all changes, ABA replacements, or changes
/// after inspection: the report is not an atomic snapshot and must never authorize checkout
/// overwrites. Open directory descriptors pin inspected directories even if concurrently moved.
/// Mount changes, hostile hardlinks and repository-metadata replacement are outside the trust
/// boundary. Metadata/object/reference reads inherit the existing storage APIs' assumptions;
/// in particular reference resolution has a depth bound but no byte budget.
///
/// Work is synchronous. Cancellation is checked between entries, objects, directories and
/// 64-KiB file-read chunks. One syscall, object read, index parse, tree parse or sort cannot be
/// interrupted. The caller owns blocking-worker scheduling; there is no wall-clock guarantee.
/// Memory is bounded by index/tree/pack limits plus visited path records and one file buffer.
/// Directory traversal retains at most `min(max_depth, 128) + 1` active directory frames.
///
/// # Errors
///
/// Returns [`Error`] for bare/unsupported layouts or names, observed concurrent changes,
/// missing/corrupt/wrong-kind objects, malformed index/HEAD, I/O, cancellation or exceeded
/// limits. Index blob targets are verified even for missing worktree files and conflicts;
/// gitlink commits and baseline leaf targets are not resolved. No partial report is returned.
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::Repository;
/// use girt::status::{Baseline, Limits, Untracked};
/// # if cfg!(not(any(target_os = "linux", target_os = "macos"))) { return Ok(()); }
/// let directory = tempfile::tempdir()?;
/// let repository = Repository::init(
/// girt::ObjectFormat::Sha1,
/// directory.path().join("repo"),
/// girt::InitKind::Worktree,
/// )?;
/// let report = repository.raw_status(
/// Baseline::Head,
/// Untracked::Omit,
/// Limits::default(),
/// &AtomicBool::new(false),
/// )?;
/// println!(
/// "{} staged, {} raw unstaged",
/// report.staged.len(),
/// report.unstaged.len()
/// );
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn raw_status(
&self,
baseline: Baseline,
untracked: Untracked,
limits: Limits,
cancel: &AtomicBool,
) -> Result<Report, Error> {
super::types::check(cancel)?;
#[cfg(any(target_os = "linux", target_os = "macos"))]
return supported::run(self, baseline, untracked, limits, cancel);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (baseline, untracked, limits);
Err(Error::Unsupported {
path: vec![],
reason: "descriptor-relative worktree traversal requires macOS/Linux",
})
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod supported {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
use crate::status::types::{charge, check, value};
use crate::{Commit, ObjectId, ObjectKind, Objects, TreeChange, TreeValue, index, refs};
pub(super) fn run(
repo: &Repository,
baseline: Baseline,
untracked: Untracked,
limits: Limits,
cancel: &AtomicBool,
) -> Result<Report, Error> {
run_with_checkpoint(repo, baseline, untracked, limits, cancel, || {})
}
fn run_with_checkpoint(
repo: &Repository,
baseline: Baseline,
untracked: Untracked,
mut limits: Limits,
cancel: &AtomicBool,
before_recheck: impl FnOnce(),
) -> Result<Report, Error> {
let root = repo.worktree().ok_or_else(|| Error::Unsupported {
path: vec![],
reason: if repo.is_bare() {
"bare repository has no working tree"
} else {
"worktree location is unknown"
},
})?;
let index = repo.read_index(limits.index)?;
check(cancel)?;
let entries = index.as_ref().map_or(&[][..], index::Index::entries);
for entry in entries {
check(cancel)?;
if entry.intent_to_add || entry.skip_worktree {
return Err(Error::Unsupported {
path: entry.path.clone(),
reason: "extended index flags require caller policy",
});
}
super::super::worktree::validate_path(&entry.path)?;
charge(&mut limits.max_path_bytes, entry.path.len(), "path bytes")?;
}
let objects = repo.objects(limits.packs).map_err(Error::Objects)?;
check(cancel)?;
let head = match baseline {
Baseline::Head => Some(resolve_head(repo)?),
Baseline::Tree(_) => None,
};
let tree = match baseline {
Baseline::Tree(tree) => tree,
Baseline::Head => head
.as_ref()
.unwrap()
.id
.map(|id| {
let object =
read_object(&objects, id, b"HEAD", ObjectKind::Commit, &mut limits)?;
Ok::<_, Error>(Commit::parse(repo.object_format(), object.data())?.tree())
})
.transpose()?,
};
let leaves = objects.compare_trees(None, tree, limits.trees, cancel)?;
let mut staged: BTreeMap<Vec<u8>, (Option<TreeValue>, Option<TreeValue>)> = leaves
.into_iter()
.map(|change| (change.path, (change.new, None)))
.collect();
let mut conflicts = BTreeSet::new();
let mut unmerged = Vec::new();
let mut gitlinks = Vec::new();
for entry in entries {
check(cancel)?;
if entry.mode != index::Mode::Gitlink {
read_object(
&objects,
entry.id,
&entry.path,
ObjectKind::Blob,
&mut limits,
)?;
}
if entry.stage != index::Stage::Normal {
conflicts.insert(entry.path.clone());
unmerged.push(entry.clone());
} else {
staged.entry(entry.path.clone()).or_default().1 = Some(value(entry));
if entry.mode == index::Mode::Gitlink {
gitlinks.push(entry.clone());
}
}
}
let staged = staged
.into_iter()
.filter(|(path, (old, new))| old != new && !conflicts.contains(path))
.map(|(path, (old, new))| TreeChange { path, old, new })
.collect();
let observed =
super::super::worktree::scan(repo, root, entries, untracked, limits, cancel)?;
before_recheck();
check(cancel)?;
if repo.read_index(limits.index)? != index {
return Err(Error::Changed(b"index".to_vec()));
}
if let Some(head) = head
&& resolve_head(repo)? != head
{
return Err(Error::Changed(b"HEAD".to_vec()));
}
check(cancel)?;
Ok(Report {
comparison: super::super::Comparison::RawBytesAndPosixModes,
baseline_tree: tree,
index_missing: index.is_none(),
staged,
unstaged: observed.changes,
unmerged,
unchecked_gitlinks: gitlinks,
untracked_policy: untracked,
raw_untracked: observed.untracked,
boundaries: observed.boundaries,
})
}
fn resolve_head(repo: &Repository) -> Result<refs::Resolution, Error> {
let refs = repo.references()?;
let name = refs::RefName::HEAD;
if refs.read(&name)?.is_none() {
return Err(Error::Unsupported {
path: b"HEAD".to_vec(),
reason: "missing HEAD",
});
}
Ok(refs.resolve(&name, 32)?)
}
fn read_object(
objects: &Objects,
id: ObjectId,
path: &[u8],
kind: ObjectKind,
limits: &mut Limits,
) -> Result<crate::Object, Error> {
let mut read = limits.read;
read.max_object_bytes = read.max_object_bytes.min(limits.max_object_bytes);
let object = objects
.read(id, read)
.map_err(|source| Error::Object {
id,
path: path.to_vec(),
source: Box::new(source),
})?
.ok_or_else(|| Error::InvalidObject {
id,
path: path.to_vec(),
reason: "missing object",
})?;
if object.kind() != kind {
return Err(Error::InvalidObject {
id,
path: path.to_vec(),
reason: "wrong object kind",
});
}
charge(
&mut limits.max_object_bytes,
object.data().len(),
"object bytes",
)?;
Ok(object)
}
#[cfg(test)]
mod tests {
use std::fs;
use rstest::rstest;
use super::*;
#[rstest]
#[case::index("index", b"index")]
#[case::head("HEAD", b"HEAD")]
fn detects_metadata_change_on_final_reread(#[case] file: &str, #[case] expected: &[u8]) {
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(
crate::ObjectFormat::Sha1,
temp.path().join("repo"),
crate::InitKind::Worktree,
)
.unwrap();
let bytes = replacement(file);
let result = run_with_checkpoint(
&repo,
Baseline::Head,
Untracked::Omit,
Limits::default(),
&AtomicBool::new(false),
|| {
fs::write(repo.git_dir().join(file), bytes).unwrap();
},
);
assert!(matches!(result, Err(Error::Changed(path)) if path == expected));
}
fn replacement(file: &str) -> Vec<u8> {
match file {
"HEAD" => b"ref: refs/heads/different\n".to_vec(),
_ => index::Index::empty(crate::ObjectFormat::Sha1)
.encode(Default::default())
.unwrap(),
}
}
}
}