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`], [`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    /// A repository operation failed.
691    #[error(transparent)]
692    Library(Box<crate::error::Error>),
693    /// A file operation failed.
694    #[error(transparent)]
695    Io(#[from] std::io::Error),
696}
697
698impl From<crate::error::Error> for CheckoutError {
699    fn from(e: crate::error::Error) -> Self {
700        Self::Library(Box::new(e))
701    }
702}
703
704impl From<crate::resumable::ResumableError> for CheckoutError {
705    fn from(e: crate::resumable::ResumableError) -> Self {
706        Self::Download(Box::new(e))
707    }
708}
709
710impl From<crate::remote::RemoteError> for CheckoutError {
711    fn from(e: crate::remote::RemoteError) -> Self {
712        Self::Remote(Box::new(e))
713    }
714}
715
716impl From<crate::porcelain::pull::PullError> for CheckoutError {
717    fn from(e: crate::porcelain::pull::PullError) -> Self {
718        Self::Pull(Box::new(e))
719    }
720}
721
722/// Result alias for snapshot checkouts.
723pub type Result<T> = std::result::Result<T, CheckoutError>;
724
725/// Clean up requested paths: relative, without `.`, `..` or empty components,
726/// without trailing slashes, sorted, deduplicated, and with paths inside
727/// another one dropped.
728///
729/// # Errors
730///
731/// [`CheckoutError::InvalidPath`] for an absolute path or one with `.`, `..`
732/// or empty components.
733pub fn normalize_paths(paths: &[String]) -> Result<Vec<String>> {
734    let mut out: Vec<String> = Vec::new();
735    for p in paths {
736        let trimmed = p.trim().trim_matches('/');
737        if trimmed.is_empty() {
738            continue;
739        }
740        if p.trim().starts_with('/') {
741            return Err(CheckoutError::InvalidPath {
742                path: p.clone(),
743                reason: "paths are relative to the repository root",
744            });
745        }
746        if trimmed
747            .split('/')
748            .any(|s| s.is_empty() || s == "." || s == "..")
749        {
750            return Err(CheckoutError::InvalidPath {
751                path: p.clone(),
752                reason: "not a plain path inside the repository",
753            });
754        }
755        out.push(trimmed.to_owned());
756    }
757    out.sort();
758    out.dedup();
759    let all = out.clone();
760    out.retain(|p| {
761        !all.iter().any(|q| {
762            q != p && p.starts_with(q.as_str()) && p.as_bytes().get(q.len()) == Some(&b'/')
763        })
764    });
765    Ok(out)
766}
767
768/// The entry at `path` (slash-separated) under `tree`, as `(mode, oid)`.
769fn lookup(odb: &Odb, tree: ObjectId, path: &[u8]) -> crate::error::Result<Option<(u32, ObjectId)>> {
770    let mut current = (TREE_MODE, tree);
771    for component in path.split(|b| *b == b'/') {
772        if current.0 != TREE_MODE {
773            return Ok(None);
774        }
775        let entries = parse_tree(&odb.read(&current.1)?.data)?;
776        match entries.into_iter().find(|e| e.name == component) {
777            Some(e) => current = (e.mode, e.oid),
778            None => return Ok(None),
779        }
780    }
781    Ok(Some(current))
782}
783
784/// A tree being assembled from entries at full paths.
785#[derive(Default)]
786struct Dir {
787    entries: BTreeMap<Vec<u8>, Node>,
788}
789
790enum Node {
791    Entry(u32, ObjectId),
792    Dir(Dir),
793}
794
795impl Dir {
796    fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
797        match path.iter().position(|b| *b == b'/') {
798            None => {
799                self.entries.insert(path.to_vec(), Node::Entry(mode, oid));
800            }
801            Some(i) => {
802                let node = self
803                    .entries
804                    .entry(path[..i].to_vec())
805                    .or_insert_with(|| Node::Dir(Dir::default()));
806                if let Node::Dir(dir) = node {
807                    dir.insert(&path[i + 1..], mode, oid);
808                }
809            }
810        }
811    }
812
813    fn write(self, odb: &Odb) -> crate::error::Result<ObjectId> {
814        let mut entries = Vec::with_capacity(self.entries.len());
815        for (name, node) in self.entries {
816            let (mode, oid) = match node {
817                Node::Entry(mode, oid) => (mode, oid),
818                Node::Dir(dir) => (TREE_MODE, dir.write(odb)?),
819            };
820            entries.push(TreeEntry { mode, name, oid });
821        }
822        entries.sort_by(|a, b| {
823            tree_entry_cmp(&a.name, a.mode == TREE_MODE, &b.name, b.mode == TREE_MODE)
824        });
825        odb.write(ObjectKind::Tree, &serialize_tree(&entries))
826    }
827}
828
829/// `tree` cut down to `paths`: each path's entry where it is, and the files
830/// directly in every directory above one, the top level included, as Git's
831/// cone-mode sparse checkout has them. Selected directories' trees are reused
832/// as they are, so only the root and the directories above nested paths are
833/// written. No paths is the whole tree.
834///
835/// # Errors
836///
837/// [`CheckoutError::MissingPath`] when `tree` has no entry at one of `paths`,
838/// and object read or write failures.
839pub fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
840    cut_tree(odb, tree, paths, true)
841}
842
843/// [`filtered_tree`], skipping paths `tree` doesn't have instead of failing.
844pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
845    cut_tree(odb, tree, paths, false)
846}
847
848/// Whether `path` (a file's slash-separated path) is in the view `paths`
849/// give, as [`filtered_tree`] cuts it: under one of them, or directly in a
850/// directory above one (the top level included). No paths is the whole tree.
851pub(crate) fn in_view(path: &[u8], paths: &[String]) -> bool {
852    if paths.is_empty() {
853        return true;
854    }
855    let parent = path
856        .iter()
857        .rposition(|b| *b == b'/')
858        .map_or(&b""[..], |i| &path[..i]);
859    let under = |dir: &[u8], p: &[u8]| p.starts_with(dir) && p.get(dir.len()) == Some(&b'/');
860    parent.is_empty()
861        || paths
862            .iter()
863            .map(String::as_bytes)
864            .any(|p| path == p || under(p, path) || under(parent, p))
865}
866
867fn cut_tree(odb: &Odb, tree: ObjectId, paths: &[String], strict: bool) -> Result<ObjectId> {
868    if paths.is_empty() {
869        return Ok(tree);
870    }
871    let mut dirs: Vec<&[u8]> = vec![b""];
872    for p in paths {
873        let p = p.as_bytes();
874        dirs.extend(
875            p.iter()
876                .enumerate()
877                .filter(|(_, b)| **b == b'/')
878                .map(|(i, _)| &p[..i]),
879        );
880    }
881    dirs.sort();
882    dirs.dedup();
883    let mut root = Dir::default();
884    for dir in dirs {
885        let oid = if dir.is_empty() {
886            tree
887        } else {
888            match lookup(odb, tree, dir)? {
889                Some((TREE_MODE, oid)) => oid,
890                _ => continue,
891            }
892        };
893        for entry in parse_tree(&odb.read(&oid)?.data)? {
894            if entry.mode == TREE_MODE {
895                continue;
896            }
897            let path = if dir.is_empty() {
898                entry.name.clone()
899            } else {
900                [dir, b"/", &entry.name].concat()
901            };
902            root.insert(&path, entry.mode, entry.oid);
903        }
904    }
905    for p in paths {
906        match lookup(odb, tree, p.as_bytes())? {
907            Some((mode, oid)) => root.insert(p.as_bytes(), mode, oid),
908            None if strict => {
909                return Err(CheckoutError::MissingPath {
910                    path: p.clone(),
911                    base: tree.to_hex(),
912                })
913            }
914            None => {}
915        }
916    }
917    Ok(root.write(odb)?)
918}
919
920/// The bytes of the snapshot commit standing for `base` with tree `tree` on
921/// `parent`.
922///
923/// The author and committer are [`SNAPSHOT_IDENT`] at `base_time` (UTC), and
924/// the message names `base`, repeats its message, and ends with a
925/// [`SNAPSHOT_BASE_TRAILER`] trailer. Nothing else goes in, so the same inputs
926/// always give the same commit.
927#[must_use]
928pub fn snapshot_object(
929    tree: ObjectId,
930    parent: Option<ObjectId>,
931    base: ObjectId,
932    base_time: i64,
933    base_message: &str,
934) -> Vec<u8> {
935    let base = base.to_hex();
936    let who = format!("{SNAPSHOT_IDENT} {base_time} +0000");
937    let mut s = format!("tree {}\n", tree.to_hex());
938    if let Some(p) = parent {
939        s.push_str(&format!("parent {}\n", p.to_hex()));
940    }
941    let subject = base_message.lines().next().unwrap_or_default().trim();
942    let quoted: String = base_message
943        .trim_end()
944        .lines()
945        .map(|l| format!("    {l}").trim_end().to_owned() + "\n")
946        .collect();
947    s.push_str(&format!(
948        "author {who}\ncommitter {who}\n\nSnapshot of {}: {subject}\n\nStands for {base}:\n\n{quoted}\n{SNAPSHOT_BASE_TRAILER}: {base}\n",
949        &base[..base.len().min(12)]
950    ));
951    s.into_bytes()
952}
953
954/// A snapshot commit written into a repository.
955#[derive(Debug, Clone, PartialEq, Eq)]
956pub struct Snapshot {
957    /// The snapshot commit.
958    pub commit: ObjectId,
959    /// Its tree.
960    pub tree: ObjectId,
961    /// The real commit it stands for.
962    pub base: ObjectId,
963    /// The commit's exact bytes.
964    pub object: Vec<u8>,
965    /// Files in the tree (submodules not counted).
966    pub files: u64,
967    /// Bytes of those files.
968    pub bytes: u64,
969}
970
971/// Write the snapshot of `base` filtered to `paths` (see [`filtered_tree`])
972/// on `parent` into `repo`, with the number and size of its files.
973///
974/// # Errors
975///
976/// [`CheckoutError::MissingPath`] for a path `base` lacks, and object read or
977/// write failures.
978pub fn make_snapshot(
979    repo: &Repository,
980    base: ObjectId,
981    paths: &[String],
982    parent: Option<ObjectId>,
983) -> Result<Snapshot> {
984    let odb = repo.odb();
985    let commit = parse_commit(&odb.read(&base)?.data)?;
986    let tree = filtered_tree(odb, commit.tree, paths).map_err(|e| match e {
987        CheckoutError::MissingPath { path, .. } => CheckoutError::MissingPath {
988            path,
989            base: base.to_hex(),
990        },
991        other => other,
992    })?;
993    let time = crate::ident::parse_signature_times(&commit.committer)
994        .map(|t| t.unix_seconds)
995        .unwrap_or(0);
996    let object = snapshot_object(tree, parent, base, time, &commit.message);
997    let id = odb.write(ObjectKind::Commit, &object)?;
998    let (files, bytes) = tree_stats(odb, tree)?;
999    Ok(Snapshot {
1000        commit: id,
1001        tree,
1002        base,
1003        object,
1004        files,
1005        bytes,
1006    })
1007}
1008
1009/// Files under `tree` and their total size, without reading file contents.
1010fn tree_stats(odb: &Odb, tree: ObjectId) -> crate::error::Result<(u64, u64)> {
1011    let (mut files, mut bytes) = (0u64, 0u64);
1012    let mut stack = vec![tree];
1013    while let Some(oid) = stack.pop() {
1014        for entry in parse_tree(&odb.read(&oid)?.data)? {
1015            match entry.mode {
1016                TREE_MODE => stack.push(entry.oid),
1017                GITLINK_MODE => {}
1018                _ => {
1019                    files += 1;
1020                    bytes += odb.read_info(&entry.oid)?.size;
1021                }
1022            }
1023        }
1024    }
1025    Ok((files, bytes))
1026}
1027
1028/// A repository URL split into what snapshot checkouts need.
1029#[derive(Debug, Clone, PartialEq, Eq)]
1030pub struct CheckoutUrl {
1031    /// The repository URL, for Git (`origin`).
1032    pub repo_url: String,
1033    /// The repository's name (last path segment, without `.git`).
1034    pub name: String,
1035    /// The checkout endpoint, `<server>/.grit/checkouts/<path>`.
1036    pub api: String,
1037}
1038
1039impl CheckoutUrl {
1040    /// Split `url` (`http(s)://host[:port]/<path to repository>[.git]`).
1041    ///
1042    /// # Errors
1043    ///
1044    /// [`CheckoutError::InvalidUrl`] when `url` is not http(s) or names no
1045    /// repository.
1046    pub fn parse(url: &str) -> Result<Self> {
1047        let invalid = || CheckoutError::InvalidUrl(url.to_owned());
1048        let parsed = url::Url::parse(url).map_err(|_| invalid())?;
1049        if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1050            return Err(invalid());
1051        }
1052        let path = parsed.path().trim_matches('/');
1053        let path = path.strip_suffix(".git").unwrap_or(path);
1054        if path.is_empty() {
1055            return Err(invalid());
1056        }
1057        let name = path.rsplit('/').next().unwrap_or(path).to_owned();
1058        let mut server = parsed.clone();
1059        server.set_path("");
1060        server.set_query(None);
1061        server.set_fragment(None);
1062        let server = server.as_str().trim_end_matches('/').to_owned();
1063        Ok(Self {
1064            repo_url: format!("{server}/{path}.git"),
1065            name,
1066            api: format!("{server}/{CHECKOUT_API}/{path}"),
1067        })
1068    }
1069}
1070
1071/// Talks to a server's snapshot-checkout endpoint.
1072pub struct CheckoutClient<'a> {
1073    http: &'a dyn HttpClient,
1074    url: CheckoutUrl,
1075    given: String,
1076}
1077
1078impl<'a> CheckoutClient<'a> {
1079    /// A client for the repository at `url`.
1080    ///
1081    /// # Errors
1082    ///
1083    /// [`CheckoutError::InvalidUrl`] as for [`CheckoutUrl::parse`].
1084    pub fn new(http: &'a dyn HttpClient, url: &str) -> Result<Self> {
1085        Ok(Self {
1086            http,
1087            url: CheckoutUrl::parse(url)?,
1088            given: url.to_owned(),
1089        })
1090    }
1091
1092    /// The parsed URL.
1093    #[must_use]
1094    pub fn url(&self) -> &CheckoutUrl {
1095        &self.url
1096    }
1097
1098    fn json<T: serde::de::DeserializeOwned>(
1099        &self,
1100        method: HttpMethod,
1101        url: &str,
1102        body: Option<&[u8]>,
1103    ) -> Result<T> {
1104        let mut request = HttpRequest::new(method, url).header("Accept", "application/json");
1105        if let Some(body) = body {
1106            request = request
1107                .header("Content-Type", "application/json")
1108                .body(body);
1109        }
1110        let response = self.http.send(&request)?;
1111        let status = response.status;
1112        let bytes = response.into_bytes()?;
1113        if status == 404 && url == self.url.api {
1114            return Err(CheckoutError::NotOffered {
1115                url: self.given.clone(),
1116                endpoint: self.url.api.clone(),
1117            });
1118        }
1119        if !(200..300).contains(&status) {
1120            let message = serde_json::from_slice::<CheckoutErrorBody>(&bytes)
1121                .map(|b| b.error)
1122                .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).trim().to_owned());
1123            return Err(CheckoutError::Server {
1124                url: url.to_owned(),
1125                status,
1126                message,
1127            });
1128        }
1129        serde_json::from_slice(&bytes).map_err(|e| CheckoutError::Protocol {
1130            url: url.to_owned(),
1131            detail: e.to_string(),
1132        })
1133    }
1134
1135    /// What the server would check out.
1136    ///
1137    /// # Errors
1138    ///
1139    /// [`CheckoutError::NotOffered`] when the server has no checkout endpoint
1140    /// for the repository, and request failures.
1141    pub fn server_info(&self) -> Result<CheckoutServerInfo> {
1142        self.json(HttpMethod::Get, &self.url.api, None)
1143    }
1144
1145    /// Make a checkout on the server.
1146    ///
1147    /// # Errors
1148    ///
1149    /// [`CheckoutError::NotOffered`], and the server's refusal (a path it
1150    /// doesn't have, an empty repository).
1151    pub fn create(&self, request: &CheckoutRequest) -> Result<CheckoutInfo> {
1152        let body = serde_json::to_vec(request).map_err(|e| CheckoutError::Protocol {
1153            url: self.url.api.clone(),
1154            detail: e.to_string(),
1155        })?;
1156        self.json(HttpMethod::Post, &self.url.api, Some(&body))
1157    }
1158
1159    /// The URL of checkout `id`'s bundle.
1160    #[must_use]
1161    pub fn bundle_url(&self, id: &str) -> String {
1162        format!("{}/{id}/bundle", self.url.api)
1163    }
1164
1165    /// Checkout `id` as the server has it, and where its target branch is now
1166    /// ([`CheckoutState::tip`]). Changes nothing on the server, so a dry run can
1167    /// tell whether a refresh would move.
1168    ///
1169    /// # Errors
1170    ///
1171    /// Request failures, and the server's refusal (no such checkout).
1172    pub fn show(&self, id: &str) -> Result<CheckoutState> {
1173        self.json(HttpMethod::Get, &format!("{}/{id}", self.url.api), None)
1174    }
1175
1176    fn post_json<T: Serialize, R: serde::de::DeserializeOwned>(
1177        &self,
1178        method: HttpMethod,
1179        url: &str,
1180        body: &T,
1181    ) -> Result<R> {
1182        let body = serde_json::to_vec(body).map_err(|e| CheckoutError::Protocol {
1183            url: url.to_owned(),
1184            detail: e.to_string(),
1185        })?;
1186        self.json(method, url, Some(&body))
1187    }
1188
1189    /// Ask the server to graft branches the client pushed to [`out_ref`].
1190    ///
1191    /// # Errors
1192    ///
1193    /// Request failures and the server's refusal of the whole request.
1194    pub fn checkin(&self, id: &str, request: &CheckinRequest) -> Result<CheckinResponse> {
1195        self.post_json(
1196            HttpMethod::Post,
1197            &format!("{}/{id}/checkin", self.url.api),
1198            request,
1199        )
1200    }
1201
1202    /// Ask the server for a snapshot of the newest target branch on top of
1203    /// checkout `id`'s last one (kept at [`target_ref`]).
1204    ///
1205    /// # Errors
1206    ///
1207    /// Request failures.
1208    pub fn update(&self, id: &str) -> Result<CheckoutInfo> {
1209        self.post_json(
1210            HttpMethod::Post,
1211            &format!("{}/{id}/update", self.url.api),
1212            &(),
1213        )
1214    }
1215
1216    /// Reserve a fresh work branch for checkout `id`: `wanted`, numbered past
1217    /// the server's branches and the names it gave out before.
1218    ///
1219    /// # Returns
1220    ///
1221    /// The name reserved, which the server never gives out again.
1222    ///
1223    /// # Errors
1224    ///
1225    /// Request failures, and the server's refusal (no such checkout, a name
1226    /// that is not a valid branch name).
1227    pub fn reserve_work_branch(&self, id: &str, wanted: &str) -> Result<String> {
1228        let reserved: WorkBranchResponse = self.post_json(
1229            HttpMethod::Post,
1230            &format!("{}/{id}/work", self.url.api),
1231            &WorkBranchRequest {
1232                work: wanted.to_owned(),
1233            },
1234        )?;
1235        Ok(reserved.work)
1236    }
1237
1238    /// Ask the server to show its branch `name` to checkout `id`: its
1239    /// projection, put at [`in_ref`] to fetch ([`fetch_branch`]).
1240    ///
1241    /// # Errors
1242    ///
1243    /// Request failures, and the server's refusal (no such branch, the
1244    /// branch the checkout follows, a branch already on the target or older
1245    /// than the checkout's first snapshot).
1246    pub fn branch(&self, id: &str, name: &str) -> Result<BranchInfo> {
1247        self.post_json(
1248            HttpMethod::Post,
1249            &format!("{}/{id}/branch", self.url.api),
1250            &BranchRequest {
1251                name: name.to_owned(),
1252            },
1253        )
1254    }
1255
1256    /// Merge a branch into the target on the server.
1257    ///
1258    /// # Errors
1259    ///
1260    /// Request failures, and the server's refusal (a conflict, no such branch).
1261    pub fn integrate(&self, request: &IntegrateRequest) -> Result<IntegrateResponse> {
1262        self.post_json(
1263            HttpMethod::Post,
1264            &format!("{}/integrate", self.url.api),
1265            request,
1266        )
1267    }
1268
1269    /// The slices on the target branch.
1270    ///
1271    /// # Errors
1272    ///
1273    /// [`CheckoutError::NotOffered`] and request failures.
1274    pub fn slices(&self) -> Result<SlicesResponse> {
1275        self.server_info()?;
1276        self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
1277    }
1278
1279    /// Make or change slice `name`.
1280    ///
1281    /// # Errors
1282    ///
1283    /// Request failures and the server's refusal (a name that exists or
1284    /// doesn't, a path the target lacks).
1285    pub fn put_slice(&self, name: &str, request: &SliceRequest) -> Result<SliceChange> {
1286        self.post_json(
1287            HttpMethod::Put,
1288            &format!("{}/slices/{name}", self.url.api),
1289            request,
1290        )
1291    }
1292
1293    /// Delete slice `name`.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Request failures and the server's refusal (no such slice).
1298    pub fn delete_slice(&self, name: &str, request: &SliceDeleteRequest) -> Result<SliceChange> {
1299        self.post_json(
1300            HttpMethod::Delete,
1301            &format!("{}/slices/{name}", self.url.api),
1302            request,
1303        )
1304    }
1305}
1306
1307/// Local branches with work to check in: every branch whose tip is a commit
1308/// on top of one of the checkout's snapshots (not a snapshot itself).
1309///
1310/// # Errors
1311///
1312/// Ref and ancestry failures.
1313pub fn branches_with_work(
1314    repo: &Repository,
1315    marker: &CheckoutMarker,
1316) -> Result<Vec<(String, ObjectId)>> {
1317    let roots: Vec<ObjectId> = marker
1318        .roots
1319        .iter()
1320        .filter_map(|r| ObjectId::from_hex(&r.commit).ok())
1321        .collect();
1322    let mut out = Vec::new();
1323    for (refname, tip) in crate::refs::list_refs(repo.git_dir(), "refs/heads/")? {
1324        if roots.contains(&tip) {
1325            continue;
1326        }
1327        let mut on_snapshot = false;
1328        for root in &roots {
1329            if crate::merge_base::is_ancestor(repo, *root, tip)? {
1330                on_snapshot = true;
1331                break;
1332            }
1333        }
1334        if on_snapshot {
1335            let name = refname
1336                .strip_prefix("refs/heads/")
1337                .unwrap_or(&refname)
1338                .to_owned();
1339            out.push((name, tip));
1340        }
1341    }
1342    Ok(out)
1343}
1344
1345/// What makes a repository a snapshot checkout: where it came from and which
1346/// snapshot commits it has. Kept as [`MARKER_FILE`] in the repository's common
1347/// git directory, so every worktree of a checkout shares it.
1348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1349pub struct CheckoutMarker {
1350    /// The repository URL it was checked out from.
1351    pub url: String,
1352    /// The checkout's id on the server.
1353    pub id: String,
1354    /// The target branch.
1355    pub branch: String,
1356    /// The checkout's paths.
1357    pub paths: Vec<String>,
1358    /// The snapshot commits, oldest first.
1359    pub roots: Vec<SnapshotRoot>,
1360    /// Tips of branches this checkout checked in that have landed in
1361    /// [`Self::branch`] (the server merged them into it, or it had them
1362    /// already): every later snapshot has their commits' changes, though not
1363    /// as ancestors, so refreshes never replay them ([`land_integration`]).
1364    /// A tip no branch, and no worktree's detached `HEAD`, builds on any
1365    /// more is dropped.
1366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1367    pub landed: Vec<String>,
1368}
1369
1370/// Where the marker of the repository whose git directory is `git_dir` is:
1371/// in its common git directory, which a linked worktree's `commondir` names.
1372fn marker_path(git_dir: &Path) -> PathBuf {
1373    crate::refs::common_dir(git_dir)
1374        .unwrap_or_else(|| git_dir.to_path_buf())
1375        .join(MARKER_FILE)
1376}
1377
1378impl CheckoutMarker {
1379    /// Read the marker of the repository whose git directory is `git_dir`
1380    /// (the main one or a linked worktree's).
1381    ///
1382    /// # Returns
1383    ///
1384    /// `None` when it is not a snapshot checkout.
1385    #[must_use]
1386    pub fn read(git_dir: &Path) -> Option<Self> {
1387        serde_json::from_slice(&std::fs::read(marker_path(git_dir)).ok()?).ok()
1388    }
1389
1390    /// Write it into the common git directory of the repository whose git
1391    /// directory is `git_dir`, replacing the old one in one rename.
1392    ///
1393    /// # Errors
1394    ///
1395    /// File write failures.
1396    pub fn write(&self, git_dir: &Path) -> Result<()> {
1397        let bytes =
1398            serde_json::to_vec_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
1399        let path = marker_path(git_dir);
1400        let tmp = path.with_file_name(format!("{MARKER_FILE}.tmp"));
1401        std::fs::write(&tmp, bytes)?;
1402        std::fs::rename(&tmp, path)?;
1403        Ok(())
1404    }
1405
1406    /// The checkout's newest snapshot, the last of [`Self::roots`].
1407    #[must_use]
1408    pub fn last(&self) -> Option<&SnapshotRoot> {
1409        self.roots.last()
1410    }
1411
1412    /// The local ref the checkout keeps its newest snapshot at
1413    /// ([`upstream_ref`] of [`Self::branch`]).
1414    #[must_use]
1415    pub fn upstream_ref(&self) -> String {
1416        upstream_ref(&self.branch)
1417    }
1418}
1419
1420/// A checkout built on disk.
1421#[derive(Debug)]
1422pub struct Materialized {
1423    /// The new repository.
1424    pub repo: Repository,
1425    /// The work branch checked out.
1426    pub work: String,
1427    /// Files written to the work tree.
1428    pub files: u64,
1429}
1430
1431/// Build the checkout described by `info` in `dest` from its downloaded
1432/// `bundle`.
1433///
1434/// Creates the repository, unpacks the bundle, checks that the snapshot
1435/// commit is byte for byte the one the server described, points
1436/// [`upstream_ref`] (`refs/remotes/origin/<branch>`, which
1437/// `refs/remotes/origin/HEAD` names) and the new branch `work` at it,
1438/// configures `origin` as `repo_url` fetching [`snapshot_fetch_refspec`] with
1439/// no tags and pushing [`out_push_refspec`], writes the [`CheckoutMarker`] and
1440/// checks out the files.
1441///
1442/// # Errors
1443///
1444/// [`CheckoutError::DestinationNotEmpty`], [`CheckoutError::Mismatch`] for a
1445/// download that is not the described snapshot, and repository failures.
1446#[allow(clippy::too_many_arguments)]
1447pub fn materialize(
1448    dest: &Path,
1449    info: &CheckoutInfo,
1450    bundle: &Path,
1451    repo_url: &str,
1452    work: &str,
1453    progress: &mut dyn Progress,
1454    cancel: &dyn Cancel,
1455) -> Result<Materialized> {
1456    if dest.is_dir() && std::fs::read_dir(dest)?.next().is_some() {
1457        return Err(CheckoutError::DestinationNotEmpty(dest.to_path_buf()));
1458    }
1459    std::fs::create_dir_all(dest)?;
1460    let repo = Repository::init(
1461        dest,
1462        &InitOptions {
1463            initial_branch: Some(work.to_owned()),
1464            ..InitOptions::default()
1465        },
1466    )?;
1467    let opened = repo
1468        .bundles()
1469        .open(bundle)
1470        .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1471    let unbundled = opened
1472        .unbundle_with_progress(&repo, progress, cancel)
1473        .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1474    let mismatch = |got: String| CheckoutError::Mismatch {
1475        expected: info.root.clone(),
1476        got,
1477    };
1478    let root = ObjectId::from_hex(&info.root).map_err(|_| mismatch(info.root.clone()))?;
1479    let tip = unbundled
1480        .refs
1481        .first()
1482        .map(|r| r.oid)
1483        .ok_or_else(|| mismatch("an empty bundle".to_owned()))?;
1484    if tip != root {
1485        return Err(mismatch(tip.to_hex()));
1486    }
1487    let object = repo.odb().read(&root)?;
1488    if object.data != info.root_object.as_bytes() {
1489        return Err(mismatch(format!(
1490            "{} with different contents",
1491            root.to_hex()
1492        )));
1493    }
1494    let git_dir = repo.git_dir().to_path_buf();
1495    let upstream = upstream_ref(&info.branch);
1496    crate::refs::write_ref(&git_dir, &upstream, &root)?;
1497    crate::refs::write_symbolic_ref(&git_dir, "refs/remotes/origin/HEAD", &upstream)?;
1498    crate::refs::write_ref(&git_dir, &format!("refs/heads/{work}"), &root)?;
1499    crate::refs::write_symbolic_ref(&git_dir, "HEAD", &format!("refs/heads/{work}"))?;
1500    let config_path = git_dir.join("config");
1501    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1502    let mut config = crate::config::ConfigFile::parse(
1503        &config_path,
1504        &content,
1505        crate::config::ConfigScope::Local,
1506    )?;
1507    config.set("remote.origin.url", repo_url)?;
1508    config.set(
1509        "remote.origin.fetch",
1510        &snapshot_fetch_refspec(&info.id, &info.branch),
1511    )?;
1512    config.set("remote.origin.push", &out_push_refspec(&info.id))?;
1513    config.set("remote.origin.tagOpt", "--no-tags")?;
1514    config.write()?;
1515    CheckoutMarker {
1516        url: repo_url.to_owned(),
1517        id: info.id.clone(),
1518        branch: info.branch.clone(),
1519        paths: info.paths.clone(),
1520        roots: info.roots.clone(),
1521        landed: Vec::new(),
1522    }
1523    .write(&git_dir)?;
1524    let tree = ObjectId::from_hex(&info.tree).map_err(|_| mismatch(info.tree.clone()))?;
1525    crate::porcelain::checkout::checkout_between_trees_with_progress(
1526        &repo, None, &tree, progress, cancel,
1527    )?;
1528    let files = u64::try_from(repo.index_handle().load()?.entries().len()).unwrap_or(u64::MAX);
1529    Ok(Materialized {
1530        repo,
1531        work: work.to_owned(),
1532        files,
1533    })
1534}
1535
1536const WORDS: &[&str] = &[
1537    "amber", "brook", "cedar", "dune", "ember", "fern", "glade", "harbor", "iris", "juniper",
1538    "kestrel", "lagoon", "maple", "nectar", "orchid", "pine", "quartz", "river", "sage", "thistle",
1539    "umber", "violet", "willow", "yarrow", "zephyr", "aspen", "birch", "coral", "delta", "elm",
1540    "flint", "grove", "hazel", "inlet", "jasper", "lark", "moss", "north", "oak", "pebble",
1541    "quill", "reed", "slate", "tide", "vale", "wren",
1542];
1543
1544/// A work branch name for a new checkout: the local part of `email` and two
1545/// words chosen by `random`, like `alice/quiet-river`.
1546#[must_use]
1547pub fn suggest_work_branch(email: Option<&str>, random: [u8; 2]) -> String {
1548    let who: String = email
1549        .and_then(|e| e.split('@').next())
1550        .unwrap_or_default()
1551        .chars()
1552        .map(|c| {
1553            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1554                c.to_ascii_lowercase()
1555            } else {
1556                '-'
1557            }
1558        })
1559        .collect();
1560    let who = who.trim_matches('-');
1561    let a = WORDS[usize::from(random[0]) % WORDS.len()];
1562    let b = WORDS[usize::from(random[1]) % WORDS.len()];
1563    if who.is_empty() {
1564        format!("checkout/{a}-{b}")
1565    } else {
1566        format!("{who}/{a}-{b}")
1567    }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573
1574    #[test]
1575    fn idents_resolve_like_commit_authors_and_sign_cleanly() {
1576        let env_of = |vars: &[(&str, &str)]| {
1577            crate::config::environment::Environment::from_vars(
1578                vars.iter().map(|(k, v)| ((*k).into(), (*v).into())),
1579                std::path::PathBuf::from("/"),
1580            )
1581        };
1582        let env = env_of(&[
1583            ("GIT_AUTHOR_NAME", "A U Thor"),
1584            ("GIT_AUTHOR_EMAIL", "author@example.com"),
1585        ]);
1586        let mut config = crate::config::ConfigSet::new();
1587        let _ = config.add_command_override("user.name", "Alice");
1588        let _ = config.add_command_override("user.email", "alice@example.com");
1589        assert_eq!(
1590            Ident::author(&env, &config).ok(),
1591            Some(Ident {
1592                name: "A U Thor".to_owned(),
1593                email: "author@example.com".to_owned()
1594            }),
1595            "the environment wins over config, as for a commit"
1596        );
1597        let bare = env_of(&[]);
1598        assert_eq!(
1599            Ident::author(&bare, &config).ok(),
1600            Some(Ident {
1601                name: "Alice".to_owned(),
1602                email: "alice@example.com".to_owned()
1603            })
1604        );
1605        assert!(Ident::author(&bare, &crate::config::ConfigSet::new()).is_err());
1606
1607        let time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
1608            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1609        let sneaky = Ident {
1610            name: "Eve\nencoding bogus".to_owned(),
1611            email: "e>v<e@x".to_owned(),
1612        };
1613        assert_eq!(
1614            sneaky
1615                .signature(time)
1616                .map(|s| s.header_line())
1617                .ok()
1618                .as_deref(),
1619            Some("Eveencoding bogus <eve@x> 1700000000 +0000")
1620        );
1621        let nameless = Ident {
1622            name: "<>".to_owned(),
1623            email: "bob@example.com".to_owned(),
1624        };
1625        assert_eq!(
1626            nameless.signature(time).map(|s| s.name).ok().as_deref(),
1627            Some("bob@example.com")
1628        );
1629    }
1630
1631    #[test]
1632    fn paths_are_normalized() {
1633        let paths: Vec<String> = ["b/", "a/x", "a", " c ", "", "a/y"]
1634            .iter()
1635            .map(|s| (*s).to_owned())
1636            .collect();
1637        assert_eq!(
1638            normalize_paths(&paths).ok(),
1639            Some(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
1640        );
1641        assert!(normalize_paths(&["/abs".to_owned()]).is_err());
1642        assert!(normalize_paths(&["a/../b".to_owned()]).is_err());
1643        assert!(normalize_paths(&["a//b".to_owned()]).is_err());
1644    }
1645
1646    #[test]
1647    fn views_are_the_paths_and_the_files_above_them() {
1648        let paths = vec!["src/deep".to_owned(), "docs/guide.md".to_owned()];
1649        for (path, seen) in [
1650            ("README", true),
1651            ("src/a.rs", true),
1652            ("src/deep/b.rs", true),
1653            ("src/deep/more/c.rs", true),
1654            ("src/other/d.rs", false),
1655            ("docs/guide.md", true),
1656            ("docs/other.md", true),
1657            ("docs/api/x.md", false),
1658            ("srcx/a.rs", false),
1659            ("src/deeper/a.rs", false),
1660        ] {
1661            assert_eq!(in_view(path.as_bytes(), &paths), seen, "{path}");
1662        }
1663        assert!(in_view(b"anything/at/all", &[]));
1664    }
1665
1666    #[test]
1667    fn urls_split_into_repository_and_endpoint() {
1668        let u = CheckoutUrl::parse("http://h:8080/demo.git").ok();
1669        assert_eq!(
1670            u,
1671            Some(CheckoutUrl {
1672                repo_url: "http://h:8080/demo.git".to_owned(),
1673                name: "demo".to_owned(),
1674                api: "http://h:8080/.grit/checkouts/demo".to_owned(),
1675            })
1676        );
1677        let nested = CheckoutUrl::parse("https://h/org/app/").ok();
1678        assert_eq!(nested.as_ref().map(|u| u.name.as_str()), Some("app"));
1679        assert_eq!(
1680            nested.map(|u| u.api),
1681            Some("https://h/.grit/checkouts/org/app".to_owned())
1682        );
1683        assert!(CheckoutUrl::parse("http://h/").is_err());
1684        assert!(CheckoutUrl::parse("ssh://h/demo").is_err());
1685    }
1686
1687    #[test]
1688    fn snapshot_objects_are_deterministic() {
1689        let tree = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").ok();
1690        let base = ObjectId::from_hex("1111111111111111111111111111111111111111").ok();
1691        let (Some(tree), Some(base)) = (tree, base) else {
1692            panic!("oids");
1693        };
1694        let a = snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n");
1695        assert_eq!(
1696            a,
1697            snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n")
1698        );
1699        let text = String::from_utf8_lossy(&a);
1700        assert!(text.starts_with("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor grit-checkout <snapshot@grit-checkout> 1700000000 +0000\n"), "{text}");
1701        assert!(
1702            text.contains("\n\nSnapshot of 111111111111: Fix it\n"),
1703            "{text}"
1704        );
1705        assert!(
1706            text.ends_with("Grit-Checkout-Base: 1111111111111111111111111111111111111111\n"),
1707            "{text}"
1708        );
1709    }
1710
1711    #[test]
1712    fn work_branches_are_named_for_the_committer() {
1713        assert_eq!(
1714            suggest_work_branch(Some("Alice.B@example.com"), [0, 1]),
1715            "alice-b/amber-brook"
1716        );
1717        assert_eq!(suggest_work_branch(None, [2, 3]), "checkout/cedar-dune");
1718    }
1719}