Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! `df-store` — the storage abstraction (spec §3).
2//!
3//! **This is the most important design constraint in the project.** The point of
4//! Git-backed storage is that migrating to a jj-native store later is a swap of
5//! one implementation, not a rewrite of the product. The rules, enforced in
6//! review and by `scripts/check-store-boundary.sh` in CI:
7//!
8//! 1. No crate other than `df-store` may depend on `gix`.
9//! 2. [`RevId`] is opaque. Do not parse it, do not assume it is 40 hex
10//! characters, do not abbreviate it outside this crate.
11//! 3. Application logic keys off `ChangeId`. `RevId` appears in the UI and in
12//! URLs only where the user is explicitly looking at a specific revision.
13//! 4. The `GitStore` implementation lives in `git/`. A future `JjStore` lives
14//! beside it, and nothing above this trait changes.
15//!
16//! No `gix` type appears anywhere in this module's public API.
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use async_trait::async_trait;
22
23pub mod git;
24pub mod path;
25pub mod refname;
26
27pub use git::GitStore;
28
29/// Errors the application is expected to handle.
30///
31/// Deliberately storage-agnostic: nothing here mentions Git, so a `JjStore`
32/// would produce the same set.
33#[derive(Debug, thiserror::Error)]
34pub enum StoreError {
35 #[error("repository not found")]
36 NoSuchRepo,
37 #[error("revision not found")]
38 NoSuchRevision,
39 #[error("path not found")]
40 NoSuchPath,
41 #[error("path is a directory, not a file")]
42 IsDirectory,
43 #[error("repository is empty")]
44 Empty,
45 #[error("invalid path: {0}")]
46 Path(#[from] path::PathError),
47 #[error("object too large: {size} bytes exceeds the {limit} byte limit")]
48 TooLarge { size: u64, limit: u64 },
49 #[error("operation timed out")]
50 Timeout,
51 #[error(transparent)]
52 Other(#[from] anyhow::Error),
53}
54
55pub type Result<T> = std::result::Result<T, StoreError>;
56
57/// A repository's identity. Storage location derives from this, never from a
58/// user-supplied name — so renaming a repo moves nothing on disk (spec §3).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub struct RepoId(pub uuid::Uuid);
61
62impl std::fmt::Display for RepoId {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 write!(f, "{}", self.0)
65 }
66}
67
68/// An opaque revision handle.
69///
70/// Callers must treat this as a token, not a SHA. It is currently a Git object
71/// id; it may not always be. There is deliberately no `len()`, no indexing, and
72/// no abbreviation method on this type.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
74pub struct RevId(String);
75
76impl RevId {
77 /// Construct from a stored value. Only `df-store` and the database layer
78 /// should call this.
79 pub fn from_stored(s: impl Into<String>) -> Self {
80 RevId(s.into())
81 }
82
83 /// The token, for storage and URLs. Not for parsing.
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89impl RevId {
90 /// The abbreviated form for display.
91 ///
92 /// Rule 2 says callers must not abbreviate a `RevId` themselves, so the
93 /// abbreviation lives here — the one place that is allowed to know how long
94 /// a revision token is and whether truncating it is even meaningful. A
95 /// future `JjStore` changes this method rather than every call site.
96 pub fn short(&self) -> &str {
97 abbreviate_rev(&self.0)
98 }
99}
100
101/// Abbreviate a revision token that has come back from the database as a
102/// plain string.
103///
104/// The database stores `RevId` values as text, so rows read back are `String`,
105/// not `RevId`. This is the sanctioned way to shorten one for display; slicing
106/// it at the call site is what `scripts/check-store-boundary.sh` rejects.
107///
108/// Char-boundary safe: a token is expected to be ASCII, but truncating a
109/// multi-byte string by byte index would panic, and a display helper must not
110/// be able to take the process down.
111pub fn abbreviate_rev(rev: &str) -> &str {
112 const DISPLAY_LEN: usize = 12;
113 if rev.len() <= DISPLAY_LEN {
114 return rev;
115 }
116 match rev.char_indices().nth(DISPLAY_LEN) {
117 Some((idx, _)) => &rev[..idx],
118 None => rev,
119 }
120}
121
122impl std::fmt::Display for RevId {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.write_str(&self.0)
125 }
126}
127
128/// A person, as recorded on a revision.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Signature {
131 pub name: String,
132 pub email: String,
133 pub when: chrono::DateTime<chrono::Utc>,
134}
135
136/// A revision as the application understands it.
137#[derive(Debug, Clone)]
138pub struct Revision {
139 pub rev: RevId,
140 /// Extracted from jj metadata; absent for plain-git commits.
141 pub change_id: Option<String>,
142 pub parents: Vec<RevId>,
143 pub author: Signature,
144 pub committer: Signature,
145 pub message: String,
146 pub conflicted: bool,
147 /// Populated when `conflicted`; the constituent tree tokens.
148 pub conflict_sides: Vec<String>,
149 pub conflict_bases: Vec<String>,
150}
151
152impl Revision {
153 /// First line of the message, for list views.
154 pub fn summary(&self) -> &str {
155 self.message.lines().next().unwrap_or("").trim()
156 }
157
158 /// Everything after the first line.
159 pub fn body(&self) -> &str {
160 match self.message.split_once('\n') {
161 Some((_, rest)) => rest.trim_start_matches('\n'),
162 None => "",
163 }
164 }
165}
166
167/// What a tree entry is.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum EntryKind {
170 File,
171 Directory,
172 /// A symlink. Never followed when serving content (spec §9).
173 Symlink,
174 /// A nested repository (gitlink). Rendered as a stub.
175 Submodule,
176}
177
178#[derive(Debug, Clone)]
179pub struct TreeEntry {
180 pub name: String,
181 /// Repository-relative, normalised.
182 pub path: String,
183 pub kind: EntryKind,
184 /// `None` for directories and submodules.
185 pub size: Option<u64>,
186 pub executable: bool,
187}
188
189impl TreeEntry {
190 pub fn is_dir(&self) -> bool {
191 matches!(self.kind, EntryKind::Directory)
192 }
193}
194
195/// File content.
196#[derive(Debug, Clone)]
197pub struct Blob {
198 pub path: String,
199 /// An opaque content address for this blob's bytes.
200 ///
201 /// Two blobs with the same bytes have the same `content_id`, in this or any
202 /// other repository. That is what makes it a correct cache key for anything
203 /// derived purely from content — syntax highlighting, in particular (spec
204 /// §8: "cache the rendered HTML in Postgres keyed by blob OID").
205 ///
206 /// Opaque under the same rule as [`RevId`]: do not parse it, do not assume
207 /// it is a Git object id, do not abbreviate it.
208 pub content_id: String,
209 pub content: Vec<u8>,
210 pub size: u64,
211 /// Whether the content looks binary, decided by the store so every caller
212 /// agrees.
213 pub binary: bool,
214}
215
216impl Blob {
217 /// UTF-8 text, when the blob is text and valid UTF-8.
218 pub fn text(&self) -> Option<&str> {
219 if self.binary {
220 return None;
221 }
222 std::str::from_utf8(&self.content).ok()
223 }
224}
225
226/// A movable pointer. Cheap, disposable, not an identity (spec §4).
227#[derive(Debug, Clone)]
228pub struct Bookmark {
229 pub name: String,
230 pub target: RevId,
231}
232
233#[derive(Debug, Clone, Copy)]
234pub struct DiffOpts {
235 pub context_lines: u32,
236 pub max_files: usize,
237 pub max_lines: usize,
238}
239
240impl Default for DiffOpts {
241 fn default() -> Self {
242 DiffOpts {
243 // Spec §8: "Collapse unchanged regions to 3 lines of context."
244 context_lines: 3,
245 max_files: 5_000,
246 max_lines: 100_000,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum ChangeKind {
253 Added,
254 Modified,
255 Deleted,
256 Renamed,
257}
258
259#[derive(Debug, Clone)]
260pub struct FileDiff {
261 pub path: String,
262 /// Set for renames.
263 pub old_path: Option<String>,
264 pub kind: ChangeKind,
265 pub binary: bool,
266 pub additions: usize,
267 pub deletions: usize,
268 pub hunks: Vec<Hunk>,
269}
270
271#[derive(Debug, Clone)]
272pub struct Hunk {
273 pub old_start: u32,
274 pub old_lines: u32,
275 pub new_start: u32,
276 pub new_lines: u32,
277 pub lines: Vec<DiffLine>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum DiffLineKind {
282 Context,
283 Added,
284 Deleted,
285}
286
287#[derive(Debug, Clone)]
288pub struct DiffLine {
289 pub kind: DiffLineKind,
290 pub old_lineno: Option<u32>,
291 pub new_lineno: Option<u32>,
292 pub content: String,
293 /// The line broken into runs, with the runs that actually differ from the
294 /// paired line marked (spec §8: "word-level intra-line diffing for changed
295 /// lines").
296 ///
297 /// Always covers the whole line: concatenating `spans` reproduces
298 /// `content`. Context lines get a single unemphasised span, so a renderer
299 /// can use `spans` uniformly and never has to fall back to `content`.
300 pub spans: Vec<DiffSpan>,
301}
302
303/// A run within a diff line.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct DiffSpan {
306 pub text: String,
307 /// Whether this run is part of what changed on this line.
308 pub emphasis: bool,
309}
310
311#[derive(Debug, Clone, Default)]
312pub struct Diff {
313 pub files: Vec<FileDiff>,
314 /// Set when the diff exceeded a limit and was not fully rendered. The UI
315 /// offers the patch download instead of taking the process down (spec §8).
316 pub truncated: bool,
317 pub total_additions: usize,
318 pub total_deletions: usize,
319}
320
321/// Which column of a conflict a piece of content came from.
322///
323/// jj's representation is `side₀ base₀ side₁ …`, and the labels matter to a
324/// reader: a base is the common ancestor the sides diverged from, not another
325/// candidate resolution.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum ConflictSide {
328 Side(usize),
329 Base(usize),
330}
331
332impl ConflictSide {
333 /// A short label for the UI.
334 pub fn label(&self) -> String {
335 match self {
336 ConflictSide::Side(n) => format!("side {}", n + 1),
337 ConflictSide::Base(n) => format!("base {}", n + 1),
338 }
339 }
340
341 pub fn is_base(&self) -> bool {
342 matches!(self, ConflictSide::Base(_))
343 }
344}
345
346/// One file in conflict, with the content each column holds.
347#[derive(Debug, Clone)]
348pub struct ConflictedFile {
349 pub path: String,
350 /// One entry per column, in jj's order. `None` means that column does not
351 /// contain the file at all — a delete/modify conflict, which is exactly the
352 /// case a viewer that only rendered text would silently misreport.
353 pub sides: Vec<(ConflictSide, Option<String>)>,
354}
355
356/// What happened when a change was landed.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum MergeOutcome {
359 /// The bookmark was already at or past this revision. Idempotent, so two
360 /// people clicking merge is a race rather than an error.
361 AlreadyMerged,
362 /// The bookmark was an ancestor, so it simply moved. No commit written.
363 FastForward { new_target: RevId },
364 /// A merge commit was written, with the bookmark as first parent.
365 Merged { new_target: RevId },
366 /// The three-way merge produced a textual conflict.
367 ///
368 /// Nothing was written. Spec §4: resolution happens in the user's working
369 /// copy, so the server must never resolve by writing conflict markers into
370 /// the target bookmark.
371 Conflicted,
372}
373
374/// What happened when a single-file edit was committed.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub enum EditOutcome {
377 Committed { rev: RevId },
378 /// The content is identical to what is already there. No commit is made —
379 /// pressing save on an unmodified file is not a change.
380 Unchanged,
381 /// The bookmark moved since the edit was composed. Nothing was written;
382 /// the caller shows the conflict rather than overwriting somebody's work.
383 Stale { current: RevId },
384}
385
386/// A single line of blame output.
387#[derive(Debug, Clone)]
388pub struct BlameLine {
389 /// The revision that last touched this line.
390 pub rev: RevId,
391 /// Author of that revision.
392 pub author: String,
393 /// When the revision was authored.
394 pub when: chrono::DateTime<chrono::Utc>,
395 /// First line of the commit message.
396 pub summary: String,
397 /// 1-based line number in the final file.
398 pub line_no: usize,
399}
400
401/// Everything the application knows about repository storage.
402///
403/// No `gix` type appears in this trait. No caller may depend on the underlying
404/// store being Git.
405#[async_trait]
406pub trait RepoStore: Send + Sync {
407 async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()>;
408
409 /// Ensure push-time validation is wired up for this repository.
410 ///
411 /// Deliberately named for the intent rather than the mechanism: the Git
412 /// implementation installs a `pre-receive` hook, but a jj-native store
413 /// would enforce the same rules some other way, and nothing above this
414 /// trait should have to care which.
415 async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()>;
416 async fn delete(&self, id: RepoId) -> Result<()>;
417 /// Whether the repository exists on disk.
418 async fn exists(&self, id: RepoId) -> bool;
419 /// Whether the repository has no commits yet.
420 async fn is_empty(&self, id: RepoId) -> Result<bool>;
421
422 async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>>;
423 async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob>;
424 async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff>;
425
426 /// Diff a revision against its first parent.
427 ///
428 /// A root commit has no parent, so it is diffed against an empty tree —
429 /// which is what makes every commit, including the first, yield a patch.
430 /// The empty-tree handling lives here rather than in the caller because it
431 /// is a storage detail; nothing above this trait should know such a thing
432 /// exists.
433 async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff>;
434
435 /// Added/deleted line counts for many revisions, each against its first
436 /// parent.
437 ///
438 /// The change list needs a diffstat per row, and the list is the hottest
439 /// page in the product (spec §4). Calling [`Self::diff_from_parent`] in a
440 /// loop would open the repository once per row and materialise every hunk
441 /// of every patch to count two numbers; this opens it once and keeps only
442 /// the totals.
443 ///
444 /// Best-effort per revision: a revision the store cannot resolve yields
445 /// `None` rather than failing the batch, because one unreadable row must
446 /// not blank out the other ninety-nine.
447 async fn diff_stats(&self, id: RepoId, revs: &[RevId]) -> Result<Vec<Option<(usize, usize)>>>;
448
449 async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>;
450 async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>;
451 async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>;
452 async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>>;
453 async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool>;
454
455 /// Resolve a bookmark name or revision token to a concrete revision.
456 async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId>;
457
458 /// Replace one file on `bookmark` and commit the result.
459 ///
460 /// The write path behind in-browser editing. `expected_tip` is the revision
461 /// the edit was composed against: if the bookmark has moved, nothing is
462 /// written and [`EditOutcome::Stale`] comes back, so a concurrent edit is
463 /// reported rather than silently overwritten.
464 ///
465 /// Always a fast-forward by construction — one new commit whose parent is
466 /// the current tip.
467 #[allow(clippy::too_many_arguments)]
468 async fn commit_file(
469 &self,
470 id: RepoId,
471 bookmark: &str,
472 expected_tip: &RevId,
473 path: &str,
474 content: Vec<u8>,
475 message: &str,
476 author: &Signature,
477 ) -> Result<EditOutcome>;
478
479 /// Land `rev` onto `bookmark`.
480 ///
481 /// Fast-forwards where possible and writes a merge commit otherwise. The
482 /// reference update is a compare-and-swap, so a concurrent push cannot be
483 /// silently discarded. Refuses on conflict without writing anything.
484 async fn merge(
485 &self,
486 id: RepoId,
487 bookmark: &str,
488 rev: &RevId,
489 message: &str,
490 author: &Signature,
491 ) -> Result<MergeOutcome>;
492
493 /// The conflicted files in a revision, with each side's content.
494 ///
495 /// Empty for an unconflicted revision. Expressed in terms of *files and
496 /// sides* rather than trees so the conflict viewer never learns that the
497 /// underlying store represents a conflict as a list of trees (spec §3
498 /// rule 4).
499 async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>>;
500
501 /// Per-line blame for a file at a given revision.
502 ///
503 /// Returns one [`BlameLine`] per line of the file. Blame is expensive;
504 /// callers should only invoke this when the user explicitly requests it
505 /// (e.g. via a toggle).
506 async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<BlameLine>>;
507
508 /// The most recent revision that modified a specific file path.
509 ///
510 /// Walks history from `rev` and returns the first commit whose diff
511 /// against its parent touches `path`. Returns `None` if the path was
512 /// never modified (should not happen for paths that exist).
513 async fn last_commit_for_path(
514 &self,
515 id: RepoId,
516 rev: &RevId,
517 path: &Path,
518 ) -> Result<Option<Revision>>;
519
520 /// The most recent commit that touched each direct child of `dir`, in one
521 /// history walk.
522 ///
523 /// This is [`last_commit_for_path`](Self::last_commit_for_path) generalised
524 /// to a whole directory listing: rather than one history walk per entry —
525 /// which is what a naive per-file implementation of this would cost, and
526 /// what made a directory-listing "last commit" column too expensive to
527 /// ship the first time around — this walks history once and, at each
528 /// commit, checks its changed paths against every entry that has not yet
529 /// been resolved. An entry resolves the first time a changed path falls
530 /// under it; the walk stops early once every entry has resolved, or after
531 /// the same 500-commit bound the single-file lookup uses.
532 ///
533 /// `entries` are names relative to `dir` (not full paths). An entry
534 /// missing from the returned map was not modified within the walk bound —
535 /// callers render that as "no info" rather than treating it as an error.
536 async fn last_commits_in_dir(
537 &self,
538 id: RepoId,
539 rev: &RevId,
540 dir: &Path,
541 entries: &[String],
542 ) -> Result<HashMap<String, Revision>>;
543
544 /// Total on-disk size, for the repo settings page and quota reporting.
545 async fn size_bytes(&self, id: RepoId) -> Result<u64>;
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn revision_summary_and_body_split_on_the_first_line() {
554 let r = Revision {
555 rev: RevId::from_stored("x"),
556 change_id: None,
557 parents: vec![],
558 author: Signature {
559 name: "a".into(),
560 email: "b".into(),
561 when: chrono::Utc::now(),
562 },
563 committer: Signature {
564 name: "a".into(),
565 email: "b".into(),
566 when: chrono::Utc::now(),
567 },
568 message: "the summary\n\nthe body\nmore body\n".into(),
569 conflicted: false,
570 conflict_sides: vec![],
571 conflict_bases: vec![],
572 };
573 assert_eq!(r.summary(), "the summary");
574 assert_eq!(r.body(), "the body\nmore body\n");
575 }
576
577 #[test]
578 fn a_single_line_message_has_no_body() {
579 let mut r = Revision {
580 rev: RevId::from_stored("x"),
581 change_id: None,
582 parents: vec![],
583 author: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() },
584 committer: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() },
585 message: "only a summary".into(),
586 conflicted: false,
587 conflict_sides: vec![],
588 conflict_bases: vec![],
589 };
590 assert_eq!(r.summary(), "only a summary");
591 assert_eq!(r.body(), "");
592 r.message = String::new();
593 assert_eq!(r.summary(), "");
594 }
595
596 #[test]
597 fn abbreviation_lives_in_the_store_and_is_boundary_safe() {
598 assert_eq!(abbreviate_rev("0123456789abcdef0123"), "0123456789ab");
599 assert_eq!(abbreviate_rev("short"), "short");
600 assert_eq!(abbreviate_rev(""), "");
601 // A multi-byte token must not panic on truncation.
602 let multi = "\u{e9}".repeat(20);
603 let out = abbreviate_rev(&multi);
604 assert_eq!(out.chars().count(), 12);
605 assert_eq!(RevId::from_stored("0123456789abcdef").short(), "0123456789ab");
606 }
607
608 #[test]
609 fn binary_blobs_never_yield_text() {
610 let b = Blob {
611 path: "x".into(),
612 content_id: "cid".into(),
613 content: b"hello".to_vec(),
614 size: 5,
615 binary: true,
616 };
617 assert_eq!(b.text(), None, "a blob marked binary must not be rendered as text");
618 }
619}

619 lines · Rust