Skip to main content

grit_lib/snapshot_checkout/
mod.rs

1//! Snapshot checkouts: start work on a repository without cloning its history.
2//!
3//! A server that offers snapshot checkouts (`grit serve` does) answers under
4//! its own endpoint, `/.grit/checkouts/<repository>`, separate from the Git
5//! smart-HTTP paths, so a client can tell a server that has them from one that
6//! doesn't. A checkout is one **snapshot commit**: the target branch's tree,
7//! cut down to the paths asked for (as Git's cone-mode sparse checkout has
8//! them: everything under each path, plus the files directly in every
9//! directory above one), in a commit with fixed metadata whose message names
10//! the real commit it stands for. Because the metadata is fixed, every
11//! checkout of the same paths at the same commit gets the same snapshot.
12//!
13//! The server hands the snapshot out as a bundle, which the client downloads
14//! with [`crate::resumable::download_resumable`] (so an interrupted checkout
15//! resumes) and turns into a new repository whose history is that one commit,
16//! with the snapshot at [`upstream_ref`] (`refs/remotes/origin/<branch>`) and a
17//! new work branch on top of it checked out. Its `origin` fetches only the
18//! checkout's snapshots ([`snapshot_fetch_refspec`], no tags) and pushes to the
19//! check-in area ([`out_push_refspec`]), so plain `git` and `grit` fetches and
20//! pushes in a checkout never bring the full history or touch the server's
21//! real branches.
22//!
23//! - Wire types: [`CheckoutServerInfo`], [`CheckoutRequest`], [`CheckoutInfo`].
24//! - Server side: [`normalize_paths`], [`filtered_tree`], [`snapshot_object`],
25//!   [`make_snapshot`], [`fetch_promised_blobs`], [`project_branch`].
26//! - Client side: [`CheckoutUrl`], [`CheckoutClient`], [`materialize`],
27//!   [`CheckoutMarker`], [`push_for_checkin`], [`fetch_snapshot`], [`refresh`],
28//!   [`fetch_branch`], [`refresh_branch`].
29//!
30//! Work comes back with a **check-in**: the client gives commits made with
31//! plain Git a change id ([`give_change_ids`]), pushes its branches' raw tips
32//! to [`out_ref`] ([`push_for_checkin`]) and asks the server to [`graft`] them
33//! onto the real history ([`graft_checkin_branch`]) and set the server's
34//! branches ([`set_checked_in_branch`], deciding with [`decide_checkin`],
35//! which also accepts a rebase that keeps every commit by change id:
36//! [`keeps_every_commit`]). The
37//! server can [`integrate`] a branch into the target with a merge commit, and
38//! keeps **slices** — named sets of paths — in [`SLICES_PATH`] on the target
39//! branch ([`edit_slices`]). Those are the only ways the target changes: the
40//! server refuses plain pushes to it with a [`TargetGuard`]. A **refresh** asks the server for a snapshot of the newest target on
41//! top of the checkout's last one ([`CheckoutClient::update`]), fetches it from
42//! [`target_ref`] ([`fetch_snapshot`]) and brings the current branch onto it
43//! ([`refresh`]). The client's transfers always go to the URL its marker
44//! records, whatever `origin` has become. Once a checked-in branch has been
45//! integrated into the branch the checkout snapshots, the checkout records
46//! that it landed ([`land_integration`], [`record_landed`]), so refreshes
47//! never replay its commits onto snapshots that already have them, and
48//! [`Landing::move_off`] puts the user on a fresh work branch.
49//!
50//! Checkouts share a branch through **projection**, the reverse of a graft:
51//! the server shows a checkout one of its branches cut down to the
52//! checkout's paths on its snapshots ([`project`], [`project_branch`], asked
53//! for with [`CheckoutClient::branch`]) at [`in_ref`], the client fetches it
54//! ([`fetch_branch`]) and brings its branch of that name onto it
55//! ([`refresh_branch`]). Grafts and projections record the pairs they make in
56//! the repository's [`GraftLog`], so work built on a projected commit grafts
57//! onto the real commit it stands for.
58//!
59//! The server keeps each checkout's record, the work-branch names it has
60//! given out, and the graft log in a [`CheckoutStore`].
61
62mod branch_refresh;
63mod checkin;
64mod graft;
65mod graft_log;
66mod guard;
67mod ids;
68mod landing;
69mod project;
70mod refresh;
71mod refs;
72mod store;
73mod target;
74#[cfg(test)]
75mod testing;
76mod transfer;
77mod tree;
78
79pub use branch_refresh::{refresh_branch, BranchRefresh, BranchRefreshResult};
80pub use checkin::{
81    decide_checkin, graft_checkin_branch, set_checked_in_branch, BranchSet, CheckinDecision,
82    CheckinRefusal, CheckinRule,
83};
84pub use graft::{graft, GraftError, Grafted};
85pub use graft_log::GraftLog;
86pub use guard::TargetGuard;
87pub use ids::{change_keys, give_change_ids, keeps_every_commit};
88pub use landing::{land_integration, move_off_landed_branch, record_landed, Landing, MovedOff};
89pub use project::{project, project_branch, ProjectError, ProjectedCommit, Projection};
90pub use refresh::{
91    ensure_refreshable, refresh, BranchOnOldSnapshot, FollowedBranch, RefreshOutcome, RefreshedHead,
92};
93pub use store::{valid_checkout_id, CheckoutRecord, CheckoutStore, ReservedName, StoredCheckout};
94pub use target::{
95    edit_slices, integrate, missing_paths, parse_slices, read_slices, render_slices,
96    same_branch_name, target_branch, valid_slice_name, Integration, SliceEdit, SlicesEdited,
97    TargetChange, TargetRefusal, SLICES_PATH,
98};
99pub use transfer::{fetch_branch, fetch_snapshot, push_for_checkin, FetchedSnapshot};
100
101use std::collections::BTreeMap;
102use std::path::{Path, PathBuf};
103
104use serde::{Deserialize, Serialize};
105
106use crate::fetch::Progress;
107use crate::objects::{
108    parse_commit, parse_tree, serialize_tree, tree_entry_cmp, ObjectId, ObjectKind, TreeEntry,
109};
110use crate::odb::Odb;
111use crate::progress::Cancel;
112use crate::repo::{InitOptions, Repository};
113use crate::transport::http::{HttpClient, HttpMethod, HttpRequest};
114
115/// Path under the server root where snapshot checkouts are served:
116/// `/.grit/checkouts/<repository>`.
117pub const CHECKOUT_API: &str = ".grit/checkouts";
118
119/// Name and email of every snapshot commit's author and committer.
120pub const SNAPSHOT_IDENT: &str = "grit-checkout <snapshot@grit-checkout>";
121
122/// Trailer naming the real commit a snapshot stands for.
123pub const SNAPSHOT_BASE_TRAILER: &str = "Grit-Checkout-Base";
124
125/// File in the repository's common git directory (shared by all its worktrees)
126/// that marks it as a snapshot checkout.
127pub const MARKER_FILE: &str = "grit-checkout.json";
128
129const TREE_MODE: u32 = 0o040000;
130const GITLINK_MODE: u32 = 0o160000;
131
132/// `GET /.grit/checkouts/<repo>`: what the server would check out.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct CheckoutServerInfo {
135    /// The repository's name.
136    pub name: String,
137    /// The target branch checkouts start from.
138    pub branch: String,
139    /// Where the target branch is.
140    pub head: String,
141}
142
143/// `POST /.grit/checkouts/<repo>`: make a checkout.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct CheckoutRequest {
146    /// Paths to check out; empty is the whole tree.
147    #[serde(default)]
148    pub paths: Vec<String>,
149    /// What the client calls the checkout (its directory name), for the server's log.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub client: Option<String>,
152    /// The work branch name the client would like.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub work: Option<String>,
155}
156
157/// One snapshot commit of a checkout and the real commit it stands for.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct SnapshotRoot {
160    /// The snapshot commit.
161    pub commit: String,
162    /// The real commit of the target branch it stands for.
163    pub base: String,
164}
165
166/// A checkout as the server knows it.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct CheckoutInfo {
169    /// The checkout's id on the server.
170    pub id: String,
171    /// The repository's name, the default directory to check out into.
172    pub name: String,
173    /// The target branch.
174    pub branch: String,
175    /// The checkout's paths (normalized); empty is the whole tree.
176    pub paths: Vec<String>,
177    /// The real commit the newest snapshot stands for.
178    pub base: String,
179    /// The newest snapshot commit.
180    pub root: String,
181    /// That commit's exact bytes, so the client can check it rebuilt the same object.
182    pub root_object: String,
183    /// The snapshot's tree.
184    pub tree: String,
185    /// Every snapshot commit of the checkout, oldest first.
186    pub roots: Vec<SnapshotRoot>,
187    /// Files in the snapshot.
188    pub files: u64,
189    /// Bytes of those files.
190    pub bytes: u64,
191    /// Size of the bundle the snapshot downloads as.
192    pub bundle_bytes: u64,
193    /// The work branch the client should create: the one it asked for, numbered
194    /// when the server already has a branch by that name.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub work: Option<String>,
197}
198
199/// `GET /.grit/checkouts/<repo>/<id>`: a checkout as the server has it, and
200/// where its target branch is now.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct CheckoutState {
203    /// The checkout's record.
204    #[serde(flatten)]
205    pub info: CheckoutInfo,
206    /// Where the target branch is now, which is past [`CheckoutInfo::base`]
207    /// when it moved since the last snapshot; `None` when the server no
208    /// longer has the branch.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub tip: Option<String>,
211}
212
213/// `POST /.grit/checkouts/<repo>/<id>/work`: reserve a fresh work branch for
214/// checkout `id`, as making a checkout does ([`CheckoutRequest::work`]).
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkBranchRequest {
217    /// The name the client would like.
218    pub work: String,
219}
220
221/// The answer to a [`WorkBranchRequest`].
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct WorkBranchResponse {
224    /// The name the server reserved: the one asked for, numbered past the
225    /// server's branches and the names it gave out before. It is never given
226    /// out again.
227    pub work: String,
228}
229
230/// `POST /.grit/checkouts/<repo>/<id>/branch`: show the server's branch
231/// `name` to checkout `id` ([`project_branch`]).
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct BranchRequest {
234    /// The branch, without `refs/heads/`.
235    pub name: String,
236}
237
238/// The answer to a [`BranchRequest`]: the server's branch as the checkout
239/// sees it, at [`in_ref`].
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct BranchInfo {
242    /// The branch, as the server spells it, which can differ in case from the
243    /// name asked for where the server ignores case.
244    pub name: String,
245    /// Where the server's branch is, on its real history.
246    pub tip: String,
247    /// Its projection for the checkout, at [`in_ref`].
248    pub projected: String,
249    /// The branch's commits the target lacks, all projected.
250    pub commits: usize,
251    /// How many of them were made afresh.
252    #[serde(default)]
253    pub made: usize,
254    /// How many were shown as commits that were there already: checkout
255    /// commits on the same snapshots that the server's are grafts of (the
256    /// checkout's own among them), and earlier projections.
257    #[serde(default)]
258    pub reused: usize,
259}
260
261/// The ref a checkout's newest snapshot is kept at on the server.
262#[must_use]
263pub fn target_ref(id: &str) -> String {
264    format!("refs/checkouts/{id}/target")
265}
266
267/// Whether `name` is a valid branch name (`refs/heads/<name>` is a valid ref).
268#[must_use]
269pub fn valid_branch_name(name: &str) -> bool {
270    crate::check_ref_format::check_refname_format(
271        &format!("refs/heads/{name}"),
272        &crate::check_ref_format::RefNameOptions::default(),
273    )
274    .is_ok()
275}
276
277/// Where a checkout pushes its branch `name` for the server to graft.
278#[must_use]
279pub fn out_ref(id: &str, name: &str) -> String {
280    format!("refs/checkouts/{id}/out/heads/{name}")
281}
282
283/// Where the server puts its branch `name` as checkout `id` sees it (its
284/// projection, [`project_branch`]) for the checkout to fetch.
285#[must_use]
286pub fn in_ref(id: &str, name: &str) -> String {
287    format!("refs/checkouts/{id}/in/heads/{name}")
288}
289
290/// The local ref a checkout keeps its newest snapshot of `branch` at:
291/// `refs/remotes/origin/<branch>`.
292#[must_use]
293pub fn upstream_ref(branch: &str) -> String {
294    format!("refs/remotes/origin/{branch}")
295}
296
297/// The refspec that fetches checkout `id`'s newest snapshot (at [`target_ref`]
298/// on the server) into [`upstream_ref`]`(branch)`:
299/// `+refs/checkouts/<id>/target:refs/remotes/origin/<branch>`.
300///
301/// A checkout's `origin` fetches with it, so a plain `git fetch` or
302/// `grit fetch` there brings only snapshots, never the full history.
303#[must_use]
304pub fn snapshot_fetch_refspec(id: &str, branch: &str) -> String {
305    format!("+{}:{}", target_ref(id), upstream_ref(branch))
306}
307
308/// The refspec a checkout's `origin` pushes with:
309/// `+refs/heads/*:refs/checkouts/<id>/out/heads/*`.
310///
311/// A plain `git push` or `grit push` in a checkout then lands where check-ins
312/// graft from ([`out_ref`]), never on the server's real branches.
313#[must_use]
314pub fn out_push_refspec(id: &str) -> String {
315    format!("+refs/heads/*:{}", out_ref(id, "*"))
316}
317
318/// One branch of a check-in: its name and the tip the client pushed to
319/// [`out_ref`].
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct CheckinBranch {
322    /// Branch name (without `refs/heads/`).
323    pub name: String,
324    /// The tip pushed to [`out_ref`].
325    pub raw: String,
326}
327
328/// `POST /.grit/checkouts/<repo>/<id>/checkin`.
329#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
330pub struct CheckinRequest {
331    /// The branches to check in.
332    pub branches: Vec<CheckinBranch>,
333    /// Set a branch even where it would drop commits the server has.
334    #[serde(default)]
335    pub force: bool,
336}
337
338/// What a check-in did to one branch.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum CheckinStatus {
342    /// The server had no such branch; it does now.
343    Created,
344    /// The server's branch moved to the graft.
345    Updated,
346    /// The server's branch already had the branch's commits: it was at the
347    /// graft, or ahead of it.
348    Unchanged,
349    /// The branch was not set.
350    Refused,
351}
352
353/// The result for one branch of a check-in.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct CheckinResult {
356    /// The branch.
357    pub name: String,
358    /// What happened.
359    pub status: CheckinStatus,
360    /// The commit on the server's history the branch's tip became.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub grafted: Option<String>,
363    /// Where the server's branch was before.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub previous: Option<String>,
366    /// Commits replayed.
367    #[serde(default)]
368    pub commits: usize,
369    /// For [`CheckinStatus::Updated`], the rule that allowed the update.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub rule: Option<CheckinRule>,
372    /// Why it was refused, in words.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub message: Option<String>,
375    /// Why it was refused, for programs.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub reason: Option<CheckinRefusal>,
378}
379
380/// The answer to a check-in.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct CheckinResponse {
383    /// One result per branch, in request order.
384    pub results: Vec<CheckinResult>,
385}
386
387/// Who did something.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389pub struct Ident {
390    /// Name.
391    pub name: String,
392    /// Email address.
393    pub email: String,
394}
395
396impl Ident {
397    /// Who the user's own commits are by, for a server-made commit made for
398    /// them (an integration's merge, a slice change): the author identity a
399    /// commit gets, from `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL`, then
400    /// `author.*`, then `user.*` (see
401    /// [`crate::config::ident_resolve::resolve_name_with`]).
402    ///
403    /// # Parameters
404    ///
405    /// - `env`: the environment to read `GIT_AUTHOR_*` and fallbacks from.
406    /// - `config`: the repository's configuration.
407    ///
408    /// # Errors
409    ///
410    /// [`crate::config::ident_resolve::IdentityError`] when no name or email
411    /// is configured; a server then uses the signed-in user.
412    pub fn author<E: crate::ident_resolve::IdentityEnv>(
413        env: &E,
414        config: &crate::config::ConfigSet,
415    ) -> std::result::Result<Self, crate::ident_resolve::IdentityError> {
416        use crate::ident_resolve::{resolve_email_with, resolve_name_with, IdentRole};
417        Ok(Self {
418            name: resolve_name_with(env, config, IdentRole::Author)?,
419            email: resolve_email_with(env, config, IdentRole::Author)?,
420        })
421    }
422
423    /// The signature of a commit by this identity at `time`, cleaned of
424    /// anything that would break the commit's header
425    /// ([`crate::ident::Signature::cleaned`]), since a server takes
426    /// identities from its requests. A name that cleans away to nothing is
427    /// replaced by the email.
428    ///
429    /// # Errors
430    ///
431    /// [`crate::ident::SignatureError`] for a `time` before the epoch or with
432    /// a sub-minute offset.
433    pub fn signature(
434        &self,
435        time: time::OffsetDateTime,
436    ) -> std::result::Result<crate::ident::Signature, crate::ident::SignatureError> {
437        let signature = crate::ident::Signature::cleaned(&self.name, &self.email, time)?;
438        if signature.name.is_empty() {
439            return Ok(crate::ident::Signature {
440                name: signature.email.clone(),
441                ..signature
442            });
443        }
444        Ok(signature)
445    }
446}
447
448/// `POST /.grit/checkouts/<repo>/integrate`: merge a branch into the target.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct IntegrateRequest {
451    /// The branch on the server.
452    pub name: String,
453    /// Who the merge commit is by (the signed-in user when absent).
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub author: Option<Ident>,
456}
457
458/// What an integration did.
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(rename_all = "snake_case")]
461pub enum IntegrateStatus {
462    /// A merge commit moved the target.
463    Merged,
464    /// The target already had every commit of the branch.
465    AlreadyIntegrated,
466}
467
468/// The answer to an integration.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct IntegrateResponse {
471    /// What happened.
472    pub status: IntegrateStatus,
473    /// The branch.
474    pub name: String,
475    /// The target branch.
476    pub target: String,
477    /// Where the target was.
478    pub previous: String,
479    /// Where it is now.
480    pub head: String,
481    /// The branch's commits the merge brought in.
482    pub commits: usize,
483}
484
485/// A named set of paths to check out together.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Slice {
488    /// The slice's name.
489    pub name: String,
490    /// What it is for.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub description: Option<String>,
493    /// Its paths.
494    pub paths: Vec<String>,
495}
496
497/// `GET /.grit/checkouts/<repo>/slices`.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct SlicesResponse {
500    /// The target branch.
501    pub target: String,
502    /// Where it is.
503    pub head: String,
504    /// The slices on it.
505    pub slices: Vec<Slice>,
506}
507
508/// `PUT /.grit/checkouts/<repo>/slices/<name>`: make or change a slice.
509#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
510pub struct SliceRequest {
511    /// The slice's paths.
512    pub paths: Vec<String>,
513    /// What it is for.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub description: Option<String>,
516    /// Make a new slice, refusing a name that exists; otherwise change one
517    /// that does.
518    #[serde(default)]
519    pub create: bool,
520    /// Who the commit is by (the signed-in user when absent).
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub author: Option<Ident>,
523}
524
525/// `DELETE /.grit/checkouts/<repo>/slices/<name>`.
526#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
527pub struct SliceDeleteRequest {
528    /// Who the commit is by (the signed-in user when absent).
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub author: Option<Ident>,
531}
532
533/// What a change to the slices did.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535pub struct SliceChange {
536    /// The slice as it is now; none once deleted.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub slice: Option<Slice>,
539    /// The target branch.
540    pub target: String,
541    /// Where it was.
542    pub previous: String,
543    /// Where it is: a commit changing the slices file, or `previous` when
544    /// nothing changed.
545    pub head: String,
546}
547
548/// An error body the checkout endpoints answer with.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct CheckoutErrorBody {
551    /// What went wrong.
552    pub error: String,
553}
554
555/// Why a snapshot checkout failed.
556#[derive(Debug, thiserror::Error)]
557pub enum CheckoutError {
558    /// The server has no snapshot-checkout endpoint for this repository.
559    #[error("{url} offers no grit checkout (no {endpoint} endpoint); it may be a plain Git server, or the repository may not exist")]
560    NotOffered {
561        /// The URL the user gave.
562        url: String,
563        /// The endpoint that was missing.
564        endpoint: String,
565    },
566    /// The server refused a request.
567    #[error("{url}: {message} (HTTP {status})")]
568    Server {
569        /// The request URL.
570        url: String,
571        /// The response status.
572        status: u16,
573        /// The server's explanation.
574        message: String,
575    },
576    /// The URL cannot name a repository.
577    #[error("{0} is not an http(s) URL of a repository")]
578    InvalidUrl(String),
579    /// A path is not a plain path inside the repository.
580    #[error("{path}: {reason}")]
581    InvalidPath {
582        /// The path as given.
583        path: String,
584        /// What is wrong with it.
585        reason: &'static str,
586    },
587    /// A path is not in the tree being checked out.
588    #[error("{path} is not in the repository at {base}")]
589    MissingPath {
590        /// The path.
591        path: String,
592        /// The commit it was looked up in.
593        base: String,
594    },
595    /// The downloaded snapshot is not the one the server described.
596    #[error("the downloaded snapshot is {got}, but the server described {expected}")]
597    Mismatch {
598        /// The snapshot commit the server described.
599        expected: String,
600        /// What the download held.
601        got: String,
602    },
603    /// The destination already has files.
604    #[error("{0} already exists and is not empty")]
605    DestinationNotEmpty(PathBuf),
606    /// A name is not a valid branch name.
607    #[error("{0} is not a valid branch name")]
608    InvalidBranchName(String),
609    /// A checkout's stored record could not be read.
610    #[error("the record of checkout {id} at {path} is damaged: {detail}")]
611    DamagedRecord {
612        /// The checkout's id.
613        id: String,
614        /// The record's file.
615        path: PathBuf,
616        /// What was wrong with it.
617        detail: String,
618    },
619    /// A ref kept moving while the server tried to set it.
620    #[error("{refname} kept moving while it was being set")]
621    RefKeptMoving {
622        /// The ref.
623        refname: String,
624    },
625    /// Every work-branch name the server reserved is a branch in the
626    /// checkout already.
627    #[error("the work branches the server reserved are taken here (the last was {name})")]
628    WorkBranchTaken {
629        /// The last name the server reserved.
630        name: String,
631    },
632    /// The snapshot fetched is not the one the server described: the
633    /// checkout was refreshed again (from another worktree, say) meanwhile.
634    #[error("the server's snapshot moved again while fetching: expected {expected}, got {got}")]
635    SnapshotMoved {
636        /// The snapshot the server described.
637        expected: String,
638        /// What the fetch brought into the checkout's upstream ref.
639        got: String,
640    },
641    /// The projection of a server branch fetched is not the one the server
642    /// described: the branch was shown to the checkout again meanwhile.
643    #[error("the server's {name} moved again while fetching: expected {expected}, got {got}")]
644    BranchMoved {
645        /// The branch.
646        name: String,
647        /// The projection the server described.
648        expected: String,
649        /// What the fetch brought.
650        got: String,
651    },
652    /// A local branch shares no commit with the server's branch of its name,
653    /// as the checkout sees it, and builds on none of its snapshots.
654    #[error("{name} has nothing in common with the server's {name}")]
655    UnrelatedBranch {
656        /// The branch.
657        name: String,
658    },
659    /// A branch that `HEAD` was switched to for its rebase couldn't be
660    /// rebased, and `HEAD` couldn't be switched back: it is still on the
661    /// branch.
662    #[error("{failed}; HEAD is still on {branch}, which it was switched to for the rebase")]
663    StillSwitched {
664        /// The branch `HEAD` is on now.
665        branch: String,
666        /// Why the rebase failed.
667        failed: Box<CheckoutError>,
668    },
669    /// The checkout is in the middle of an operation (a stopped rebase, a
670    /// merge) that has to be finished or aborted first.
671    #[error("a {} is in progress", .0.description())]
672    OperationInProgress(crate::state::InProgressOperation),
673    /// A fetch from or push to the server failed.
674    #[error(transparent)]
675    Remote(Box<crate::remote::RemoteError>),
676    /// Bringing the current branch onto the new snapshot failed.
677    #[error(transparent)]
678    Pull(Box<crate::porcelain::pull::PullError>),
679    /// The server's answer could not be parsed.
680    #[error("unexpected answer from {url}: {detail}")]
681    Protocol {
682        /// The request URL.
683        url: String,
684        /// What was wrong.
685        detail: String,
686    },
687    /// The download failed.
688    #[error(transparent)]
689    Download(Box<crate::resumable::ResumableError>),
690    /// The snapshot needs blobs the repository keeps on a promisor remote (a
691    /// large-object promisor), and lazy fetch is off, so they cannot be brought in.
692    /// As for upload-pack, lazy fetch is turned on with `uploadpack.lazyFetch` or
693    /// `GIT_NO_LAZY_FETCH=0` on the server (see [`fetch_promised_blobs`]).
694    #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and this server does not fetch from its promisor remotes (uploadpack.lazyFetch is off)")]
695    PromisedBlobs {
696        /// The first missing blob.
697        oid: ObjectId,
698        /// How many blobs the snapshot lacks.
699        count: usize,
700    },
701    /// The snapshot's blobs kept on a promisor remote were fetched, but the fetch
702    /// failed or did not bring them all.
703    #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and fetching them failed: {source}")]
704    PromisedBlobsNotFetched {
705        /// The first blob still missing.
706        oid: ObjectId,
707        /// How many blobs were missing before the fetch.
708        count: usize,
709        /// Why the fetch failed.
710        #[source]
711        source: Box<crate::error::Error>,
712    },
713    /// A repository operation failed.
714    #[error(transparent)]
715    Library(Box<crate::error::Error>),
716    /// A file operation failed.
717    #[error(transparent)]
718    Io(#[from] std::io::Error),
719}
720
721impl From<crate::error::Error> for CheckoutError {
722    fn from(e: crate::error::Error) -> Self {
723        Self::Library(Box::new(e))
724    }
725}
726
727impl From<crate::resumable::ResumableError> for CheckoutError {
728    fn from(e: crate::resumable::ResumableError) -> Self {
729        Self::Download(Box::new(e))
730    }
731}
732
733impl From<crate::remote::RemoteError> for CheckoutError {
734    fn from(e: crate::remote::RemoteError) -> Self {
735        Self::Remote(Box::new(e))
736    }
737}
738
739impl From<crate::porcelain::pull::PullError> for CheckoutError {
740    fn from(e: crate::porcelain::pull::PullError) -> Self {
741        Self::Pull(Box::new(e))
742    }
743}
744
745/// Result alias for snapshot checkouts.
746pub type Result<T> = std::result::Result<T, CheckoutError>;
747
748/// Clean up requested paths: relative, without `.`, `..` or empty components,
749/// without trailing slashes, sorted, deduplicated, and with paths inside
750/// another one dropped.
751///
752/// # Errors
753///
754/// [`CheckoutError::InvalidPath`] for an absolute path or one with `.`, `..`
755/// or empty components.
756pub fn normalize_paths(paths: &[String]) -> Result<Vec<String>> {
757    let mut out: Vec<String> = Vec::new();
758    for p in paths {
759        let trimmed = p.trim().trim_matches('/');
760        if trimmed.is_empty() {
761            continue;
762        }
763        if p.trim().starts_with('/') {
764            return Err(CheckoutError::InvalidPath {
765                path: p.clone(),
766                reason: "paths are relative to the repository root",
767            });
768        }
769        if trimmed
770            .split('/')
771            .any(|s| s.is_empty() || s == "." || s == "..")
772        {
773            return Err(CheckoutError::InvalidPath {
774                path: p.clone(),
775                reason: "not a plain path inside the repository",
776            });
777        }
778        out.push(trimmed.to_owned());
779    }
780    out.sort();
781    out.dedup();
782    let all = out.clone();
783    out.retain(|p| {
784        !all.iter().any(|q| {
785            q != p && p.starts_with(q.as_str()) && p.as_bytes().get(q.len()) == Some(&b'/')
786        })
787    });
788    Ok(out)
789}
790
791/// The entry at `path` (slash-separated) under `tree`, as `(mode, oid)`.
792fn lookup(odb: &Odb, tree: ObjectId, path: &[u8]) -> crate::error::Result<Option<(u32, ObjectId)>> {
793    let mut current = (TREE_MODE, tree);
794    for component in path.split(|b| *b == b'/') {
795        if current.0 != TREE_MODE {
796            return Ok(None);
797        }
798        let entries = parse_tree(&odb.read(&current.1)?.data)?;
799        match entries.into_iter().find(|e| e.name == component) {
800            Some(e) => current = (e.mode, e.oid),
801            None => return Ok(None),
802        }
803    }
804    Ok(Some(current))
805}
806
807/// A tree being assembled from entries at full paths.
808#[derive(Default)]
809struct Dir {
810    entries: BTreeMap<Vec<u8>, Node>,
811}
812
813enum Node {
814    Entry(u32, ObjectId),
815    Dir(Dir),
816}
817
818impl Dir {
819    fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
820        match path.iter().position(|b| *b == b'/') {
821            None => {
822                self.entries.insert(path.to_vec(), Node::Entry(mode, oid));
823            }
824            Some(i) => {
825                let node = self
826                    .entries
827                    .entry(path[..i].to_vec())
828                    .or_insert_with(|| Node::Dir(Dir::default()));
829                if let Node::Dir(dir) = node {
830                    dir.insert(&path[i + 1..], mode, oid);
831                }
832            }
833        }
834    }
835
836    fn write(self, odb: &Odb) -> crate::error::Result<ObjectId> {
837        let mut entries = Vec::with_capacity(self.entries.len());
838        for (name, node) in self.entries {
839            let (mode, oid) = match node {
840                Node::Entry(mode, oid) => (mode, oid),
841                Node::Dir(dir) => (TREE_MODE, dir.write(odb)?),
842            };
843            entries.push(TreeEntry { mode, name, oid });
844        }
845        entries.sort_by(|a, b| {
846            tree_entry_cmp(&a.name, a.mode == TREE_MODE, &b.name, b.mode == TREE_MODE)
847        });
848        odb.write(ObjectKind::Tree, &serialize_tree(&entries))
849    }
850}
851
852/// `tree` cut down to `paths`: each path's entry where it is, and the files
853/// directly in every directory above one, the top level included, as Git's
854/// cone-mode sparse checkout has them. Selected directories' trees are reused
855/// as they are, so only the root and the directories above nested paths are
856/// written. No paths is the whole tree.
857///
858/// # Errors
859///
860/// [`CheckoutError::MissingPath`] when `tree` has no entry at one of `paths`,
861/// and object read or write failures.
862pub fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
863    cut_tree(odb, tree, paths, true)
864}
865
866/// [`filtered_tree`], skipping paths `tree` doesn't have instead of failing.
867pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
868    cut_tree(odb, tree, paths, false)
869}
870
871/// Whether `path` (a file's slash-separated path) is in the view `paths`
872/// give, as [`filtered_tree`] cuts it: under one of them, or directly in a
873/// directory above one (the top level included). No paths is the whole tree.
874pub(crate) fn in_view(path: &[u8], paths: &[String]) -> bool {
875    if paths.is_empty() {
876        return true;
877    }
878    let parent = path
879        .iter()
880        .rposition(|b| *b == b'/')
881        .map_or(&b""[..], |i| &path[..i]);
882    let under = |dir: &[u8], p: &[u8]| p.starts_with(dir) && p.get(dir.len()) == Some(&b'/');
883    parent.is_empty()
884        || paths
885            .iter()
886            .map(String::as_bytes)
887            .any(|p| path == p || under(p, path) || under(parent, p))
888}
889
890fn cut_tree(odb: &Odb, tree: ObjectId, paths: &[String], strict: bool) -> Result<ObjectId> {
891    if paths.is_empty() {
892        return Ok(tree);
893    }
894    let mut dirs: Vec<&[u8]> = vec![b""];
895    for p in paths {
896        let p = p.as_bytes();
897        dirs.extend(
898            p.iter()
899                .enumerate()
900                .filter(|(_, b)| **b == b'/')
901                .map(|(i, _)| &p[..i]),
902        );
903    }
904    dirs.sort();
905    dirs.dedup();
906    let mut root = Dir::default();
907    for dir in dirs {
908        let oid = if dir.is_empty() {
909            tree
910        } else {
911            match lookup(odb, tree, dir)? {
912                Some((TREE_MODE, oid)) => oid,
913                _ => continue,
914            }
915        };
916        for entry in parse_tree(&odb.read(&oid)?.data)? {
917            if entry.mode == TREE_MODE {
918                continue;
919            }
920            let path = if dir.is_empty() {
921                entry.name.clone()
922            } else {
923                [dir, b"/", &entry.name].concat()
924            };
925            root.insert(&path, entry.mode, entry.oid);
926        }
927    }
928    for p in paths {
929        match lookup(odb, tree, p.as_bytes())? {
930            Some((mode, oid)) => root.insert(p.as_bytes(), mode, oid),
931            None if strict => {
932                return Err(CheckoutError::MissingPath {
933                    path: p.clone(),
934                    base: tree.to_hex(),
935                })
936            }
937            None => {}
938        }
939    }
940    Ok(root.write(odb)?)
941}
942
943/// The bytes of the snapshot commit standing for `base` with tree `tree` on
944/// `parent`.
945///
946/// The author and committer are [`SNAPSHOT_IDENT`] at `base_time` (UTC), and
947/// the message names `base`, repeats its message, and ends with a
948/// [`SNAPSHOT_BASE_TRAILER`] trailer. Nothing else goes in, so the same inputs
949/// always give the same commit.
950#[must_use]
951pub fn snapshot_object(
952    tree: ObjectId,
953    parent: Option<ObjectId>,
954    base: ObjectId,
955    base_time: i64,
956    base_message: &str,
957) -> Vec<u8> {
958    let base = base.to_hex();
959    let who = format!("{SNAPSHOT_IDENT} {base_time} +0000");
960    let mut s = format!("tree {}\n", tree.to_hex());
961    if let Some(p) = parent {
962        s.push_str(&format!("parent {}\n", p.to_hex()));
963    }
964    let subject = base_message.lines().next().unwrap_or_default().trim();
965    let quoted: String = base_message
966        .trim_end()
967        .lines()
968        .map(|l| format!("    {l}").trim_end().to_owned() + "\n")
969        .collect();
970    s.push_str(&format!(
971        "author {who}\ncommitter {who}\n\nSnapshot of {}: {subject}\n\nStands for {base}:\n\n{quoted}\n{SNAPSHOT_BASE_TRAILER}: {base}\n",
972        &base[..base.len().min(12)]
973    ));
974    s.into_bytes()
975}
976
977/// A snapshot commit written into a repository.
978#[derive(Debug, Clone, PartialEq, Eq)]
979pub struct Snapshot {
980    /// The snapshot commit.
981    pub commit: ObjectId,
982    /// Its tree.
983    pub tree: ObjectId,
984    /// The real commit it stands for.
985    pub base: ObjectId,
986    /// The commit's exact bytes.
987    pub object: Vec<u8>,
988    /// Files in the tree (submodules not counted).
989    pub files: u64,
990    /// Bytes of those files.
991    pub bytes: u64,
992}
993
994/// Write the snapshot of `base` filtered to `paths` (see [`filtered_tree`])
995/// on `parent` into `repo`, with the number and size of its files.
996///
997/// A snapshot holds every file it names, so in a repository whose large blobs
998/// live on a promisor remote, the ones the snapshot needs are fetched first
999/// ([`fetch_promised_blobs`]).
1000///
1001/// # Errors
1002///
1003/// [`CheckoutError::MissingPath`] for a path `base` lacks,
1004/// [`CheckoutError::PromisedBlobs`] and [`CheckoutError::PromisedBlobsNotFetched`]
1005/// for blobs on a promisor remote that cannot be fetched, and object read or
1006/// write failures.
1007pub fn make_snapshot(
1008    repo: &Repository,
1009    base: ObjectId,
1010    paths: &[String],
1011    parent: Option<ObjectId>,
1012) -> Result<Snapshot> {
1013    let odb = repo.odb();
1014    let commit = odb.read_commit(&base)?;
1015    let tree = filtered_tree(odb, commit.tree, paths).map_err(|e| match e {
1016        CheckoutError::MissingPath { path, .. } => CheckoutError::MissingPath {
1017            path,
1018            base: base.to_hex(),
1019        },
1020        other => other,
1021    })?;
1022    let time = crate::ident::parse_signature_times(&commit.committer)
1023        .map(|t| t.unix_seconds)
1024        .unwrap_or(0);
1025    let object = snapshot_object(tree, parent, base, time, &commit.message);
1026    fetch_promised_tree_blobs(repo, tree)?;
1027    let id = odb.write(ObjectKind::Commit, &object)?;
1028    let (files, bytes) = tree_stats(odb, tree)?;
1029    Ok(Snapshot {
1030        commit: id,
1031        tree,
1032        base,
1033        object,
1034        files,
1035        bytes,
1036    })
1037}
1038
1039/// Make sure `repo` stores every blob of the snapshot commit `snapshot`, fetching
1040/// the ones it keeps on a promisor remote.
1041///
1042/// A server whose large blobs were offloaded to a large-object promisor lacks
1043/// them locally, but a snapshot (and its bundle) must hold every file. This
1044/// follows upload-pack's rule for a client that accepted no promisor remote: the
1045/// blobs are fetched from the repository's promisor remotes when lazy fetch is on,
1046/// which the host decides by installing a
1047/// [`PromisorFetcher`](crate::promisor_fetch::PromisorFetcher) on the repository
1048/// (as [`crate::serve::ServeOptions::attach_promisor_fetcher`] does for
1049/// upload-pack), and the request fails otherwise. Every missing blob is fetched in
1050/// one request. A repository without promisor remotes is not checked.
1051///
1052/// # Parameters
1053///
1054/// - `repo`: the served repository.
1055/// - `snapshot`: a snapshot commit (or any commit) whose tree must be complete.
1056///
1057/// # Returns
1058///
1059/// How many blobs were fetched (`0` when nothing was missing).
1060///
1061/// # Errors
1062///
1063/// [`CheckoutError::PromisedBlobs`] when blobs are missing and the repository has
1064/// no promisor fetcher, [`CheckoutError::PromisedBlobsNotFetched`] when the fetch
1065/// failed or left some missing, [`CheckoutError::Library`] with
1066/// [`crate::error::Error::ObjectNotFound`] for a missing blob nothing promises,
1067/// and object read failures.
1068pub fn fetch_promised_blobs(repo: &Repository, snapshot: ObjectId) -> Result<usize> {
1069    let tree = parse_commit(&repo.odb().read(&snapshot)?.data)?.tree;
1070    fetch_promised_tree_blobs(repo, tree)
1071}
1072
1073/// [`fetch_promised_blobs`] for the snapshot tree `tree`.
1074fn fetch_promised_tree_blobs(repo: &Repository, tree: ObjectId) -> Result<usize> {
1075    let config = repo.config()?;
1076    if !crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config.as_ref()) {
1077        return Ok(0);
1078    }
1079    let odb = repo.odb();
1080    // Each missing blob with the tree that names it, for the promise check.
1081    let mut blobs: Vec<(ObjectId, ObjectId)> = Vec::new();
1082    let mut seen = crate::objects::OidHashSet::default();
1083    let mut stack = vec![tree];
1084    while let Some(oid) = stack.pop() {
1085        for entry in parse_tree(&odb.read(&oid)?.data)? {
1086            match entry.mode {
1087                TREE_MODE => stack.push(entry.oid),
1088                GITLINK_MODE => {}
1089                _ if seen.insert(entry.oid) => blobs.push((entry.oid, oid)),
1090                _ => {}
1091            }
1092        }
1093    }
1094    let missing: crate::objects::OidHashSet = odb
1095        .missing_stored_oids(blobs.iter().map(|(blob, _)| *blob))?
1096        .into_iter()
1097        .collect();
1098    let Some(&(first, referrer)) = blobs.iter().find(|(blob, _)| missing.contains(blob)) else {
1099        return Ok(0);
1100    };
1101    let promised = crate::serve::promised_objects::PromisorObjects::new(repo)?;
1102    if !promised.is_promised(&first, Some(&referrer))? {
1103        return Err(crate::error::Error::ObjectNotFound(first.to_hex()).into());
1104    }
1105    let count = missing.len();
1106    if repo.caches().promisor_fetcher().is_none() {
1107        return Err(CheckoutError::PromisedBlobs { oid: first, count });
1108    }
1109    let wanted: Vec<ObjectId> = blobs
1110        .iter()
1111        .map(|(blob, _)| *blob)
1112        .filter(|blob| missing.contains(blob))
1113        .collect();
1114    let not_fetched =
1115        |oid: ObjectId, source: crate::error::Error| CheckoutError::PromisedBlobsNotFetched {
1116            oid,
1117            count,
1118            source: Box::new(source),
1119        };
1120    repo.prefetch_promised(&wanted)
1121        .map_err(|e| not_fetched(first, e))?;
1122    odb.reprepare_local_packs();
1123    odb.invalidate_packs();
1124    if let Some(&still) = odb.missing_stored_oids(wanted.iter().copied())?.first() {
1125        return Err(not_fetched(
1126            still,
1127            crate::error::Error::PromisedObjectMissing(still),
1128        ));
1129    }
1130    Ok(count)
1131}
1132
1133/// Files under `tree` and their total size, without reading file contents.
1134fn tree_stats(odb: &Odb, tree: ObjectId) -> crate::error::Result<(u64, u64)> {
1135    let (mut files, mut bytes) = (0u64, 0u64);
1136    let mut stack = vec![tree];
1137    while let Some(oid) = stack.pop() {
1138        for entry in parse_tree(&odb.read(&oid)?.data)? {
1139            match entry.mode {
1140                TREE_MODE => stack.push(entry.oid),
1141                GITLINK_MODE => {}
1142                _ => {
1143                    files += 1;
1144                    bytes += odb.read_info(&entry.oid)?.size;
1145                }
1146            }
1147        }
1148    }
1149    Ok((files, bytes))
1150}
1151
1152/// A repository URL split into what snapshot checkouts need.
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct CheckoutUrl {
1155    /// The repository URL, for Git (`origin`).
1156    pub repo_url: String,
1157    /// The repository's name (last path segment, without `.git`).
1158    pub name: String,
1159    /// The checkout endpoint, `<server>/.grit/checkouts/<path>`.
1160    pub api: String,
1161}
1162
1163impl CheckoutUrl {
1164    /// Split `url` (`http(s)://host[:port]/<path to repository>[.git]`).
1165    ///
1166    /// # Errors
1167    ///
1168    /// [`CheckoutError::InvalidUrl`] when `url` is not http(s) or names no
1169    /// repository.
1170    pub fn parse(url: &str) -> Result<Self> {
1171        let invalid = || CheckoutError::InvalidUrl(url.to_owned());
1172        let parsed = url::Url::parse(url).map_err(|_| invalid())?;
1173        if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1174            return Err(invalid());
1175        }
1176        let path = parsed.path().trim_matches('/');
1177        let path = path.strip_suffix(".git").unwrap_or(path);
1178        if path.is_empty() {
1179            return Err(invalid());
1180        }
1181        let name = path.rsplit('/').next().unwrap_or(path).to_owned();
1182        let mut server = parsed.clone();
1183        server.set_path("");
1184        server.set_query(None);
1185        server.set_fragment(None);
1186        let server = server.as_str().trim_end_matches('/').to_owned();
1187        Ok(Self {
1188            repo_url: format!("{server}/{path}.git"),
1189            name,
1190            api: format!("{server}/{CHECKOUT_API}/{path}"),
1191        })
1192    }
1193}
1194
1195/// Talks to a server's snapshot-checkout endpoint.
1196pub struct CheckoutClient<'a> {
1197    http: &'a dyn HttpClient,
1198    url: CheckoutUrl,
1199    given: String,
1200}
1201
1202impl<'a> CheckoutClient<'a> {
1203    /// A client for the repository at `url`.
1204    ///
1205    /// # Errors
1206    ///
1207    /// [`CheckoutError::InvalidUrl`] as for [`CheckoutUrl::parse`].
1208    pub fn new(http: &'a dyn HttpClient, url: &str) -> Result<Self> {
1209        Ok(Self {
1210            http,
1211            url: CheckoutUrl::parse(url)?,
1212            given: url.to_owned(),
1213        })
1214    }
1215
1216    /// The parsed URL.
1217    #[must_use]
1218    pub fn url(&self) -> &CheckoutUrl {
1219        &self.url
1220    }
1221
1222    fn json<T: serde::de::DeserializeOwned>(
1223        &self,
1224        method: HttpMethod,
1225        url: &str,
1226        body: Option<&[u8]>,
1227    ) -> Result<T> {
1228        let mut request = HttpRequest::new(method, url).header("Accept", "application/json");
1229        if let Some(body) = body {
1230            request = request
1231                .header("Content-Type", "application/json")
1232                .body(body);
1233        }
1234        let response = self.http.send(&request)?;
1235        let status = response.status;
1236        let bytes = response.into_bytes()?;
1237        if status == 404 && url == self.url.api {
1238            return Err(CheckoutError::NotOffered {
1239                url: self.given.clone(),
1240                endpoint: self.url.api.clone(),
1241            });
1242        }
1243        if !(200..300).contains(&status) {
1244            let message = serde_json::from_slice::<CheckoutErrorBody>(&bytes)
1245                .map(|b| b.error)
1246                .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).trim().to_owned());
1247            return Err(CheckoutError::Server {
1248                url: url.to_owned(),
1249                status,
1250                message,
1251            });
1252        }
1253        serde_json::from_slice(&bytes).map_err(|e| CheckoutError::Protocol {
1254            url: url.to_owned(),
1255            detail: e.to_string(),
1256        })
1257    }
1258
1259    /// What the server would check out.
1260    ///
1261    /// # Errors
1262    ///
1263    /// [`CheckoutError::NotOffered`] when the server has no checkout endpoint
1264    /// for the repository, and request failures.
1265    pub fn server_info(&self) -> Result<CheckoutServerInfo> {
1266        self.json(HttpMethod::Get, &self.url.api, None)
1267    }
1268
1269    /// Make a checkout on the server.
1270    ///
1271    /// # Errors
1272    ///
1273    /// [`CheckoutError::NotOffered`], and the server's refusal (a path it
1274    /// doesn't have, an empty repository).
1275    pub fn create(&self, request: &CheckoutRequest) -> Result<CheckoutInfo> {
1276        let body = serde_json::to_vec(request).map_err(|e| CheckoutError::Protocol {
1277            url: self.url.api.clone(),
1278            detail: e.to_string(),
1279        })?;
1280        self.json(HttpMethod::Post, &self.url.api, Some(&body))
1281    }
1282
1283    /// The URL of checkout `id`'s bundle.
1284    #[must_use]
1285    pub fn bundle_url(&self, id: &str) -> String {
1286        format!("{}/{id}/bundle", self.url.api)
1287    }
1288
1289    /// Checkout `id` as the server has it, and where its target branch is now
1290    /// ([`CheckoutState::tip`]). Changes nothing on the server, so a dry run can
1291    /// tell whether a refresh would move.
1292    ///
1293    /// # Errors
1294    ///
1295    /// Request failures, and the server's refusal (no such checkout).
1296    pub fn show(&self, id: &str) -> Result<CheckoutState> {
1297        self.json(HttpMethod::Get, &format!("{}/{id}", self.url.api), None)
1298    }
1299
1300    fn post_json<T: Serialize, R: serde::de::DeserializeOwned>(
1301        &self,
1302        method: HttpMethod,
1303        url: &str,
1304        body: &T,
1305    ) -> Result<R> {
1306        let body = serde_json::to_vec(body).map_err(|e| CheckoutError::Protocol {
1307            url: url.to_owned(),
1308            detail: e.to_string(),
1309        })?;
1310        self.json(method, url, Some(&body))
1311    }
1312
1313    /// Ask the server to graft branches the client pushed to [`out_ref`].
1314    ///
1315    /// # Errors
1316    ///
1317    /// Request failures and the server's refusal of the whole request.
1318    pub fn checkin(&self, id: &str, request: &CheckinRequest) -> Result<CheckinResponse> {
1319        self.post_json(
1320            HttpMethod::Post,
1321            &format!("{}/{id}/checkin", self.url.api),
1322            request,
1323        )
1324    }
1325
1326    /// Ask the server for a snapshot of the newest target branch on top of
1327    /// checkout `id`'s last one (kept at [`target_ref`]).
1328    ///
1329    /// # Errors
1330    ///
1331    /// Request failures.
1332    pub fn update(&self, id: &str) -> Result<CheckoutInfo> {
1333        self.post_json(
1334            HttpMethod::Post,
1335            &format!("{}/{id}/update", self.url.api),
1336            &(),
1337        )
1338    }
1339
1340    /// Reserve a fresh work branch for checkout `id`: `wanted`, numbered past
1341    /// the server's branches and the names it gave out before.
1342    ///
1343    /// # Returns
1344    ///
1345    /// The name reserved, which the server never gives out again.
1346    ///
1347    /// # Errors
1348    ///
1349    /// Request failures, and the server's refusal (no such checkout, a name
1350    /// that is not a valid branch name).
1351    pub fn reserve_work_branch(&self, id: &str, wanted: &str) -> Result<String> {
1352        let reserved: WorkBranchResponse = self.post_json(
1353            HttpMethod::Post,
1354            &format!("{}/{id}/work", self.url.api),
1355            &WorkBranchRequest {
1356                work: wanted.to_owned(),
1357            },
1358        )?;
1359        Ok(reserved.work)
1360    }
1361
1362    /// Ask the server to show its branch `name` to checkout `id`: its
1363    /// projection, put at [`in_ref`] to fetch ([`fetch_branch`]).
1364    ///
1365    /// # Errors
1366    ///
1367    /// Request failures, and the server's refusal (no such branch, the
1368    /// branch the checkout follows, a branch already on the target or older
1369    /// than the checkout's first snapshot).
1370    pub fn branch(&self, id: &str, name: &str) -> Result<BranchInfo> {
1371        self.post_json(
1372            HttpMethod::Post,
1373            &format!("{}/{id}/branch", self.url.api),
1374            &BranchRequest {
1375                name: name.to_owned(),
1376            },
1377        )
1378    }
1379
1380    /// Merge a branch into the target on the server.
1381    ///
1382    /// # Errors
1383    ///
1384    /// Request failures, and the server's refusal (a conflict, no such branch).
1385    pub fn integrate(&self, request: &IntegrateRequest) -> Result<IntegrateResponse> {
1386        self.post_json(
1387            HttpMethod::Post,
1388            &format!("{}/integrate", self.url.api),
1389            request,
1390        )
1391    }
1392
1393    /// The slices on the target branch.
1394    ///
1395    /// # Errors
1396    ///
1397    /// [`CheckoutError::NotOffered`] and request failures.
1398    pub fn slices(&self) -> Result<SlicesResponse> {
1399        self.server_info()?;
1400        self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
1401    }
1402
1403    /// Make or change slice `name`.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Request failures and the server's refusal (a name that exists or
1408    /// doesn't, a path the target lacks).
1409    pub fn put_slice(&self, name: &str, request: &SliceRequest) -> Result<SliceChange> {
1410        self.post_json(
1411            HttpMethod::Put,
1412            &format!("{}/slices/{name}", self.url.api),
1413            request,
1414        )
1415    }
1416
1417    /// Delete slice `name`.
1418    ///
1419    /// # Errors
1420    ///
1421    /// Request failures and the server's refusal (no such slice).
1422    pub fn delete_slice(&self, name: &str, request: &SliceDeleteRequest) -> Result<SliceChange> {
1423        self.post_json(
1424            HttpMethod::Delete,
1425            &format!("{}/slices/{name}", self.url.api),
1426            request,
1427        )
1428    }
1429}
1430
1431/// Local branches with work to check in: every branch whose tip is a commit
1432/// on top of one of the checkout's snapshots (not a snapshot itself).
1433///
1434/// # Errors
1435///
1436/// Ref and ancestry failures.
1437pub fn branches_with_work(
1438    repo: &Repository,
1439    marker: &CheckoutMarker,
1440) -> Result<Vec<(String, ObjectId)>> {
1441    let roots: Vec<ObjectId> = marker
1442        .roots
1443        .iter()
1444        .filter_map(|r| ObjectId::from_hex(&r.commit).ok())
1445        .collect();
1446    let mut out = Vec::new();
1447    for (refname, tip) in crate::refs::list_refs(repo.git_dir(), "refs/heads/")? {
1448        if roots.contains(&tip) {
1449            continue;
1450        }
1451        let mut on_snapshot = false;
1452        for root in &roots {
1453            if crate::merge_base::is_ancestor(repo, *root, tip)? {
1454                on_snapshot = true;
1455                break;
1456            }
1457        }
1458        if on_snapshot {
1459            let name = refname
1460                .strip_prefix("refs/heads/")
1461                .unwrap_or(&refname)
1462                .to_owned();
1463            out.push((name, tip));
1464        }
1465    }
1466    Ok(out)
1467}
1468
1469/// What makes a repository a snapshot checkout: where it came from and which
1470/// snapshot commits it has. Kept as [`MARKER_FILE`] in the repository's common
1471/// git directory, so every worktree of a checkout shares it.
1472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1473pub struct CheckoutMarker {
1474    /// The repository URL it was checked out from.
1475    pub url: String,
1476    /// The checkout's id on the server.
1477    pub id: String,
1478    /// The target branch.
1479    pub branch: String,
1480    /// The checkout's paths.
1481    pub paths: Vec<String>,
1482    /// The snapshot commits, oldest first.
1483    pub roots: Vec<SnapshotRoot>,
1484    /// Tips of branches this checkout checked in that have landed in
1485    /// [`Self::branch`] (the server merged them into it, or it had them
1486    /// already): every later snapshot has their commits' changes, though not
1487    /// as ancestors, so refreshes never replay them ([`land_integration`]).
1488    /// A tip no branch, and no worktree's detached `HEAD`, builds on any
1489    /// more is dropped.
1490    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1491    pub landed: Vec<String>,
1492}
1493
1494/// Where the marker of the repository whose git directory is `git_dir` is:
1495/// in its common git directory, which a linked worktree's `commondir` names.
1496fn marker_path(git_dir: &Path) -> PathBuf {
1497    crate::refs::common_dir(git_dir)
1498        .unwrap_or_else(|| git_dir.to_path_buf())
1499        .join(MARKER_FILE)
1500}
1501
1502impl CheckoutMarker {
1503    /// Read the marker of the repository whose git directory is `git_dir`
1504    /// (the main one or a linked worktree's).
1505    ///
1506    /// # Returns
1507    ///
1508    /// `None` when it is not a snapshot checkout.
1509    #[must_use]
1510    pub fn read(git_dir: &Path) -> Option<Self> {
1511        serde_json::from_slice(&std::fs::read(marker_path(git_dir)).ok()?).ok()
1512    }
1513
1514    /// Write it into the common git directory of the repository whose git
1515    /// directory is `git_dir`, replacing the old one in one rename.
1516    ///
1517    /// # Errors
1518    ///
1519    /// File write failures.
1520    pub fn write(&self, git_dir: &Path) -> Result<()> {
1521        let bytes =
1522            serde_json::to_vec_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
1523        let path = marker_path(git_dir);
1524        let tmp = path.with_file_name(format!("{MARKER_FILE}.tmp"));
1525        std::fs::write(&tmp, bytes)?;
1526        std::fs::rename(&tmp, path)?;
1527        Ok(())
1528    }
1529
1530    /// The checkout's newest snapshot, the last of [`Self::roots`].
1531    #[must_use]
1532    pub fn last(&self) -> Option<&SnapshotRoot> {
1533        self.roots.last()
1534    }
1535
1536    /// The local ref the checkout keeps its newest snapshot at
1537    /// ([`upstream_ref`] of [`Self::branch`]).
1538    #[must_use]
1539    pub fn upstream_ref(&self) -> String {
1540        upstream_ref(&self.branch)
1541    }
1542}
1543
1544/// A checkout built on disk.
1545#[derive(Debug)]
1546pub struct Materialized {
1547    /// The new repository.
1548    pub repo: Repository,
1549    /// The work branch checked out.
1550    pub work: String,
1551    /// Files written to the work tree.
1552    pub files: u64,
1553}
1554
1555/// Build the checkout described by `info` in `dest` from its downloaded
1556/// `bundle`.
1557///
1558/// Creates the repository, unpacks the bundle, checks that the snapshot
1559/// commit is byte for byte the one the server described, points
1560/// [`upstream_ref`] (`refs/remotes/origin/<branch>`, which
1561/// `refs/remotes/origin/HEAD` names) and the new branch `work` at it,
1562/// configures `origin` as `repo_url` fetching [`snapshot_fetch_refspec`] with
1563/// no tags and pushing [`out_push_refspec`], writes the [`CheckoutMarker`] and
1564/// checks out the files.
1565///
1566/// # Errors
1567///
1568/// [`CheckoutError::DestinationNotEmpty`], [`CheckoutError::Mismatch`] for a
1569/// download that is not the described snapshot, and repository failures.
1570#[allow(clippy::too_many_arguments)]
1571pub fn materialize(
1572    dest: &Path,
1573    info: &CheckoutInfo,
1574    bundle: &Path,
1575    repo_url: &str,
1576    work: &str,
1577    progress: &mut dyn Progress,
1578    cancel: &dyn Cancel,
1579) -> Result<Materialized> {
1580    if dest.is_dir() && std::fs::read_dir(dest)?.next().is_some() {
1581        return Err(CheckoutError::DestinationNotEmpty(dest.to_path_buf()));
1582    }
1583    std::fs::create_dir_all(dest)?;
1584    let repo = Repository::init(
1585        dest,
1586        &InitOptions {
1587            initial_branch: Some(work.to_owned()),
1588            ..InitOptions::default()
1589        },
1590    )?;
1591    let opened = repo
1592        .bundles()
1593        .open(bundle)
1594        .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1595    let unbundled = opened
1596        .unbundle_with_progress(&repo, progress, cancel)
1597        .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1598    let mismatch = |got: String| CheckoutError::Mismatch {
1599        expected: info.root.clone(),
1600        got,
1601    };
1602    let root = ObjectId::from_hex(&info.root).map_err(|_| mismatch(info.root.clone()))?;
1603    let tip = unbundled
1604        .refs
1605        .first()
1606        .map(|r| r.oid)
1607        .ok_or_else(|| mismatch("an empty bundle".to_owned()))?;
1608    if tip != root {
1609        return Err(mismatch(tip.to_hex()));
1610    }
1611    let object = repo.odb().read(&root)?;
1612    if object.data != info.root_object.as_bytes() {
1613        return Err(mismatch(format!(
1614            "{} with different contents",
1615            root.to_hex()
1616        )));
1617    }
1618    let git_dir = repo.git_dir().to_path_buf();
1619    let upstream = upstream_ref(&info.branch);
1620    crate::refs::write_ref(&git_dir, &upstream, &root)?;
1621    crate::refs::write_symbolic_ref(&git_dir, "refs/remotes/origin/HEAD", &upstream)?;
1622    crate::refs::write_ref(&git_dir, &format!("refs/heads/{work}"), &root)?;
1623    crate::refs::write_symbolic_ref(&git_dir, "HEAD", &format!("refs/heads/{work}"))?;
1624    let config_path = git_dir.join("config");
1625    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1626    let mut config = crate::config::ConfigFile::parse(
1627        &config_path,
1628        &content,
1629        crate::config::ConfigScope::Local,
1630    )?;
1631    config.set("remote.origin.url", repo_url)?;
1632    config.set(
1633        "remote.origin.fetch",
1634        &snapshot_fetch_refspec(&info.id, &info.branch),
1635    )?;
1636    config.set("remote.origin.push", &out_push_refspec(&info.id))?;
1637    config.set("remote.origin.tagOpt", "--no-tags")?;
1638    config.write()?;
1639    CheckoutMarker {
1640        url: repo_url.to_owned(),
1641        id: info.id.clone(),
1642        branch: info.branch.clone(),
1643        paths: info.paths.clone(),
1644        roots: info.roots.clone(),
1645        landed: Vec::new(),
1646    }
1647    .write(&git_dir)?;
1648    let tree = ObjectId::from_hex(&info.tree).map_err(|_| mismatch(info.tree.clone()))?;
1649    crate::porcelain::checkout::checkout_between_trees_with_progress(
1650        &repo, None, &tree, progress, cancel,
1651    )?;
1652    let files = u64::try_from(repo.index_handle().load()?.entries().len()).unwrap_or(u64::MAX);
1653    Ok(Materialized {
1654        repo,
1655        work: work.to_owned(),
1656        files,
1657    })
1658}
1659
1660const WORDS: &[&str] = &[
1661    "amber", "brook", "cedar", "dune", "ember", "fern", "glade", "harbor", "iris", "juniper",
1662    "kestrel", "lagoon", "maple", "nectar", "orchid", "pine", "quartz", "river", "sage", "thistle",
1663    "umber", "violet", "willow", "yarrow", "zephyr", "aspen", "birch", "coral", "delta", "elm",
1664    "flint", "grove", "hazel", "inlet", "jasper", "lark", "moss", "north", "oak", "pebble",
1665    "quill", "reed", "slate", "tide", "vale", "wren",
1666];
1667
1668/// A work branch name for a new checkout: the local part of `email` and two
1669/// words chosen by `random`, like `alice/quiet-river`.
1670#[must_use]
1671pub fn suggest_work_branch(email: Option<&str>, random: [u8; 2]) -> String {
1672    let who: String = email
1673        .and_then(|e| e.split('@').next())
1674        .unwrap_or_default()
1675        .chars()
1676        .map(|c| {
1677            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1678                c.to_ascii_lowercase()
1679            } else {
1680                '-'
1681            }
1682        })
1683        .collect();
1684    let who = who.trim_matches('-');
1685    let a = WORDS[usize::from(random[0]) % WORDS.len()];
1686    let b = WORDS[usize::from(random[1]) % WORDS.len()];
1687    if who.is_empty() {
1688        format!("checkout/{a}-{b}")
1689    } else {
1690        format!("{who}/{a}-{b}")
1691    }
1692}
1693
1694#[cfg(test)]
1695mod tests {
1696    use super::*;
1697
1698    #[test]
1699    fn idents_resolve_like_commit_authors_and_sign_cleanly() {
1700        let env_of = |vars: &[(&str, &str)]| {
1701            crate::config::environment::Environment::from_vars(
1702                vars.iter().map(|(k, v)| ((*k).into(), (*v).into())),
1703                std::path::PathBuf::from("/"),
1704            )
1705        };
1706        let env = env_of(&[
1707            ("GIT_AUTHOR_NAME", "A U Thor"),
1708            ("GIT_AUTHOR_EMAIL", "author@example.com"),
1709        ]);
1710        let mut config = crate::config::ConfigSet::new();
1711        let _ = config.add_command_override("user.name", "Alice");
1712        let _ = config.add_command_override("user.email", "alice@example.com");
1713        assert_eq!(
1714            Ident::author(&env, &config).ok(),
1715            Some(Ident {
1716                name: "A U Thor".to_owned(),
1717                email: "author@example.com".to_owned()
1718            }),
1719            "the environment wins over config, as for a commit"
1720        );
1721        let bare = env_of(&[]);
1722        assert_eq!(
1723            Ident::author(&bare, &config).ok(),
1724            Some(Ident {
1725                name: "Alice".to_owned(),
1726                email: "alice@example.com".to_owned()
1727            })
1728        );
1729        assert!(Ident::author(&bare, &crate::config::ConfigSet::new()).is_err());
1730
1731        let time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
1732            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1733        let sneaky = Ident {
1734            name: "Eve\nencoding bogus".to_owned(),
1735            email: "e>v<e@x".to_owned(),
1736        };
1737        assert_eq!(
1738            sneaky
1739                .signature(time)
1740                .map(|s| s.header_line())
1741                .ok()
1742                .as_deref(),
1743            Some("Eveencoding bogus <eve@x> 1700000000 +0000")
1744        );
1745        let nameless = Ident {
1746            name: "<>".to_owned(),
1747            email: "bob@example.com".to_owned(),
1748        };
1749        assert_eq!(
1750            nameless.signature(time).map(|s| s.name).ok().as_deref(),
1751            Some("bob@example.com")
1752        );
1753    }
1754
1755    #[test]
1756    fn paths_are_normalized() {
1757        let paths: Vec<String> = ["b/", "a/x", "a", " c ", "", "a/y"]
1758            .iter()
1759            .map(|s| (*s).to_owned())
1760            .collect();
1761        assert_eq!(
1762            normalize_paths(&paths).ok(),
1763            Some(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
1764        );
1765        assert!(normalize_paths(&["/abs".to_owned()]).is_err());
1766        assert!(normalize_paths(&["a/../b".to_owned()]).is_err());
1767        assert!(normalize_paths(&["a//b".to_owned()]).is_err());
1768    }
1769
1770    #[test]
1771    fn views_are_the_paths_and_the_files_above_them() {
1772        let paths = vec!["src/deep".to_owned(), "docs/guide.md".to_owned()];
1773        for (path, seen) in [
1774            ("README", true),
1775            ("src/a.rs", true),
1776            ("src/deep/b.rs", true),
1777            ("src/deep/more/c.rs", true),
1778            ("src/other/d.rs", false),
1779            ("docs/guide.md", true),
1780            ("docs/other.md", true),
1781            ("docs/api/x.md", false),
1782            ("srcx/a.rs", false),
1783            ("src/deeper/a.rs", false),
1784        ] {
1785            assert_eq!(in_view(path.as_bytes(), &paths), seen, "{path}");
1786        }
1787        assert!(in_view(b"anything/at/all", &[]));
1788    }
1789
1790    #[test]
1791    fn urls_split_into_repository_and_endpoint() {
1792        let u = CheckoutUrl::parse("http://h:8080/demo.git").ok();
1793        assert_eq!(
1794            u,
1795            Some(CheckoutUrl {
1796                repo_url: "http://h:8080/demo.git".to_owned(),
1797                name: "demo".to_owned(),
1798                api: "http://h:8080/.grit/checkouts/demo".to_owned(),
1799            })
1800        );
1801        let nested = CheckoutUrl::parse("https://h/org/app/").ok();
1802        assert_eq!(nested.as_ref().map(|u| u.name.as_str()), Some("app"));
1803        assert_eq!(
1804            nested.map(|u| u.api),
1805            Some("https://h/.grit/checkouts/org/app".to_owned())
1806        );
1807        assert!(CheckoutUrl::parse("http://h/").is_err());
1808        assert!(CheckoutUrl::parse("ssh://h/demo").is_err());
1809    }
1810
1811    #[test]
1812    fn snapshot_objects_are_deterministic() {
1813        let tree = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").ok();
1814        let base = ObjectId::from_hex("1111111111111111111111111111111111111111").ok();
1815        let (Some(tree), Some(base)) = (tree, base) else {
1816            panic!("oids");
1817        };
1818        let a = snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n");
1819        assert_eq!(
1820            a,
1821            snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n")
1822        );
1823        let text = String::from_utf8_lossy(&a);
1824        assert!(text.starts_with("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor grit-checkout <snapshot@grit-checkout> 1700000000 +0000\n"), "{text}");
1825        assert!(
1826            text.contains("\n\nSnapshot of 111111111111: Fix it\n"),
1827            "{text}"
1828        );
1829        assert!(
1830            text.ends_with("Grit-Checkout-Base: 1111111111111111111111111111111111111111\n"),
1831            "{text}"
1832        );
1833    }
1834
1835    #[test]
1836    fn work_branches_are_named_for_the_committer() {
1837        assert_eq!(
1838            suggest_work_branch(Some("Alice.B@example.com"), [0, 1]),
1839            "alice-b/amber-brook"
1840        );
1841        assert_eq!(suggest_work_branch(None, [2, 3]), "checkout/cedar-dune");
1842    }
1843
1844    /// A bare repository whose large blob `src/big` lives on the promisor `lop`:
1845    /// its commit and trees are in a promisor pack, `src/a` in an ordinary pack,
1846    /// `src/big` nowhere. Returns the commit and the big blob's id and data.
1847    fn offloaded(dir: &Path) -> (ObjectId, ObjectId, Vec<u8>) {
1848        use crate::objects::ObjectKind;
1849        let repo = Repository::init(dir, &InitOptions::new().with_bare(true)).unwrap();
1850        let odb = repo.odb();
1851        let small = odb
1852            .write_loose_materialize(ObjectKind::Blob, b"a\n")
1853            .unwrap();
1854        let data = vec![b'x'; 4096];
1855        let big = odb
1856            .write_loose_materialize(ObjectKind::Blob, &data)
1857            .unwrap();
1858        let tree_of = |entries: &[(&str, &str, ObjectId)]| {
1859            let mut body = Vec::new();
1860            for (mode, name, oid) in entries {
1861                body.extend_from_slice(format!("{mode} {name}\0").as_bytes());
1862                body.extend_from_slice(oid.as_bytes());
1863            }
1864            odb.write_loose_materialize(ObjectKind::Tree, &body)
1865                .unwrap()
1866        };
1867        let src = tree_of(&[("100644", "a", small), ("100644", "big", big)]);
1868        let root = tree_of(&[("40000", "src", src)]);
1869        let commit =
1870            format!("tree {root}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nc\n");
1871        let commit = odb
1872            .write_loose_materialize(ObjectKind::Commit, commit.as_bytes())
1873            .unwrap();
1874        for (oids, promisor) in [(vec![commit, root, src], true), (vec![small], false)] {
1875            let pack = crate::pack_objects::build_pack_from_send_list(
1876                odb,
1877                &oids,
1878                &std::collections::HashSet::new(),
1879                &crate::pack_objects::PackBuildOptions::default(),
1880                &std::collections::HashMap::new(),
1881            )
1882            .unwrap();
1883            crate::index_pack::install_pack_bytes(
1884                pack,
1885                odb,
1886                &crate::index_pack::IngestPackOptions {
1887                    mark_promisor_pack: promisor,
1888                    ..Default::default()
1889                },
1890            )
1891            .unwrap();
1892        }
1893        crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
1894        let hex = big.to_hex();
1895        std::fs::remove_file(dir.join("objects").join(&hex[..2]).join(&hex[2..])).unwrap();
1896        let mut config = std::fs::read_to_string(dir.join("config")).unwrap();
1897        config.push_str("[remote \"lop\"]\n\turl = /nowhere\n\tpromisor = true\n");
1898        std::fs::write(dir.join("config"), config).unwrap();
1899        crate::pack::clear_pack_cache();
1900        (commit, big, data)
1901    }
1902
1903    /// Writes the blobs it is asked for from `blobs`, or nothing.
1904    struct StubFetcher {
1905        blobs: Vec<(ObjectId, Vec<u8>)>,
1906        calls: std::sync::Mutex<Vec<Vec<ObjectId>>>,
1907    }
1908
1909    impl crate::promisor_fetch::PromisorFetcher for StubFetcher {
1910        fn fetch_missing(&self, repo: &Repository, oids: &[ObjectId]) -> crate::error::Result<()> {
1911            self.calls
1912                .lock()
1913                .unwrap_or_else(|e| e.into_inner())
1914                .push(oids.to_vec());
1915            for (oid, data) in &self.blobs {
1916                if oids.contains(oid) {
1917                    repo.odb()
1918                        .write_loose_materialize(crate::objects::ObjectKind::Blob, data)?;
1919                }
1920            }
1921            Ok(())
1922        }
1923    }
1924
1925    fn open_with(dir: &Path, fetcher: Option<std::sync::Arc<StubFetcher>>) -> Repository {
1926        let fetcher =
1927            fetcher.map(|f| f as std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>);
1928        Repository::open(
1929            dir,
1930            &crate::config::environment::RepositoryOptions::empty().with_promisor_fetcher(fetcher),
1931        )
1932        .unwrap()
1933    }
1934
1935    #[test]
1936    fn a_snapshot_of_offloaded_blobs_is_refused_without_lazy_fetch() {
1937        let tmp = tempfile::tempdir().unwrap();
1938        let (commit, big, _) = offloaded(tmp.path());
1939        let repo = open_with(tmp.path(), None);
1940        let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
1941        assert!(
1942            matches!(err, CheckoutError::PromisedBlobs { oid, count: 1 } if oid == big),
1943            "{err:?}"
1944        );
1945        assert!(err.to_string().contains("uploadpack.lazyFetch"), "{err}");
1946    }
1947
1948    #[test]
1949    fn a_snapshot_of_offloaded_blobs_fetches_them_in_one_request() {
1950        let tmp = tempfile::tempdir().unwrap();
1951        let (commit, big, data) = offloaded(tmp.path());
1952        let fetcher = std::sync::Arc::new(StubFetcher {
1953            blobs: vec![(big, data)],
1954            calls: std::sync::Mutex::default(),
1955        });
1956        let repo = open_with(tmp.path(), Some(std::sync::Arc::clone(&fetcher)));
1957        let snap = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap();
1958        assert_eq!((snap.files, snap.bytes), (2, 4096 + 2));
1959        assert_eq!(*fetcher.calls.lock().unwrap(), vec![vec![big]]);
1960        // Everything is here now; the bundle path finds nothing to fetch.
1961        assert_eq!(fetch_promised_blobs(&repo, snap.commit).unwrap(), 0);
1962        assert_eq!(fetcher.calls.lock().unwrap().len(), 1);
1963    }
1964
1965    #[test]
1966    fn a_fetch_that_brings_nothing_is_reported() {
1967        let tmp = tempfile::tempdir().unwrap();
1968        let (commit, big, _) = offloaded(tmp.path());
1969        let fetcher = std::sync::Arc::new(StubFetcher {
1970            blobs: Vec::new(),
1971            calls: std::sync::Mutex::default(),
1972        });
1973        let repo = open_with(tmp.path(), Some(fetcher));
1974        let err = fetch_promised_blobs(&repo, commit).unwrap_err();
1975        assert!(
1976            matches!(err, CheckoutError::PromisedBlobsNotFetched { oid, count: 1, .. } if oid == big),
1977            "{err:?}"
1978        );
1979    }
1980
1981    #[test]
1982    fn a_repository_without_promisor_remotes_is_not_checked() {
1983        let tmp = tempfile::tempdir().unwrap();
1984        let (commit, _, _) = offloaded(tmp.path());
1985        let config = std::fs::read_to_string(tmp.path().join("config")).unwrap();
1986        let plain = config.replace("\tpromisor = true\n", "");
1987        std::fs::write(tmp.path().join("config"), plain).unwrap();
1988        let repo = open_with(tmp.path(), None);
1989        // Not a partial repository: the check is skipped and the stats read fails.
1990        let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
1991        assert!(
1992            !matches!(err, CheckoutError::PromisedBlobs { .. }),
1993            "{err:?}"
1994        );
1995    }
1996}