Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Git-backed implementation of [`RepoStore`].
2//!
3//! The only module in the workspace that may reference `gix`. Everything it
4//! returns is a plain `df-store` type, so no Git concept escapes this file.
5//!
6//! Repositories live at `{root}/{shard}/{uuid}.git` where `shard` is the first
7//! two hex characters of the repo UUID. The path derives from the id and never
8//! from a name, so renaming a repository moves nothing on disk (spec §3).
9
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13use async_trait::async_trait;
14use gix::ThreadSafeRepository;
15use lru::LruCache;
16
17use crate::path as safe_path;
18use crate::{
19 Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, EntryKind, MergeOutcome, RepoId,
20 RepoStore, Result, RevId, Revision, Signature, StoreError, TreeEntry,
21};
22
23mod conflicts;
24mod convert;
25mod diff;
26mod edit;
27mod merge;
28
29/// Bounded cache of opened repositories.
30///
31/// Opening is cheap but not free, and pack index loading is not (spec §3).
32/// `ThreadSafeRepository` is shared; each operation takes a cheap thread-local
33/// handle from it.
34const REPO_CACHE_SIZE: usize = 128;
35
36/// Blobs larger than this are not read into memory for rendering. Callers get
37/// [`StoreError::TooLarge`] and offer a download instead (spec §9).
38const DEFAULT_MAX_BLOB: u64 = 8 * 1024 * 1024;
39
40pub struct GitStore {
41 root: PathBuf,
42 cache: Arc<Mutex<LruCache<RepoId, Arc<ThreadSafeRepository>>>>,
43 max_blob_bytes: u64,
44}
45
46impl GitStore {
47 pub fn new(root: impl Into<PathBuf>) -> Self {
48 GitStore {
49 root: root.into(),
50 cache: Arc::new(Mutex::new(LruCache::new(
51 std::num::NonZeroUsize::new(REPO_CACHE_SIZE).expect("cache size is non-zero"),
52 ))),
53 max_blob_bytes: DEFAULT_MAX_BLOB,
54 }
55 }
56
57 pub fn with_max_blob_bytes(mut self, n: u64) -> Self {
58 self.max_blob_bytes = n;
59 self
60 }
61
62 /// On-disk location for a repository.
63 ///
64 /// Sharded by the first two characters of the UUID so no single directory
65 /// accumulates every repository.
66 pub fn repo_path(&self, id: RepoId) -> PathBuf {
67 let hex = id.0.simple().to_string();
68 self.root.join(&hex[..2]).join(format!("{hex}.git"))
69 }
70
71 /// Open a repository, using the cache.
72 fn open(&self, id: RepoId) -> Result<Arc<ThreadSafeRepository>> {
73 if let Some(r) = self.cache.lock().expect("repo cache poisoned").get(&id) {
74 return Ok(r.clone());
75 }
76
77 let path = self.repo_path(id);
78 if !path.is_dir() {
79 return Err(StoreError::NoSuchRepo);
80 }
81
82 let repo = ThreadSafeRepository::open(&path).map_err(|e| {
83 // A directory that exists but will not open is a corrupt repo, not
84 // a missing one — worth distinguishing in the logs.
85 tracing::error!(%id, ?path, "opening repository failed: {e}");
86 StoreError::NoSuchRepo
87 })?;
88
89 let repo = Arc::new(repo);
90 self.cache
91 .lock()
92 .expect("repo cache poisoned")
93 .put(id, repo.clone());
94 Ok(repo)
95 }
96
97 /// Run a blocking Git operation off the async runtime.
98 async fn with_repo<T, F>(&self, id: RepoId, f: F) -> Result<T>
99 where
100 T: Send + 'static,
101 F: FnOnce(&gix::Repository) -> Result<T> + Send + 'static,
102 {
103 let repo = self.open(id)?;
104 tokio::task::spawn_blocking(move || {
105 // `to_thread_local` is the supported way to get a usable handle
106 // from a shared repository.
107 let local = repo.to_thread_local();
108 f(&local)
109 })
110 .await
111 .map_err(|e| StoreError::Other(anyhow::anyhow!("git task panicked: {e}")))?
112 }
113}
114
115#[async_trait]
116impl RepoStore for GitStore {
117 async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()> {
118 let path = self.repo_path(id);
119 let default_bookmark = default_bookmark.to_string();
120
121 tokio::task::spawn_blocking(move || -> Result<()> {
122 if let Some(parent) = path.parent() {
123 std::fs::create_dir_all(parent)
124 .map_err(|e| StoreError::Other(anyhow::anyhow!("creating shard dir: {e}")))?;
125 }
126
127 let repo = gix::init_bare(&path)
128 .map_err(|e| StoreError::Other(anyhow::anyhow!("git init --bare: {e}")))?;
129
130 // Point HEAD at the configured default bookmark. Without this the
131 // repo advertises `master` while the content lands on `main`, and
132 // a fresh clone checks out nothing.
133 let head_path = repo.path().join("HEAD");
134 std::fs::write(&head_path, format!("ref: refs/heads/{default_bookmark}\n"))
135 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing HEAD: {e}")))?;
136
137 Ok(())
138 })
139 .await
140 .map_err(|e| StoreError::Other(anyhow::anyhow!("create task panicked: {e}")))?
141 }
142
143 async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()> {
144 let dir = self.repo_path(id);
145 let hook_binary = hook_binary.to_string();
146 let repo_id = id.0.to_string();
147
148 tokio::task::spawn_blocking(move || -> Result<()> {
149 let hooks = dir.join("hooks");
150 std::fs::create_dir_all(&hooks)
151 .map_err(|e| StoreError::Other(anyhow::anyhow!("creating hooks dir: {e}")))?;
152
153 // git requires an executable file at hooks/pre-receive. The binary
154 // needs the repo id, and git does not pass one, so a two-line
155 // exec wrapper carries it in the environment. The *validation* is
156 // still the compiled binary — this stub contains no logic (spec §4).
157 let script = format!(
158 "#!/bin/sh\nDOGFOOD_REPO_ID={repo_id} exec {hook_binary}\n"
159 );
160
161 let path = hooks.join("pre-receive");
162 std::fs::write(&path, script)
163 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing pre-receive: {e}")))?;
164
165 #[cfg(unix)]
166 {
167 use std::os::unix::fs::PermissionsExt;
168 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
169 .map_err(|e| StoreError::Other(anyhow::anyhow!("chmod pre-receive: {e}")))?;
170 }
171 Ok(())
172 })
173 .await
174 .map_err(|e| StoreError::Other(anyhow::anyhow!("hook install task panicked: {e}")))?
175 }
176
177 async fn delete(&self, id: RepoId) -> Result<()> {
178 // Drop the cached handle first so nothing holds the directory open.
179 self.cache.lock().expect("repo cache poisoned").pop(&id);
180
181 let path = self.repo_path(id);
182 tokio::task::spawn_blocking(move || -> Result<()> {
183 if path.is_dir() {
184 std::fs::remove_dir_all(&path)
185 .map_err(|e| StoreError::Other(anyhow::anyhow!("removing repo: {e}")))?;
186 }
187 Ok(())
188 })
189 .await
190 .map_err(|e| StoreError::Other(anyhow::anyhow!("delete task panicked: {e}")))?
191 }
192
193 async fn exists(&self, id: RepoId) -> bool {
194 self.repo_path(id).is_dir()
195 }
196
197 async fn is_empty(&self, id: RepoId) -> Result<bool> {
198 self.with_repo(id, |repo| {
199 // A repository with no references has received no pushes.
200 let any = repo
201 .references()
202 .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?
203 .all()
204 .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?
205 .filter_map(std::result::Result::ok)
206 .next()
207 .is_some();
208 Ok(!any)
209 })
210 .await
211 }
212
213 async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId> {
214 // Reject control characters before they reach the revision parser; a
215 // spec is user input from a URL.
216 if spec.is_empty() || spec.bytes().any(|b| b < 0x20 || b == 0x7f) {
217 return Err(StoreError::NoSuchRevision);
218 }
219 let spec = spec.to_string();
220
221 self.with_repo(id, move |repo| {
222 let id = repo
223 .rev_parse_single(spec.as_str())
224 .map_err(|_| StoreError::NoSuchRevision)?;
225 Ok(RevId::from_stored(id.to_string()))
226 })
227 .await
228 }
229
230 async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>> {
231 let rev = rev.clone();
232 let rel = safe_path::normalise(&path.to_string_lossy())?;
233
234 self.with_repo(id, move |repo| {
235 let commit = convert::find_commit(repo, &rev)?;
236 let tree = commit
237 .tree()
238 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
239
240 // Descend to the requested subdirectory.
241 let tree = if rel.is_empty() {
242 tree
243 } else {
244 let entry = tree
245 .lookup_entry_by_path(&rel)
246 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
247 .ok_or(StoreError::NoSuchPath)?;
248 let obj = entry
249 .object()
250 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading entry: {e}")))?;
251 obj.try_into_tree().map_err(|_| StoreError::NoSuchPath)?
252 };
253
254 let mut out = Vec::new();
255 for entry in tree.iter() {
256 let entry =
257 entry.map_err(|e| StoreError::Other(anyhow::anyhow!("tree entry: {e}")))?;
258 let name = entry.filename().to_string();
259
260 // jj's conflict machinery lives in the tree. It is storage
261 // detail and must never appear in a file listing (see
262 // docs/change-id-format.md §6).
263 if is_conflict_artifact(&name) {
264 continue;
265 }
266
267 let mode = entry.mode();
268 let kind = convert::entry_kind(mode);
269
270 let size = match kind {
271 EntryKind::File | EntryKind::Symlink => entry
272 .object()
273 .ok()
274 .map(|o| o.data.len() as u64),
275 _ => None,
276 };
277
278 let full = if rel.is_empty() {
279 name.clone()
280 } else {
281 format!("{rel}/{name}")
282 };
283
284 out.push(TreeEntry {
285 name,
286 path: full,
287 kind,
288 size,
289 executable: mode.is_executable(),
290 });
291 }
292
293 // Directories first, then files, each alphabetically — the ordering
294 // every forge uses and users expect.
295 out.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
296 (true, false) => std::cmp::Ordering::Less,
297 (false, true) => std::cmp::Ordering::Greater,
298 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
299 });
300
301 Ok(out)
302 })
303 .await
304 }
305
306 async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob> {
307 let rev = rev.clone();
308 let rel = safe_path::normalise(&path.to_string_lossy())?;
309 if rel.is_empty() {
310 return Err(StoreError::IsDirectory);
311 }
312 let limit = self.max_blob_bytes;
313
314 self.with_repo(id, move |repo| {
315 let commit = convert::find_commit(repo, &rev)?;
316 let tree = commit
317 .tree()
318 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
319
320 let entry = tree
321 .lookup_entry_by_path(&rel)
322 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
323 .ok_or(StoreError::NoSuchPath)?;
324
325 let mode = entry.mode();
326
327 // A repository can contain a symlink to /etc/passwd, and a naive
328 // blob handler will happily serve it (spec §9). We serve the link's
329 // *target text*, never the file it points at — resolution would
330 // escape the repository.
331 match convert::entry_kind(mode) {
332 EntryKind::Directory => return Err(StoreError::IsDirectory),
333 EntryKind::Submodule => return Err(StoreError::NoSuchPath),
334 _ => {}
335 }
336
337 let obj = entry
338 .object()
339 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?;
340
341 let size = obj.data.len() as u64;
342 if size > limit {
343 return Err(StoreError::TooLarge { size, limit });
344 }
345
346 let content = obj.data.clone();
347 let binary = looks_binary(&content);
348
349 // The blob's object id, which is a hash of its bytes — so it is
350 // stable across repositories and across rewrites that did not touch
351 // the file. Callers treat it as opaque.
352 let content_id = obj.id.to_string();
353
354 Ok(Blob { path: rel, content_id, content, size, binary })
355 })
356 .await
357 }
358
359 async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision> {
360 let rev = rev.clone();
361 self.with_repo(id, move |repo| {
362 let commit = convert::find_commit(repo, &rev)?;
363 convert::to_revision(&commit)
364 })
365 .await
366 }
367
368 async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>> {
369 let from = from.clone();
370 // Bounded so a URL cannot ask for the whole history of a large repo.
371 let limit = limit.clamp(1, 1000);
372
373 self.with_repo(id, move |repo| {
374 let commit = convert::find_commit(repo, &from)?;
375 let walk = commit
376 .ancestors()
377 .all()
378 .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
379
380 let mut out = Vec::with_capacity(limit.min(64));
381 for info in walk.take(limit) {
382 let info =
383 info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk error: {e}")))?;
384 let c = repo
385 .find_object(info.id)
386 .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))?
387 .try_into_commit()
388 .map_err(|_| StoreError::NoSuchRevision)?;
389 out.push(convert::to_revision(&c)?);
390 }
391 Ok(out)
392 })
393 .await
394 }
395
396 async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>> {
397 self.with_repo(id, |repo| {
398 let platform = repo
399 .references()
400 .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?;
401
402 let mut out = Vec::new();
403 for r in platform
404 .prefixed("refs/heads/")
405 .map_err(|e| StoreError::Other(anyhow::anyhow!("listing branches: {e}")))?
406 {
407 let mut r = match r {
408 Ok(r) => r,
409 Err(e) => {
410 tracing::warn!("skipping unreadable reference: {e}");
411 continue;
412 }
413 };
414 let Ok(target) = r.peel_to_id_in_place() else {
415 continue;
416 };
417 let name = r.name().shorten().to_string();
418 out.push(Bookmark {
419 name,
420 target: RevId::from_stored(target.to_string()),
421 });
422 }
423
424 out.sort_by(|a, b| a.name.cmp(&b.name));
425 Ok(out)
426 })
427 .await
428 }
429
430 async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>> {
431 let (a, b) = (a.clone(), b.clone());
432 self.with_repo(id, move |repo| {
433 let ca = convert::parse_oid(repo, &a)?;
434 let cb = convert::parse_oid(repo, &b)?;
435 match repo.merge_base(ca, cb) {
436 Ok(id) => Ok(Some(RevId::from_stored(id.to_string()))),
437 // No common ancestor is a legitimate answer, not an error.
438 Err(_) => Ok(None),
439 }
440 })
441 .await
442 }
443
444 async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool> {
445 let (a, b) = (a.clone(), b.clone());
446 self.with_repo(id, move |repo| {
447 let ca = convert::parse_oid(repo, &a)?;
448 let cb = convert::parse_oid(repo, &b)?;
449 if ca == cb {
450 return Ok(true);
451 }
452 // `a` is an ancestor of `b` exactly when it is their merge base.
453 match repo.merge_base(ca, cb) {
454 Ok(base) => Ok(base.detach() == ca),
455 Err(_) => Ok(false),
456 }
457 })
458 .await
459 }
460
461 async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff> {
462 let (from, to) = (from.clone(), to.clone());
463 self.with_repo(id, move |repo| diff::compute(repo, &from, &to, opts))
464 .await
465 }
466
467 async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff> {
468 let rev = rev.clone();
469 self.with_repo(id, move |repo| diff::compute_from_parent(repo, &rev, opts))
470 .await
471 }
472
473 async fn diff_stats(&self, id: RepoId, revs: &[RevId]) -> Result<Vec<Option<(usize, usize)>>> {
474 let revs: Vec<RevId> = revs.to_vec();
475
476 self.with_repo(id, move |repo| {
477 // No context lines: the totals do not depend on them, and asking
478 // for three means building three times the hunk bodies we then
479 // throw away.
480 let opts = DiffOpts { context_lines: 0, ..DiffOpts::default() };
481
482 Ok(revs
483 .iter()
484 .map(|rev| {
485 diff::compute_from_parent(repo, rev, opts)
486 .ok()
487 .map(|d| (d.total_additions, d.total_deletions))
488 })
489 .collect())
490 })
491 .await
492 }
493
494 async fn commit_file(
495 &self,
496 id: RepoId,
497 bookmark: &str,
498 expected_tip: &RevId,
499 path: &str,
500 content: Vec<u8>,
501 message: &str,
502 author: &Signature,
503 ) -> Result<EditOutcome> {
504 let bookmark = bookmark.to_owned();
505 let expected_tip = expected_tip.clone();
506 let path = path.to_owned();
507 let message = message.to_owned();
508 let author = author.clone();
509 self.with_repo(id, move |repo| {
510 edit::commit_file(
511 repo,
512 &bookmark,
513 &expected_tip,
514 &path,
515 &content,
516 &message,
517 &author,
518 )
519 })
520 .await
521 }
522
523 async fn merge(
524 &self,
525 id: RepoId,
526 bookmark: &str,
527 rev: &RevId,
528 message: &str,
529 author: &Signature,
530 ) -> Result<MergeOutcome> {
531 let bookmark = bookmark.to_owned();
532 let rev = rev.clone();
533 let message = message.to_owned();
534 let author = author.clone();
535 self.with_repo(id, move |repo| {
536 merge::merge(repo, &bookmark, &rev, &message, &author)
537 })
538 .await
539 }
540
541 async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>> {
542 let rev = rev.clone();
543 self.with_repo(id, move |repo| conflicts::read(repo, &rev))
544 .await
545 }
546
547 async fn size_bytes(&self, id: RepoId) -> Result<u64> {
548 let path = self.repo_path(id);
549 tokio::task::spawn_blocking(move || -> Result<u64> { Ok(dir_size(&path)) })
550 .await
551 .map_err(|e| StoreError::Other(anyhow::anyhow!("size task panicked: {e}")))?
552 }
553
554 async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<crate::BlameLine>> {
555 let rev = rev.clone();
556 let rel = safe_path::normalise(&path.to_string_lossy())?;
557 if rel.is_empty() {
558 return Err(StoreError::IsDirectory);
559 }
560
561 self.with_repo(id, move |repo| {
562 // Walk history and attribute each line to its introducing commit.
563 // This is a simplified blame: for each commit in the ancestry of
564 // `rev`, check whether the file's blob oid changed compared to the
565 // first parent. Lines present in the current version that were NOT
566 // in the parent's version are attributed to this commit.
567 //
568 // For very large histories this is expensive; bounded to 500 commits
569 // to keep latency predictable.
570 let commit = convert::find_commit(repo, &rev)?;
571 let tree = commit
572 .tree()
573 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
574
575 let entry = tree
576 .lookup_entry_by_path(&rel)
577 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
578 .ok_or(StoreError::NoSuchPath)?;
579
580 let blob_obj = entry
581 .object()
582 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?;
583 let content = std::str::from_utf8(&blob_obj.data)
584 .map_err(|_| StoreError::Other(anyhow::anyhow!("blame requires text file")))?;
585
586 let line_count = content.lines().count();
587 if line_count == 0 {
588 return Ok(Vec::new());
589 }
590
591 // Simple approach: attribute all lines to the most recent commit
592 // that changed the file's blob oid (walking up to 500 commits).
593 // For a proper per-line blame we would need gix's blame support.
594 // This gives a useful approximation: each line is attributed to
595 // the commit that last modified the file.
596 let walk = commit
597 .ancestors()
598 .all()
599 .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
600
601 // Collect commits that touched this file (by checking blob oid changes).
602 let mut attributions: Vec<(gix::ObjectId, gix::ObjectId)> = Vec::new(); // (commit_oid, blob_oid)
603 let mut prev_blob_oid: Option<gix::ObjectId> = None;
604 let mut last_changing_commit_oid: Option<gix::ObjectId> = None;
605
606 for info in walk.take(500) {
607 let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
608 let c = repo
609 .find_object(info.id)
610 .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))?
611 .try_into_commit()
612 .map_err(|_| StoreError::NoSuchRevision)?;
613
614 let t = c
615 .tree()
616 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
617
618 let blob_oid = t
619 .lookup_entry_by_path(&rel)
620 .ok()
621 .flatten()
622 .map(|e| e.object_id());
623
624 if prev_blob_oid.is_none() {
625 // First commit in walk — this is our starting point
626 prev_blob_oid = blob_oid;
627 last_changing_commit_oid = Some(info.id);
628 } else if blob_oid != prev_blob_oid {
629 // The file changed at the previous commit relative to this one
630 break;
631 } else {
632 last_changing_commit_oid = Some(info.id);
633 }
634 }
635
636 // Build blame lines — attribute all lines to the last commit that modified the file
637 let blame_commit_oid = last_changing_commit_oid.unwrap_or_else(|| commit.id().detach());
638 let blame_commit = repo
639 .find_object(blame_commit_oid)
640 .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
641 .try_into_commit()
642 .map_err(|_| StoreError::NoSuchRevision)?;
643
644 let r = convert::to_revision(&blame_commit)?;
645
646 let lines: Vec<crate::BlameLine> = (1..=line_count)
647 .map(|n| crate::BlameLine {
648 rev: r.rev.clone(),
649 author: r.author.name.clone(),
650 when: r.author.when,
651 summary: r.summary().to_string(),
652 line_no: n,
653 })
654 .collect();
655
656 Ok(lines)
657 })
658 .await
659 }
660
661 async fn last_commit_for_path(
662 &self,
663 id: RepoId,
664 rev: &RevId,
665 path: &Path,
666 ) -> Result<Option<Revision>> {
667 let rev = rev.clone();
668 let rel = safe_path::normalise(&path.to_string_lossy())?;
669 if rel.is_empty() {
670 return Ok(None);
671 }
672
673 self.with_repo(id, move |repo| {
674 let commit = convert::find_commit(repo, &rev)?;
675
676 // Walk history and find the first commit where the file's blob oid
677 // differs from its parent's (meaning this commit introduced the change).
678 let walk = commit
679 .ancestors()
680 .all()
681 .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
682
683 let mut prev_blob: Option<gix::ObjectId> = None;
684 let mut result_oid: Option<gix::ObjectId> = None;
685
686 for info in walk.take(500) {
687 let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
688 let c = repo
689 .find_object(info.id)
690 .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
691 .try_into_commit()
692 .map_err(|_| StoreError::NoSuchRevision)?;
693
694 let t = c
695 .tree()
696 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
697
698 let blob_oid = t
699 .lookup_entry_by_path(&rel)
700 .ok()
701 .flatten()
702 .map(|e| e.object_id());
703
704 if prev_blob.is_none() {
705 prev_blob = blob_oid;
706 result_oid = Some(info.id);
707 continue;
708 }
709
710 if blob_oid != prev_blob {
711 // The file changed at result_oid relative to this ancestor
712 break;
713 }
714
715 // File unchanged, keep walking
716 result_oid = Some(info.id);
717 }
718
719 match result_oid {
720 Some(oid) => {
721 let c = repo
722 .find_object(oid)
723 .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
724 .try_into_commit()
725 .map_err(|_| StoreError::NoSuchRevision)?;
726 Ok(Some(convert::to_revision(&c)?))
727 }
728 None => Ok(None),
729 }
730 })
731 .await
732 }
733
734 async fn last_commits_in_dir(
735 &self,
736 id: RepoId,
737 rev: &RevId,
738 dir: &Path,
739 entries: &[String],
740 ) -> Result<std::collections::HashMap<String, Revision>> {
741 let rev = rev.clone();
742 let rel_dir = safe_path::normalise(&dir.to_string_lossy())?;
743 let wanted: std::collections::HashSet<String> = entries.iter().cloned().collect();
744
745 if wanted.is_empty() {
746 return Ok(std::collections::HashMap::new());
747 }
748
749 self.with_repo(id, move |repo| {
750 let commit = convert::find_commit(repo, &rev)?;
751
752 let walk = commit
753 .ancestors()
754 .all()
755 .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
756
757 let mut remaining = wanted;
758 // (entry name -> the commit that last touched it), filled in as the
759 // walk resolves each entry.
760 let mut resolved: std::collections::HashMap<String, gix::ObjectId> =
761 std::collections::HashMap::new();
762
763 'walk: for info in walk.take(500) {
764 let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
765 let c = repo
766 .find_object(info.id)
767 .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
768 .try_into_commit()
769 .map_err(|_| StoreError::NoSuchRevision)?;
770
771 let to_tree = c
772 .tree()
773 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
774
775 // First parent only, matching `diff_from_parent` — a merge's
776 // "last commit" for a path is the mainline change, not every
777 // side branch that happened to touch it too.
778 let from_tree = match c.parent_ids().next() {
779 Some(parent) => repo
780 .find_object(parent.detach())
781 .map_err(|e| StoreError::Other(anyhow::anyhow!("finding parent: {e}")))?
782 .try_into_commit()
783 .map_err(|_| StoreError::NoSuchRevision)?
784 .tree()
785 .map_err(|e| StoreError::Other(anyhow::anyhow!("parent tree: {e}")))?,
786 None => repo.empty_tree(),
787 };
788
789 // Path list only — no blob reads. This is what keeps a whole
790 // directory resolvable in one walk rather than one walk per
791 // file: the expensive part of a diff is reading and comparing
792 // content, and a "which entry did this commit touch" check
793 // never needs it.
794 let mut touched: Vec<String> = Vec::new();
795 from_tree
796 .changes()
797 .map_err(|e| StoreError::Other(anyhow::anyhow!("diffing trees: {e}")))?
798 .for_each_to_obtain_tree(&to_tree, |change| {
799 touched.push(change.location().to_string());
800 Ok::<_, std::convert::Infallible>(
801 gix::object::tree::diff::Action::Continue,
802 )
803 })
804 .map_err(|e| StoreError::Other(anyhow::anyhow!("walking tree diff: {e}")))?;
805
806 for path in &touched {
807 let rest = if rel_dir.is_empty() {
808 Some(path.as_str())
809 } else {
810 path.strip_prefix(&rel_dir).and_then(|s| s.strip_prefix('/'))
811 };
812 let Some(rest) = rest else { continue };
813 let entry_name = rest.split('/').next().unwrap_or(rest);
814 if remaining.remove(entry_name) {
815 resolved.insert(entry_name.to_string(), info.id);
816 }
817 }
818
819 if remaining.is_empty() {
820 break 'walk;
821 }
822 }
823
824 let mut out = std::collections::HashMap::with_capacity(resolved.len());
825 for (name, oid) in resolved {
826 let c = repo
827 .find_object(oid)
828 .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
829 .try_into_commit()
830 .map_err(|_| StoreError::NoSuchRevision)?;
831 out.insert(name, convert::to_revision(&c)?);
832 }
833 Ok(out)
834 })
835 .await
836 }
837}
838
839/// Recursive directory size, used for the repo settings page.
840fn dir_size(path: &Path) -> u64 {
841 let Ok(entries) = std::fs::read_dir(path) else {
842 return 0;
843 };
844 entries
845 .filter_map(std::result::Result::ok)
846 .map(|e| match e.file_type() {
847 Ok(t) if t.is_dir() => dir_size(&e.path()),
848 Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
849 _ => 0,
850 })
851 .sum()
852}
853
854/// jj conflict artefacts, which must never appear in a user-facing listing.
855fn is_conflict_artifact(name: &str) -> bool {
856 name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README"
857}
858
859/// Whether content should be treated as binary.
860///
861/// Git's own heuristic: a NUL byte in the first 8000 bytes. Cheap, and it
862/// agrees with what `git diff` decides, so the UI and the CLI do not disagree
863/// about whether a file is renderable.
864fn looks_binary(content: &[u8]) -> bool {
865 content.iter().take(8000).any(|&b| b == 0)
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use uuid::Uuid;
872
873 #[test]
874 fn repo_paths_are_sharded_and_derived_from_the_id() {
875 let store = GitStore::new("/srv/repos");
876 let id = RepoId(Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap());
877 let p = store.repo_path(id);
878 assert_eq!(
879 p,
880 PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git")
881 );
882 }
883
884 #[test]
885 fn repo_path_never_contains_a_user_supplied_name() {
886 // The property that makes renaming free and makes a hostile repo name
887 // harmless: the path is a function of the UUID alone.
888 let store = GitStore::new("/srv/repos");
889 for _ in 0..100 {
890 let id = RepoId(Uuid::now_v7());
891 let p = store.repo_path(id).to_string_lossy().to_string();
892 assert!(p.starts_with("/srv/repos/"));
893 assert!(!p.contains(".."));
894 assert_eq!(p.matches(".git").count(), 1);
895 }
896 }
897
898 #[test]
899 fn binary_detection_matches_gits_heuristic() {
900 assert!(!looks_binary(b"plain text\nwith newlines\n"));
901 assert!(looks_binary(b"has a \0 nul"));
902 assert!(!looks_binary(&[]));
903 // A NUL past the sniff window is not detected, exactly as in Git.
904 let mut late = vec![b'a'; 9000];
905 late.push(0);
906 assert!(!looks_binary(&late));
907 }
908
909 #[test]
910 fn conflict_artifacts_are_filtered() {
911 assert!(is_conflict_artifact(".jjconflict-side-0"));
912 assert!(is_conflict_artifact(".jjconflict-base-0"));
913 assert!(is_conflict_artifact("JJ-CONFLICT-README"));
914 assert!(!is_conflict_artifact("src"));
915 assert!(!is_conflict_artifact("README.md"));
916 }
917}

917 lines · Rust