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
//! A thin `gix` wrapper exposing exactly the git facts the sync engine needs:
//! the HEAD tree id, the blobs in that tree, and blob contents. Kept small so
//! all `gix` coupling lives in one place.
use std::path::Path;
/// A blob in a tree: its repository-relative path and hex object id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobRef {
/// Repository-relative path (forward-slash separated).
pub path: String,
/// Hex-encoded git blob object id.
pub oid: String,
}
/// Errors raised while reading from a git repository.
#[derive(Debug, thiserror::Error)]
pub enum GitError {
/// A `gix` operation failed (message preserved).
#[error("git error: {0}")]
Git(String),
/// A tree entry path was not valid UTF-8.
#[error("non-utf8 path in tree: {0:?}")]
NonUtf8Path(Vec<u8>),
}
fn ge<E: std::fmt::Display>(e: E) -> GitError {
GitError::Git(e.to_string())
}
/// A discovered git repository.
pub struct Repo {
inner: gix::Repository,
}
impl Repo {
/// Discover the repository containing `path` (walking upwards to the `.git`).
///
/// # Errors
/// Returns [`GitError::Git`] if no repository is found or it cannot be opened.
pub fn discover(path: &Path) -> Result<Self, GitError> {
Ok(Self {
inner: gix::discover(path).map_err(ge)?,
})
}
/// The repository's *common* git directory. The cache lives under here so it
/// is shared across linked worktrees (which each have their own git dir).
#[must_use]
pub fn common_dir(&self) -> &Path {
self.inner.common_dir()
}
/// This worktree's git directory (per-worktree; the graph DB lives here).
#[must_use]
pub fn git_dir(&self) -> &Path {
self.inner.git_dir()
}
/// The directory git actually looks in for hooks. Honours `core.hooksPath`
/// (absolute, or relative to the working-tree root — else the git dir); when
/// unset it is `<common git dir>/hooks`, so managed hooks are shared across
/// linked worktrees. `roteiro init` installs into this so its hooks run
/// wherever git expects them.
#[must_use]
pub fn hooks_dir(&self) -> std::path::PathBuf {
let configured = self.inner.config_snapshot().string("core.hooksPath");
// An empty `core.hooksPath` (e.g. `git -c core.hooksPath=`) means "unset".
let configured = configured.filter(|c| !AsRef::<[u8]>::as_ref(c).is_empty());
if let Some(configured) = configured {
let bytes: &[u8] = configured.as_ref();
let path = std::path::PathBuf::from(String::from_utf8_lossy(bytes).into_owned());
if path.is_absolute() {
return path;
}
let base = self.inner.workdir().unwrap_or_else(|| self.inner.git_dir());
return base.join(path);
}
self.common_dir().join("hooks")
}
/// The working directory, if this is not a bare repository. The dirty
/// overlay reads uncommitted file contents from here.
#[must_use]
pub fn workdir(&self) -> Option<&Path> {
self.inner.workdir()
}
/// The hex git blob object id that `bytes` would have, without writing
/// anything. Used to detect whether a working-copy file differs from the
/// committed blob (same content ⇒ same id).
///
/// # Errors
/// Returns [`GitError::Git`] if hashing fails.
pub fn blob_oid(&self, bytes: &[u8]) -> Result<String, GitError> {
let id = gix::objs::compute_hash(self.inner.object_hash(), gix::objs::Kind::Blob, bytes)
.map_err(ge)?;
Ok(id.to_hex().to_string())
}
/// Hex object id of the tree at `HEAD`.
///
/// # Errors
/// Returns [`GitError::Git`] if `HEAD` cannot be resolved to a tree.
pub fn head_tree_id(&self) -> Result<String, GitError> {
let tree = self.inner.head_tree().map_err(ge)?;
Ok(tree.id().to_hex().to_string())
}
/// Every blob reachable from the `HEAD` tree, with full paths.
///
/// # Errors
/// Returns [`GitError`] if the tree cannot be traversed or a path is not
/// valid UTF-8.
pub fn walk_blobs(&self) -> Result<Vec<BlobRef>, GitError> {
let tree = self.inner.head_tree().map_err(ge)?;
walk_tree_blobs(&tree)
}
/// The tracked files that differ between `base` (any revspec — a branch,
/// `HEAD~3`, a sha) and the current `HEAD`, sorted by path. Used for
/// change-scoped tooling over a commit range (e.g. `roteiro review --base
/// main`), distinct from [`Repo::changed_files`], which compares the working
/// tree to `HEAD`. A path only in `HEAD` is added, only in `base` is deleted.
///
/// # Errors
/// Returns [`GitError`] if `base` cannot be resolved to a tree, a tree cannot
/// be traversed, or a path is not valid UTF-8.
pub fn changed_between(&self, base: &str) -> Result<Vec<ChangedFile>, GitError> {
let base_tree = self
.inner
.rev_parse_single(base)
.map_err(ge)?
.object()
.map_err(ge)?
.peel_to_tree()
.map_err(ge)?;
let base: std::collections::HashMap<String, String> = walk_tree_blobs(&base_tree)?
.into_iter()
.map(|b| (b.path, b.oid))
.collect();
let head = self.walk_blobs()?;
let head_paths: std::collections::HashSet<&str> =
head.iter().map(|b| b.path.as_str()).collect();
let mut out = Vec::new();
// Added or modified in HEAD relative to base.
for blob in &head {
if base.get(&blob.path) != Some(&blob.oid) {
out.push(ChangedFile {
path: blob.path.clone(),
deleted: false,
});
}
}
// Present in base but gone from HEAD.
for path in base.keys() {
if !head_paths.contains(path.as_str()) {
out.push(ChangedFile {
path: path.clone(),
deleted: true,
});
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
/// Read the bytes of the blob with hex object id `oid`.
///
/// # Errors
/// Returns [`GitError::Git`] if the id is malformed or the object is absent.
pub fn read_blob(&self, oid: &str) -> Result<Vec<u8>, GitError> {
let id = gix::ObjectId::from_hex(oid.as_bytes()).map_err(ge)?;
// `detach()` moves the owned data out without cloning; `Object` itself
// implements `Drop`, so the bare field cannot be moved out directly.
Ok(self.inner.find_object(id).map_err(ge)?.detach().data)
}
/// Tracked files whose working-tree content differs from `HEAD` — the change
/// about to be committed. A file is *changed* when its working-copy bytes hash
/// to a different blob id than the committed one (content, not mtime), and
/// *deleted* when it is absent from the working tree. Untracked new files are
/// not reported (they are not in the `HEAD` tree). Same detection as
/// [`crate::sync_worktree`], surfaced for change-scoped tooling.
///
/// # Errors
/// Returns [`GitError`] on a git failure. In a bare repo (no working tree)
/// the change set is empty.
pub fn changed_files(&self) -> Result<Vec<ChangedFile>, GitError> {
let mut out = Vec::new();
let Some(workdir) = self.workdir() else {
return Ok(out);
};
for blob in self.walk_blobs()? {
match std::fs::read(workdir.join(&blob.path)) {
Ok(bytes) => {
if self.blob_oid(&bytes)? != blob.oid {
out.push(ChangedFile {
path: blob.path,
deleted: false,
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => out.push(ChangedFile {
path: blob.path,
deleted: true,
}),
Err(e) => return Err(GitError::Git(e.to_string())),
}
}
// `walk_blobs` order is an implementation detail; sort so `roteiro review`
// output is deterministic across platforms and gix versions.
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
/// The **staged** files: each regular blob in the git index with its staged
/// object id, sorted by path. This is the tree that a commit would record —
/// unlike [`Repo::changed_files`] (the working tree) — so it lets tooling gate
/// exactly what is about to be committed (the pre-commit index-aware `check`).
/// Conflict (unmerged) entries, directories, submodules and symlinks are
/// skipped.
///
/// # Errors
/// Returns [`GitError`] if the index cannot be loaded or a path is not valid
/// UTF-8.
pub fn index_files(&self) -> Result<Vec<BlobRef>, GitError> {
use gix::index::entry::Mode;
let index = self.inner.index_or_load_from_head().map_err(ge)?;
let mut out = Vec::new();
for entry in index.entries() {
if entry.stage_raw() != 0 || !matches!(entry.mode, Mode::FILE | Mode::FILE_EXECUTABLE) {
continue;
}
let path = String::from_utf8(entry.path(&index).to_vec())
.map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
out.push(BlobRef {
path,
oid: entry.id.to_hex().to_string(),
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
}
/// Collect every blob reachable from `tree`, with full repository-relative paths.
fn walk_tree_blobs(tree: &gix::Tree<'_>) -> Result<Vec<BlobRef>, GitError> {
let mut recorder = gix::traverse::tree::Recorder::default();
tree.traverse().breadthfirst(&mut recorder).map_err(ge)?;
let mut out = Vec::new();
for entry in recorder.records {
if !entry.mode.is_blob() {
continue;
}
let path = String::from_utf8(entry.filepath.into())
.map_err(|e| GitError::NonUtf8Path(e.into_bytes()))?;
out.push(BlobRef {
path,
oid: entry.oid.to_hex().to_string(),
});
}
Ok(out)
}
/// A tracked file that differs between the working tree and `HEAD`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedFile {
/// Repository-relative path.
pub path: String,
/// `true` when the file was removed from the working tree.
pub deleted: bool,
}