| 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 | ||
| 18 | use std::collections::HashMap; | |
| 19 | use std::path::Path; | |
| 20 | ||
| 21 | use async_trait::async_trait; | |
| 22 | ||
| 23 | pub mod git; | |
| 24 | pub mod path; | |
| 25 | pub mod refname; | |
| 26 | ||
| 27 | pub 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)] | |
| 34 | pub 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 | ||
| 55 | pub 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)] | |
| 60 | pub struct RepoId(pub uuid::Uuid); | |
| 61 | ||
| 62 | impl 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)] | |
| 74 | pub struct RevId(String); | |
| 75 | ||
| 76 | impl 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 | ||
| 89 | impl 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. | |
| 111 | pub 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 | ||
| 122 | impl 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)] | |
| 130 | pub 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)] | |
| 138 | pub 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 | ||
| 152 | impl 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)] | |
| 169 | pub 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)] | |
| 179 | pub 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 | ||
| 189 | impl TreeEntry { | |
| 190 | pub fn is_dir(&self) -> bool { | |
| 191 | matches!(self.kind, EntryKind::Directory) | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | /// File content. | |
| 196 | #[derive(Debug, Clone)] | |
| 197 | pub 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 | ||
| 216 | impl 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)] | |
| 228 | pub struct Bookmark { | |
| 229 | pub name: String, | |
| 230 | pub target: RevId, | |
| 231 | } | |
| 232 | ||
| 233 | #[derive(Debug, Clone, Copy)] | |
| 234 | pub struct DiffOpts { | |
| 235 | pub context_lines: u32, | |
| 236 | pub max_files: usize, | |
| 237 | pub max_lines: usize, | |
| 238 | } | |
| 239 | ||
| 240 | impl 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)] | |
| 252 | pub enum ChangeKind { | |
| 253 | Added, | |
| 254 | Modified, | |
| 255 | Deleted, | |
| 256 | Renamed, | |
| 257 | } | |
| 258 | ||
| 259 | #[derive(Debug, Clone)] | |
| 260 | pub 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)] | |
| 272 | pub 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)] | |
| 281 | pub enum DiffLineKind { | |
| 282 | Context, | |
| 283 | Added, | |
| 284 | Deleted, | |
| 285 | } | |
| 286 | ||
| 287 | #[derive(Debug, Clone)] | |
| 288 | pub 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)] | |
| 305 | pub 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)] | |
| 312 | pub 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)] | |
| 327 | pub enum ConflictSide { | |
| 328 | Side(usize), | |
| 329 | Base(usize), | |
| 330 | } | |
| 331 | ||
| 332 | impl 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)] | |
| 348 | pub 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)] | |
| 358 | pub 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)] | |
| 376 | pub 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)] | |
| 388 | pub 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] | |
| 406 | pub 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)] | |
| 549 | mod 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