| 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