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