1mod 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
115pub const CHECKOUT_API: &str = ".grit/checkouts";
118
119pub const SNAPSHOT_IDENT: &str = "grit-checkout <snapshot@grit-checkout>";
121
122pub const SNAPSHOT_BASE_TRAILER: &str = "Grit-Checkout-Base";
124
125pub const MARKER_FILE: &str = "grit-checkout.json";
128
129const TREE_MODE: u32 = 0o040000;
130const GITLINK_MODE: u32 = 0o160000;
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct CheckoutServerInfo {
135 pub name: String,
137 pub branch: String,
139 pub head: String,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct CheckoutRequest {
146 #[serde(default)]
148 pub paths: Vec<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub client: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub work: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct SnapshotRoot {
160 pub commit: String,
162 pub base: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct CheckoutInfo {
169 pub id: String,
171 pub name: String,
173 pub branch: String,
175 pub paths: Vec<String>,
177 pub base: String,
179 pub root: String,
181 pub root_object: String,
183 pub tree: String,
185 pub roots: Vec<SnapshotRoot>,
187 pub files: u64,
189 pub bytes: u64,
191 pub bundle_bytes: u64,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub work: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct CheckoutState {
203 #[serde(flatten)]
205 pub info: CheckoutInfo,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub tip: Option<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkBranchRequest {
217 pub work: String,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct WorkBranchResponse {
224 pub work: String,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct BranchRequest {
234 pub name: String,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct BranchInfo {
242 pub name: String,
245 pub tip: String,
247 pub projected: String,
249 pub commits: usize,
251 #[serde(default)]
253 pub made: usize,
254 #[serde(default)]
258 pub reused: usize,
259}
260
261#[must_use]
263pub fn target_ref(id: &str) -> String {
264 format!("refs/checkouts/{id}/target")
265}
266
267#[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#[must_use]
279pub fn out_ref(id: &str, name: &str) -> String {
280 format!("refs/checkouts/{id}/out/heads/{name}")
281}
282
283#[must_use]
286pub fn in_ref(id: &str, name: &str) -> String {
287 format!("refs/checkouts/{id}/in/heads/{name}")
288}
289
290#[must_use]
293pub fn upstream_ref(branch: &str) -> String {
294 format!("refs/remotes/origin/{branch}")
295}
296
297#[must_use]
304pub fn snapshot_fetch_refspec(id: &str, branch: &str) -> String {
305 format!("+{}:{}", target_ref(id), upstream_ref(branch))
306}
307
308#[must_use]
314pub fn out_push_refspec(id: &str) -> String {
315 format!("+refs/heads/*:{}", out_ref(id, "*"))
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct CheckinBranch {
322 pub name: String,
324 pub raw: String,
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
330pub struct CheckinRequest {
331 pub branches: Vec<CheckinBranch>,
333 #[serde(default)]
335 pub force: bool,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum CheckinStatus {
342 Created,
344 Updated,
346 Unchanged,
349 Refused,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct CheckinResult {
356 pub name: String,
358 pub status: CheckinStatus,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub grafted: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub previous: Option<String>,
366 #[serde(default)]
368 pub commits: usize,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub rule: Option<CheckinRule>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub message: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub reason: Option<CheckinRefusal>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct CheckinResponse {
383 pub results: Vec<CheckinResult>,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389pub struct Ident {
390 pub name: String,
392 pub email: String,
394}
395
396impl Ident {
397 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct IntegrateRequest {
451 pub name: String,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub author: Option<Ident>,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(rename_all = "snake_case")]
461pub enum IntegrateStatus {
462 Merged,
464 AlreadyIntegrated,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct IntegrateResponse {
471 pub status: IntegrateStatus,
473 pub name: String,
475 pub target: String,
477 pub previous: String,
479 pub head: String,
481 pub commits: usize,
483}
484
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct Slice {
488 pub name: String,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub description: Option<String>,
493 pub paths: Vec<String>,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct SlicesResponse {
500 pub target: String,
502 pub head: String,
504 pub slices: Vec<Slice>,
506}
507
508#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
510pub struct SliceRequest {
511 pub paths: Vec<String>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub description: Option<String>,
516 #[serde(default)]
519 pub create: bool,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub author: Option<Ident>,
523}
524
525#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
527pub struct SliceDeleteRequest {
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub author: Option<Ident>,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535pub struct SliceChange {
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub slice: Option<Slice>,
539 pub target: String,
541 pub previous: String,
543 pub head: String,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct CheckoutErrorBody {
551 pub error: String,
553}
554
555#[derive(Debug, thiserror::Error)]
557pub enum CheckoutError {
558 #[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 url: String,
563 endpoint: String,
565 },
566 #[error("{url}: {message} (HTTP {status})")]
568 Server {
569 url: String,
571 status: u16,
573 message: String,
575 },
576 #[error("{0} is not an http(s) URL of a repository")]
578 InvalidUrl(String),
579 #[error("{path}: {reason}")]
581 InvalidPath {
582 path: String,
584 reason: &'static str,
586 },
587 #[error("{path} is not in the repository at {base}")]
589 MissingPath {
590 path: String,
592 base: String,
594 },
595 #[error("the downloaded snapshot is {got}, but the server described {expected}")]
597 Mismatch {
598 expected: String,
600 got: String,
602 },
603 #[error("{0} already exists and is not empty")]
605 DestinationNotEmpty(PathBuf),
606 #[error("{0} is not a valid branch name")]
608 InvalidBranchName(String),
609 #[error("the record of checkout {id} at {path} is damaged: {detail}")]
611 DamagedRecord {
612 id: String,
614 path: PathBuf,
616 detail: String,
618 },
619 #[error("{refname} kept moving while it was being set")]
621 RefKeptMoving {
622 refname: String,
624 },
625 #[error("the work branches the server reserved are taken here (the last was {name})")]
628 WorkBranchTaken {
629 name: String,
631 },
632 #[error("the server's snapshot moved again while fetching: expected {expected}, got {got}")]
635 SnapshotMoved {
636 expected: String,
638 got: String,
640 },
641 #[error("the server's {name} moved again while fetching: expected {expected}, got {got}")]
644 BranchMoved {
645 name: String,
647 expected: String,
649 got: String,
651 },
652 #[error("{name} has nothing in common with the server's {name}")]
655 UnrelatedBranch {
656 name: String,
658 },
659 #[error("{failed}; HEAD is still on {branch}, which it was switched to for the rebase")]
663 StillSwitched {
664 branch: String,
666 failed: Box<CheckoutError>,
668 },
669 #[error("a {} is in progress", .0.description())]
672 OperationInProgress(crate::state::InProgressOperation),
673 #[error(transparent)]
675 Remote(Box<crate::remote::RemoteError>),
676 #[error(transparent)]
678 Pull(Box<crate::porcelain::pull::PullError>),
679 #[error("unexpected answer from {url}: {detail}")]
681 Protocol {
682 url: String,
684 detail: String,
686 },
687 #[error(transparent)]
689 Download(Box<crate::resumable::ResumableError>),
690 #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and this server does not fetch from its promisor remotes (uploadpack.lazyFetch is off)")]
695 PromisedBlobs {
696 oid: ObjectId,
698 count: usize,
700 },
701 #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and fetching them failed: {source}")]
704 PromisedBlobsNotFetched {
705 oid: ObjectId,
707 count: usize,
709 #[source]
711 source: Box<crate::error::Error>,
712 },
713 #[error(transparent)]
715 Library(Box<crate::error::Error>),
716 #[error(transparent)]
718 Io(#[from] std::io::Error),
719}
720
721impl From<crate::error::Error> for CheckoutError {
722 fn from(e: crate::error::Error) -> Self {
723 Self::Library(Box::new(e))
724 }
725}
726
727impl From<crate::resumable::ResumableError> for CheckoutError {
728 fn from(e: crate::resumable::ResumableError) -> Self {
729 Self::Download(Box::new(e))
730 }
731}
732
733impl From<crate::remote::RemoteError> for CheckoutError {
734 fn from(e: crate::remote::RemoteError) -> Self {
735 Self::Remote(Box::new(e))
736 }
737}
738
739impl From<crate::porcelain::pull::PullError> for CheckoutError {
740 fn from(e: crate::porcelain::pull::PullError) -> Self {
741 Self::Pull(Box::new(e))
742 }
743}
744
745pub type Result<T> = std::result::Result<T, CheckoutError>;
747
748pub fn normalize_paths(paths: &[String]) -> Result<Vec<String>> {
757 let mut out: Vec<String> = Vec::new();
758 for p in paths {
759 let trimmed = p.trim().trim_matches('/');
760 if trimmed.is_empty() {
761 continue;
762 }
763 if p.trim().starts_with('/') {
764 return Err(CheckoutError::InvalidPath {
765 path: p.clone(),
766 reason: "paths are relative to the repository root",
767 });
768 }
769 if trimmed
770 .split('/')
771 .any(|s| s.is_empty() || s == "." || s == "..")
772 {
773 return Err(CheckoutError::InvalidPath {
774 path: p.clone(),
775 reason: "not a plain path inside the repository",
776 });
777 }
778 out.push(trimmed.to_owned());
779 }
780 out.sort();
781 out.dedup();
782 let all = out.clone();
783 out.retain(|p| {
784 !all.iter().any(|q| {
785 q != p && p.starts_with(q.as_str()) && p.as_bytes().get(q.len()) == Some(&b'/')
786 })
787 });
788 Ok(out)
789}
790
791fn lookup(odb: &Odb, tree: ObjectId, path: &[u8]) -> crate::error::Result<Option<(u32, ObjectId)>> {
793 let mut current = (TREE_MODE, tree);
794 for component in path.split(|b| *b == b'/') {
795 if current.0 != TREE_MODE {
796 return Ok(None);
797 }
798 let entries = parse_tree(&odb.read(¤t.1)?.data)?;
799 match entries.into_iter().find(|e| e.name == component) {
800 Some(e) => current = (e.mode, e.oid),
801 None => return Ok(None),
802 }
803 }
804 Ok(Some(current))
805}
806
807#[derive(Default)]
809struct Dir {
810 entries: BTreeMap<Vec<u8>, Node>,
811}
812
813enum Node {
814 Entry(u32, ObjectId),
815 Dir(Dir),
816}
817
818impl Dir {
819 fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
820 match path.iter().position(|b| *b == b'/') {
821 None => {
822 self.entries.insert(path.to_vec(), Node::Entry(mode, oid));
823 }
824 Some(i) => {
825 let node = self
826 .entries
827 .entry(path[..i].to_vec())
828 .or_insert_with(|| Node::Dir(Dir::default()));
829 if let Node::Dir(dir) = node {
830 dir.insert(&path[i + 1..], mode, oid);
831 }
832 }
833 }
834 }
835
836 fn write(self, odb: &Odb) -> crate::error::Result<ObjectId> {
837 let mut entries = Vec::with_capacity(self.entries.len());
838 for (name, node) in self.entries {
839 let (mode, oid) = match node {
840 Node::Entry(mode, oid) => (mode, oid),
841 Node::Dir(dir) => (TREE_MODE, dir.write(odb)?),
842 };
843 entries.push(TreeEntry { mode, name, oid });
844 }
845 entries.sort_by(|a, b| {
846 tree_entry_cmp(&a.name, a.mode == TREE_MODE, &b.name, b.mode == TREE_MODE)
847 });
848 odb.write(ObjectKind::Tree, &serialize_tree(&entries))
849 }
850}
851
852pub fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
863 cut_tree(odb, tree, paths, true)
864}
865
866pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
868 cut_tree(odb, tree, paths, false)
869}
870
871pub(crate) fn in_view(path: &[u8], paths: &[String]) -> bool {
875 if paths.is_empty() {
876 return true;
877 }
878 let parent = path
879 .iter()
880 .rposition(|b| *b == b'/')
881 .map_or(&b""[..], |i| &path[..i]);
882 let under = |dir: &[u8], p: &[u8]| p.starts_with(dir) && p.get(dir.len()) == Some(&b'/');
883 parent.is_empty()
884 || paths
885 .iter()
886 .map(String::as_bytes)
887 .any(|p| path == p || under(p, path) || under(parent, p))
888}
889
890fn cut_tree(odb: &Odb, tree: ObjectId, paths: &[String], strict: bool) -> Result<ObjectId> {
891 if paths.is_empty() {
892 return Ok(tree);
893 }
894 let mut dirs: Vec<&[u8]> = vec![b""];
895 for p in paths {
896 let p = p.as_bytes();
897 dirs.extend(
898 p.iter()
899 .enumerate()
900 .filter(|(_, b)| **b == b'/')
901 .map(|(i, _)| &p[..i]),
902 );
903 }
904 dirs.sort();
905 dirs.dedup();
906 let mut root = Dir::default();
907 for dir in dirs {
908 let oid = if dir.is_empty() {
909 tree
910 } else {
911 match lookup(odb, tree, dir)? {
912 Some((TREE_MODE, oid)) => oid,
913 _ => continue,
914 }
915 };
916 for entry in parse_tree(&odb.read(&oid)?.data)? {
917 if entry.mode == TREE_MODE {
918 continue;
919 }
920 let path = if dir.is_empty() {
921 entry.name.clone()
922 } else {
923 [dir, b"/", &entry.name].concat()
924 };
925 root.insert(&path, entry.mode, entry.oid);
926 }
927 }
928 for p in paths {
929 match lookup(odb, tree, p.as_bytes())? {
930 Some((mode, oid)) => root.insert(p.as_bytes(), mode, oid),
931 None if strict => {
932 return Err(CheckoutError::MissingPath {
933 path: p.clone(),
934 base: tree.to_hex(),
935 })
936 }
937 None => {}
938 }
939 }
940 Ok(root.write(odb)?)
941}
942
943#[must_use]
951pub fn snapshot_object(
952 tree: ObjectId,
953 parent: Option<ObjectId>,
954 base: ObjectId,
955 base_time: i64,
956 base_message: &str,
957) -> Vec<u8> {
958 let base = base.to_hex();
959 let who = format!("{SNAPSHOT_IDENT} {base_time} +0000");
960 let mut s = format!("tree {}\n", tree.to_hex());
961 if let Some(p) = parent {
962 s.push_str(&format!("parent {}\n", p.to_hex()));
963 }
964 let subject = base_message.lines().next().unwrap_or_default().trim();
965 let quoted: String = base_message
966 .trim_end()
967 .lines()
968 .map(|l| format!(" {l}").trim_end().to_owned() + "\n")
969 .collect();
970 s.push_str(&format!(
971 "author {who}\ncommitter {who}\n\nSnapshot of {}: {subject}\n\nStands for {base}:\n\n{quoted}\n{SNAPSHOT_BASE_TRAILER}: {base}\n",
972 &base[..base.len().min(12)]
973 ));
974 s.into_bytes()
975}
976
977#[derive(Debug, Clone, PartialEq, Eq)]
979pub struct Snapshot {
980 pub commit: ObjectId,
982 pub tree: ObjectId,
984 pub base: ObjectId,
986 pub object: Vec<u8>,
988 pub files: u64,
990 pub bytes: u64,
992}
993
994pub fn make_snapshot(
1008 repo: &Repository,
1009 base: ObjectId,
1010 paths: &[String],
1011 parent: Option<ObjectId>,
1012) -> Result<Snapshot> {
1013 let odb = repo.odb();
1014 let commit = odb.read_commit(&base)?;
1015 let tree = filtered_tree(odb, commit.tree, paths).map_err(|e| match e {
1016 CheckoutError::MissingPath { path, .. } => CheckoutError::MissingPath {
1017 path,
1018 base: base.to_hex(),
1019 },
1020 other => other,
1021 })?;
1022 let time = crate::ident::parse_signature_times(&commit.committer)
1023 .map(|t| t.unix_seconds)
1024 .unwrap_or(0);
1025 let object = snapshot_object(tree, parent, base, time, &commit.message);
1026 fetch_promised_tree_blobs(repo, tree)?;
1027 let id = odb.write(ObjectKind::Commit, &object)?;
1028 let (files, bytes) = tree_stats(odb, tree)?;
1029 Ok(Snapshot {
1030 commit: id,
1031 tree,
1032 base,
1033 object,
1034 files,
1035 bytes,
1036 })
1037}
1038
1039pub fn fetch_promised_blobs(repo: &Repository, snapshot: ObjectId) -> Result<usize> {
1069 let tree = parse_commit(&repo.odb().read(&snapshot)?.data)?.tree;
1070 fetch_promised_tree_blobs(repo, tree)
1071}
1072
1073fn fetch_promised_tree_blobs(repo: &Repository, tree: ObjectId) -> Result<usize> {
1075 let config = repo.config()?;
1076 if !crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config.as_ref()) {
1077 return Ok(0);
1078 }
1079 let odb = repo.odb();
1080 let mut blobs: Vec<(ObjectId, ObjectId)> = Vec::new();
1082 let mut seen = crate::objects::OidHashSet::default();
1083 let mut stack = vec![tree];
1084 while let Some(oid) = stack.pop() {
1085 for entry in parse_tree(&odb.read(&oid)?.data)? {
1086 match entry.mode {
1087 TREE_MODE => stack.push(entry.oid),
1088 GITLINK_MODE => {}
1089 _ if seen.insert(entry.oid) => blobs.push((entry.oid, oid)),
1090 _ => {}
1091 }
1092 }
1093 }
1094 let missing: crate::objects::OidHashSet = odb
1095 .missing_stored_oids(blobs.iter().map(|(blob, _)| *blob))?
1096 .into_iter()
1097 .collect();
1098 let Some(&(first, referrer)) = blobs.iter().find(|(blob, _)| missing.contains(blob)) else {
1099 return Ok(0);
1100 };
1101 let promised = crate::serve::promised_objects::PromisorObjects::new(repo)?;
1102 if !promised.is_promised(&first, Some(&referrer))? {
1103 return Err(crate::error::Error::ObjectNotFound(first.to_hex()).into());
1104 }
1105 let count = missing.len();
1106 if repo.caches().promisor_fetcher().is_none() {
1107 return Err(CheckoutError::PromisedBlobs { oid: first, count });
1108 }
1109 let wanted: Vec<ObjectId> = blobs
1110 .iter()
1111 .map(|(blob, _)| *blob)
1112 .filter(|blob| missing.contains(blob))
1113 .collect();
1114 let not_fetched =
1115 |oid: ObjectId, source: crate::error::Error| CheckoutError::PromisedBlobsNotFetched {
1116 oid,
1117 count,
1118 source: Box::new(source),
1119 };
1120 repo.prefetch_promised(&wanted)
1121 .map_err(|e| not_fetched(first, e))?;
1122 odb.reprepare_local_packs();
1123 odb.invalidate_packs();
1124 if let Some(&still) = odb.missing_stored_oids(wanted.iter().copied())?.first() {
1125 return Err(not_fetched(
1126 still,
1127 crate::error::Error::PromisedObjectMissing(still),
1128 ));
1129 }
1130 Ok(count)
1131}
1132
1133fn tree_stats(odb: &Odb, tree: ObjectId) -> crate::error::Result<(u64, u64)> {
1135 let (mut files, mut bytes) = (0u64, 0u64);
1136 let mut stack = vec![tree];
1137 while let Some(oid) = stack.pop() {
1138 for entry in parse_tree(&odb.read(&oid)?.data)? {
1139 match entry.mode {
1140 TREE_MODE => stack.push(entry.oid),
1141 GITLINK_MODE => {}
1142 _ => {
1143 files += 1;
1144 bytes += odb.read_info(&entry.oid)?.size;
1145 }
1146 }
1147 }
1148 }
1149 Ok((files, bytes))
1150}
1151
1152#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct CheckoutUrl {
1155 pub repo_url: String,
1157 pub name: String,
1159 pub api: String,
1161}
1162
1163impl CheckoutUrl {
1164 pub fn parse(url: &str) -> Result<Self> {
1171 let invalid = || CheckoutError::InvalidUrl(url.to_owned());
1172 let parsed = url::Url::parse(url).map_err(|_| invalid())?;
1173 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1174 return Err(invalid());
1175 }
1176 let path = parsed.path().trim_matches('/');
1177 let path = path.strip_suffix(".git").unwrap_or(path);
1178 if path.is_empty() {
1179 return Err(invalid());
1180 }
1181 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
1182 let mut server = parsed.clone();
1183 server.set_path("");
1184 server.set_query(None);
1185 server.set_fragment(None);
1186 let server = server.as_str().trim_end_matches('/').to_owned();
1187 Ok(Self {
1188 repo_url: format!("{server}/{path}.git"),
1189 name,
1190 api: format!("{server}/{CHECKOUT_API}/{path}"),
1191 })
1192 }
1193}
1194
1195pub struct CheckoutClient<'a> {
1197 http: &'a dyn HttpClient,
1198 url: CheckoutUrl,
1199 given: String,
1200}
1201
1202impl<'a> CheckoutClient<'a> {
1203 pub fn new(http: &'a dyn HttpClient, url: &str) -> Result<Self> {
1209 Ok(Self {
1210 http,
1211 url: CheckoutUrl::parse(url)?,
1212 given: url.to_owned(),
1213 })
1214 }
1215
1216 #[must_use]
1218 pub fn url(&self) -> &CheckoutUrl {
1219 &self.url
1220 }
1221
1222 fn json<T: serde::de::DeserializeOwned>(
1223 &self,
1224 method: HttpMethod,
1225 url: &str,
1226 body: Option<&[u8]>,
1227 ) -> Result<T> {
1228 let mut request = HttpRequest::new(method, url).header("Accept", "application/json");
1229 if let Some(body) = body {
1230 request = request
1231 .header("Content-Type", "application/json")
1232 .body(body);
1233 }
1234 let response = self.http.send(&request)?;
1235 let status = response.status;
1236 let bytes = response.into_bytes()?;
1237 if status == 404 && url == self.url.api {
1238 return Err(CheckoutError::NotOffered {
1239 url: self.given.clone(),
1240 endpoint: self.url.api.clone(),
1241 });
1242 }
1243 if !(200..300).contains(&status) {
1244 let message = serde_json::from_slice::<CheckoutErrorBody>(&bytes)
1245 .map(|b| b.error)
1246 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).trim().to_owned());
1247 return Err(CheckoutError::Server {
1248 url: url.to_owned(),
1249 status,
1250 message,
1251 });
1252 }
1253 serde_json::from_slice(&bytes).map_err(|e| CheckoutError::Protocol {
1254 url: url.to_owned(),
1255 detail: e.to_string(),
1256 })
1257 }
1258
1259 pub fn server_info(&self) -> Result<CheckoutServerInfo> {
1266 self.json(HttpMethod::Get, &self.url.api, None)
1267 }
1268
1269 pub fn create(&self, request: &CheckoutRequest) -> Result<CheckoutInfo> {
1276 let body = serde_json::to_vec(request).map_err(|e| CheckoutError::Protocol {
1277 url: self.url.api.clone(),
1278 detail: e.to_string(),
1279 })?;
1280 self.json(HttpMethod::Post, &self.url.api, Some(&body))
1281 }
1282
1283 #[must_use]
1285 pub fn bundle_url(&self, id: &str) -> String {
1286 format!("{}/{id}/bundle", self.url.api)
1287 }
1288
1289 pub fn show(&self, id: &str) -> Result<CheckoutState> {
1297 self.json(HttpMethod::Get, &format!("{}/{id}", self.url.api), None)
1298 }
1299
1300 fn post_json<T: Serialize, R: serde::de::DeserializeOwned>(
1301 &self,
1302 method: HttpMethod,
1303 url: &str,
1304 body: &T,
1305 ) -> Result<R> {
1306 let body = serde_json::to_vec(body).map_err(|e| CheckoutError::Protocol {
1307 url: url.to_owned(),
1308 detail: e.to_string(),
1309 })?;
1310 self.json(method, url, Some(&body))
1311 }
1312
1313 pub fn checkin(&self, id: &str, request: &CheckinRequest) -> Result<CheckinResponse> {
1319 self.post_json(
1320 HttpMethod::Post,
1321 &format!("{}/{id}/checkin", self.url.api),
1322 request,
1323 )
1324 }
1325
1326 pub fn update(&self, id: &str) -> Result<CheckoutInfo> {
1333 self.post_json(
1334 HttpMethod::Post,
1335 &format!("{}/{id}/update", self.url.api),
1336 &(),
1337 )
1338 }
1339
1340 pub fn reserve_work_branch(&self, id: &str, wanted: &str) -> Result<String> {
1352 let reserved: WorkBranchResponse = self.post_json(
1353 HttpMethod::Post,
1354 &format!("{}/{id}/work", self.url.api),
1355 &WorkBranchRequest {
1356 work: wanted.to_owned(),
1357 },
1358 )?;
1359 Ok(reserved.work)
1360 }
1361
1362 pub fn branch(&self, id: &str, name: &str) -> Result<BranchInfo> {
1371 self.post_json(
1372 HttpMethod::Post,
1373 &format!("{}/{id}/branch", self.url.api),
1374 &BranchRequest {
1375 name: name.to_owned(),
1376 },
1377 )
1378 }
1379
1380 pub fn integrate(&self, request: &IntegrateRequest) -> Result<IntegrateResponse> {
1386 self.post_json(
1387 HttpMethod::Post,
1388 &format!("{}/integrate", self.url.api),
1389 request,
1390 )
1391 }
1392
1393 pub fn slices(&self) -> Result<SlicesResponse> {
1399 self.server_info()?;
1400 self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
1401 }
1402
1403 pub fn put_slice(&self, name: &str, request: &SliceRequest) -> Result<SliceChange> {
1410 self.post_json(
1411 HttpMethod::Put,
1412 &format!("{}/slices/{name}", self.url.api),
1413 request,
1414 )
1415 }
1416
1417 pub fn delete_slice(&self, name: &str, request: &SliceDeleteRequest) -> Result<SliceChange> {
1423 self.post_json(
1424 HttpMethod::Delete,
1425 &format!("{}/slices/{name}", self.url.api),
1426 request,
1427 )
1428 }
1429}
1430
1431pub fn branches_with_work(
1438 repo: &Repository,
1439 marker: &CheckoutMarker,
1440) -> Result<Vec<(String, ObjectId)>> {
1441 let roots: Vec<ObjectId> = marker
1442 .roots
1443 .iter()
1444 .filter_map(|r| ObjectId::from_hex(&r.commit).ok())
1445 .collect();
1446 let mut out = Vec::new();
1447 for (refname, tip) in crate::refs::list_refs(repo.git_dir(), "refs/heads/")? {
1448 if roots.contains(&tip) {
1449 continue;
1450 }
1451 let mut on_snapshot = false;
1452 for root in &roots {
1453 if crate::merge_base::is_ancestor(repo, *root, tip)? {
1454 on_snapshot = true;
1455 break;
1456 }
1457 }
1458 if on_snapshot {
1459 let name = refname
1460 .strip_prefix("refs/heads/")
1461 .unwrap_or(&refname)
1462 .to_owned();
1463 out.push((name, tip));
1464 }
1465 }
1466 Ok(out)
1467}
1468
1469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1473pub struct CheckoutMarker {
1474 pub url: String,
1476 pub id: String,
1478 pub branch: String,
1480 pub paths: Vec<String>,
1482 pub roots: Vec<SnapshotRoot>,
1484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1491 pub landed: Vec<String>,
1492}
1493
1494fn marker_path(git_dir: &Path) -> PathBuf {
1497 crate::refs::common_dir(git_dir)
1498 .unwrap_or_else(|| git_dir.to_path_buf())
1499 .join(MARKER_FILE)
1500}
1501
1502impl CheckoutMarker {
1503 #[must_use]
1510 pub fn read(git_dir: &Path) -> Option<Self> {
1511 serde_json::from_slice(&std::fs::read(marker_path(git_dir)).ok()?).ok()
1512 }
1513
1514 pub fn write(&self, git_dir: &Path) -> Result<()> {
1521 let bytes =
1522 serde_json::to_vec_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
1523 let path = marker_path(git_dir);
1524 let tmp = path.with_file_name(format!("{MARKER_FILE}.tmp"));
1525 std::fs::write(&tmp, bytes)?;
1526 std::fs::rename(&tmp, path)?;
1527 Ok(())
1528 }
1529
1530 #[must_use]
1532 pub fn last(&self) -> Option<&SnapshotRoot> {
1533 self.roots.last()
1534 }
1535
1536 #[must_use]
1539 pub fn upstream_ref(&self) -> String {
1540 upstream_ref(&self.branch)
1541 }
1542}
1543
1544#[derive(Debug)]
1546pub struct Materialized {
1547 pub repo: Repository,
1549 pub work: String,
1551 pub files: u64,
1553}
1554
1555#[allow(clippy::too_many_arguments)]
1571pub fn materialize(
1572 dest: &Path,
1573 info: &CheckoutInfo,
1574 bundle: &Path,
1575 repo_url: &str,
1576 work: &str,
1577 progress: &mut dyn Progress,
1578 cancel: &dyn Cancel,
1579) -> Result<Materialized> {
1580 if dest.is_dir() && std::fs::read_dir(dest)?.next().is_some() {
1581 return Err(CheckoutError::DestinationNotEmpty(dest.to_path_buf()));
1582 }
1583 std::fs::create_dir_all(dest)?;
1584 let repo = Repository::init(
1585 dest,
1586 &InitOptions {
1587 initial_branch: Some(work.to_owned()),
1588 ..InitOptions::default()
1589 },
1590 )?;
1591 let opened = repo
1592 .bundles()
1593 .open(bundle)
1594 .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1595 let unbundled = opened
1596 .unbundle_with_progress(&repo, progress, cancel)
1597 .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1598 let mismatch = |got: String| CheckoutError::Mismatch {
1599 expected: info.root.clone(),
1600 got,
1601 };
1602 let root = ObjectId::from_hex(&info.root).map_err(|_| mismatch(info.root.clone()))?;
1603 let tip = unbundled
1604 .refs
1605 .first()
1606 .map(|r| r.oid)
1607 .ok_or_else(|| mismatch("an empty bundle".to_owned()))?;
1608 if tip != root {
1609 return Err(mismatch(tip.to_hex()));
1610 }
1611 let object = repo.odb().read(&root)?;
1612 if object.data != info.root_object.as_bytes() {
1613 return Err(mismatch(format!(
1614 "{} with different contents",
1615 root.to_hex()
1616 )));
1617 }
1618 let git_dir = repo.git_dir().to_path_buf();
1619 let upstream = upstream_ref(&info.branch);
1620 crate::refs::write_ref(&git_dir, &upstream, &root)?;
1621 crate::refs::write_symbolic_ref(&git_dir, "refs/remotes/origin/HEAD", &upstream)?;
1622 crate::refs::write_ref(&git_dir, &format!("refs/heads/{work}"), &root)?;
1623 crate::refs::write_symbolic_ref(&git_dir, "HEAD", &format!("refs/heads/{work}"))?;
1624 let config_path = git_dir.join("config");
1625 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1626 let mut config = crate::config::ConfigFile::parse(
1627 &config_path,
1628 &content,
1629 crate::config::ConfigScope::Local,
1630 )?;
1631 config.set("remote.origin.url", repo_url)?;
1632 config.set(
1633 "remote.origin.fetch",
1634 &snapshot_fetch_refspec(&info.id, &info.branch),
1635 )?;
1636 config.set("remote.origin.push", &out_push_refspec(&info.id))?;
1637 config.set("remote.origin.tagOpt", "--no-tags")?;
1638 config.write()?;
1639 CheckoutMarker {
1640 url: repo_url.to_owned(),
1641 id: info.id.clone(),
1642 branch: info.branch.clone(),
1643 paths: info.paths.clone(),
1644 roots: info.roots.clone(),
1645 landed: Vec::new(),
1646 }
1647 .write(&git_dir)?;
1648 let tree = ObjectId::from_hex(&info.tree).map_err(|_| mismatch(info.tree.clone()))?;
1649 crate::porcelain::checkout::checkout_between_trees_with_progress(
1650 &repo, None, &tree, progress, cancel,
1651 )?;
1652 let files = u64::try_from(repo.index_handle().load()?.entries().len()).unwrap_or(u64::MAX);
1653 Ok(Materialized {
1654 repo,
1655 work: work.to_owned(),
1656 files,
1657 })
1658}
1659
1660const WORDS: &[&str] = &[
1661 "amber", "brook", "cedar", "dune", "ember", "fern", "glade", "harbor", "iris", "juniper",
1662 "kestrel", "lagoon", "maple", "nectar", "orchid", "pine", "quartz", "river", "sage", "thistle",
1663 "umber", "violet", "willow", "yarrow", "zephyr", "aspen", "birch", "coral", "delta", "elm",
1664 "flint", "grove", "hazel", "inlet", "jasper", "lark", "moss", "north", "oak", "pebble",
1665 "quill", "reed", "slate", "tide", "vale", "wren",
1666];
1667
1668#[must_use]
1671pub fn suggest_work_branch(email: Option<&str>, random: [u8; 2]) -> String {
1672 let who: String = email
1673 .and_then(|e| e.split('@').next())
1674 .unwrap_or_default()
1675 .chars()
1676 .map(|c| {
1677 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1678 c.to_ascii_lowercase()
1679 } else {
1680 '-'
1681 }
1682 })
1683 .collect();
1684 let who = who.trim_matches('-');
1685 let a = WORDS[usize::from(random[0]) % WORDS.len()];
1686 let b = WORDS[usize::from(random[1]) % WORDS.len()];
1687 if who.is_empty() {
1688 format!("checkout/{a}-{b}")
1689 } else {
1690 format!("{who}/{a}-{b}")
1691 }
1692}
1693
1694#[cfg(test)]
1695mod tests {
1696 use super::*;
1697
1698 #[test]
1699 fn idents_resolve_like_commit_authors_and_sign_cleanly() {
1700 let env_of = |vars: &[(&str, &str)]| {
1701 crate::config::environment::Environment::from_vars(
1702 vars.iter().map(|(k, v)| ((*k).into(), (*v).into())),
1703 std::path::PathBuf::from("/"),
1704 )
1705 };
1706 let env = env_of(&[
1707 ("GIT_AUTHOR_NAME", "A U Thor"),
1708 ("GIT_AUTHOR_EMAIL", "author@example.com"),
1709 ]);
1710 let mut config = crate::config::ConfigSet::new();
1711 let _ = config.add_command_override("user.name", "Alice");
1712 let _ = config.add_command_override("user.email", "alice@example.com");
1713 assert_eq!(
1714 Ident::author(&env, &config).ok(),
1715 Some(Ident {
1716 name: "A U Thor".to_owned(),
1717 email: "author@example.com".to_owned()
1718 }),
1719 "the environment wins over config, as for a commit"
1720 );
1721 let bare = env_of(&[]);
1722 assert_eq!(
1723 Ident::author(&bare, &config).ok(),
1724 Some(Ident {
1725 name: "Alice".to_owned(),
1726 email: "alice@example.com".to_owned()
1727 })
1728 );
1729 assert!(Ident::author(&bare, &crate::config::ConfigSet::new()).is_err());
1730
1731 let time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
1732 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1733 let sneaky = Ident {
1734 name: "Eve\nencoding bogus".to_owned(),
1735 email: "e>v<e@x".to_owned(),
1736 };
1737 assert_eq!(
1738 sneaky
1739 .signature(time)
1740 .map(|s| s.header_line())
1741 .ok()
1742 .as_deref(),
1743 Some("Eveencoding bogus <eve@x> 1700000000 +0000")
1744 );
1745 let nameless = Ident {
1746 name: "<>".to_owned(),
1747 email: "bob@example.com".to_owned(),
1748 };
1749 assert_eq!(
1750 nameless.signature(time).map(|s| s.name).ok().as_deref(),
1751 Some("bob@example.com")
1752 );
1753 }
1754
1755 #[test]
1756 fn paths_are_normalized() {
1757 let paths: Vec<String> = ["b/", "a/x", "a", " c ", "", "a/y"]
1758 .iter()
1759 .map(|s| (*s).to_owned())
1760 .collect();
1761 assert_eq!(
1762 normalize_paths(&paths).ok(),
1763 Some(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
1764 );
1765 assert!(normalize_paths(&["/abs".to_owned()]).is_err());
1766 assert!(normalize_paths(&["a/../b".to_owned()]).is_err());
1767 assert!(normalize_paths(&["a//b".to_owned()]).is_err());
1768 }
1769
1770 #[test]
1771 fn views_are_the_paths_and_the_files_above_them() {
1772 let paths = vec!["src/deep".to_owned(), "docs/guide.md".to_owned()];
1773 for (path, seen) in [
1774 ("README", true),
1775 ("src/a.rs", true),
1776 ("src/deep/b.rs", true),
1777 ("src/deep/more/c.rs", true),
1778 ("src/other/d.rs", false),
1779 ("docs/guide.md", true),
1780 ("docs/other.md", true),
1781 ("docs/api/x.md", false),
1782 ("srcx/a.rs", false),
1783 ("src/deeper/a.rs", false),
1784 ] {
1785 assert_eq!(in_view(path.as_bytes(), &paths), seen, "{path}");
1786 }
1787 assert!(in_view(b"anything/at/all", &[]));
1788 }
1789
1790 #[test]
1791 fn urls_split_into_repository_and_endpoint() {
1792 let u = CheckoutUrl::parse("http://h:8080/demo.git").ok();
1793 assert_eq!(
1794 u,
1795 Some(CheckoutUrl {
1796 repo_url: "http://h:8080/demo.git".to_owned(),
1797 name: "demo".to_owned(),
1798 api: "http://h:8080/.grit/checkouts/demo".to_owned(),
1799 })
1800 );
1801 let nested = CheckoutUrl::parse("https://h/org/app/").ok();
1802 assert_eq!(nested.as_ref().map(|u| u.name.as_str()), Some("app"));
1803 assert_eq!(
1804 nested.map(|u| u.api),
1805 Some("https://h/.grit/checkouts/org/app".to_owned())
1806 );
1807 assert!(CheckoutUrl::parse("http://h/").is_err());
1808 assert!(CheckoutUrl::parse("ssh://h/demo").is_err());
1809 }
1810
1811 #[test]
1812 fn snapshot_objects_are_deterministic() {
1813 let tree = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").ok();
1814 let base = ObjectId::from_hex("1111111111111111111111111111111111111111").ok();
1815 let (Some(tree), Some(base)) = (tree, base) else {
1816 panic!("oids");
1817 };
1818 let a = snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n");
1819 assert_eq!(
1820 a,
1821 snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n")
1822 );
1823 let text = String::from_utf8_lossy(&a);
1824 assert!(text.starts_with("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor grit-checkout <snapshot@grit-checkout> 1700000000 +0000\n"), "{text}");
1825 assert!(
1826 text.contains("\n\nSnapshot of 111111111111: Fix it\n"),
1827 "{text}"
1828 );
1829 assert!(
1830 text.ends_with("Grit-Checkout-Base: 1111111111111111111111111111111111111111\n"),
1831 "{text}"
1832 );
1833 }
1834
1835 #[test]
1836 fn work_branches_are_named_for_the_committer() {
1837 assert_eq!(
1838 suggest_work_branch(Some("Alice.B@example.com"), [0, 1]),
1839 "alice-b/amber-brook"
1840 );
1841 assert_eq!(suggest_work_branch(None, [2, 3]), "checkout/cedar-dune");
1842 }
1843
1844 fn offloaded(dir: &Path) -> (ObjectId, ObjectId, Vec<u8>) {
1848 use crate::objects::ObjectKind;
1849 let repo = Repository::init(dir, &InitOptions::new().with_bare(true)).unwrap();
1850 let odb = repo.odb();
1851 let small = odb
1852 .write_loose_materialize(ObjectKind::Blob, b"a\n")
1853 .unwrap();
1854 let data = vec![b'x'; 4096];
1855 let big = odb
1856 .write_loose_materialize(ObjectKind::Blob, &data)
1857 .unwrap();
1858 let tree_of = |entries: &[(&str, &str, ObjectId)]| {
1859 let mut body = Vec::new();
1860 for (mode, name, oid) in entries {
1861 body.extend_from_slice(format!("{mode} {name}\0").as_bytes());
1862 body.extend_from_slice(oid.as_bytes());
1863 }
1864 odb.write_loose_materialize(ObjectKind::Tree, &body)
1865 .unwrap()
1866 };
1867 let src = tree_of(&[("100644", "a", small), ("100644", "big", big)]);
1868 let root = tree_of(&[("40000", "src", src)]);
1869 let commit =
1870 format!("tree {root}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nc\n");
1871 let commit = odb
1872 .write_loose_materialize(ObjectKind::Commit, commit.as_bytes())
1873 .unwrap();
1874 for (oids, promisor) in [(vec![commit, root, src], true), (vec![small], false)] {
1875 let pack = crate::pack_objects::build_pack_from_send_list(
1876 odb,
1877 &oids,
1878 &std::collections::HashSet::new(),
1879 &crate::pack_objects::PackBuildOptions::default(),
1880 &std::collections::HashMap::new(),
1881 )
1882 .unwrap();
1883 crate::index_pack::install_pack_bytes(
1884 pack,
1885 odb,
1886 &crate::index_pack::IngestPackOptions {
1887 mark_promisor_pack: promisor,
1888 ..Default::default()
1889 },
1890 )
1891 .unwrap();
1892 }
1893 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
1894 let hex = big.to_hex();
1895 std::fs::remove_file(dir.join("objects").join(&hex[..2]).join(&hex[2..])).unwrap();
1896 let mut config = std::fs::read_to_string(dir.join("config")).unwrap();
1897 config.push_str("[remote \"lop\"]\n\turl = /nowhere\n\tpromisor = true\n");
1898 std::fs::write(dir.join("config"), config).unwrap();
1899 crate::pack::clear_pack_cache();
1900 (commit, big, data)
1901 }
1902
1903 struct StubFetcher {
1905 blobs: Vec<(ObjectId, Vec<u8>)>,
1906 calls: std::sync::Mutex<Vec<Vec<ObjectId>>>,
1907 }
1908
1909 impl crate::promisor_fetch::PromisorFetcher for StubFetcher {
1910 fn fetch_missing(&self, repo: &Repository, oids: &[ObjectId]) -> crate::error::Result<()> {
1911 self.calls
1912 .lock()
1913 .unwrap_or_else(|e| e.into_inner())
1914 .push(oids.to_vec());
1915 for (oid, data) in &self.blobs {
1916 if oids.contains(oid) {
1917 repo.odb()
1918 .write_loose_materialize(crate::objects::ObjectKind::Blob, data)?;
1919 }
1920 }
1921 Ok(())
1922 }
1923 }
1924
1925 fn open_with(dir: &Path, fetcher: Option<std::sync::Arc<StubFetcher>>) -> Repository {
1926 let fetcher =
1927 fetcher.map(|f| f as std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>);
1928 Repository::open(
1929 dir,
1930 &crate::config::environment::RepositoryOptions::empty().with_promisor_fetcher(fetcher),
1931 )
1932 .unwrap()
1933 }
1934
1935 #[test]
1936 fn a_snapshot_of_offloaded_blobs_is_refused_without_lazy_fetch() {
1937 let tmp = tempfile::tempdir().unwrap();
1938 let (commit, big, _) = offloaded(tmp.path());
1939 let repo = open_with(tmp.path(), None);
1940 let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
1941 assert!(
1942 matches!(err, CheckoutError::PromisedBlobs { oid, count: 1 } if oid == big),
1943 "{err:?}"
1944 );
1945 assert!(err.to_string().contains("uploadpack.lazyFetch"), "{err}");
1946 }
1947
1948 #[test]
1949 fn a_snapshot_of_offloaded_blobs_fetches_them_in_one_request() {
1950 let tmp = tempfile::tempdir().unwrap();
1951 let (commit, big, data) = offloaded(tmp.path());
1952 let fetcher = std::sync::Arc::new(StubFetcher {
1953 blobs: vec![(big, data)],
1954 calls: std::sync::Mutex::default(),
1955 });
1956 let repo = open_with(tmp.path(), Some(std::sync::Arc::clone(&fetcher)));
1957 let snap = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap();
1958 assert_eq!((snap.files, snap.bytes), (2, 4096 + 2));
1959 assert_eq!(*fetcher.calls.lock().unwrap(), vec![vec![big]]);
1960 assert_eq!(fetch_promised_blobs(&repo, snap.commit).unwrap(), 0);
1962 assert_eq!(fetcher.calls.lock().unwrap().len(), 1);
1963 }
1964
1965 #[test]
1966 fn a_fetch_that_brings_nothing_is_reported() {
1967 let tmp = tempfile::tempdir().unwrap();
1968 let (commit, big, _) = offloaded(tmp.path());
1969 let fetcher = std::sync::Arc::new(StubFetcher {
1970 blobs: Vec::new(),
1971 calls: std::sync::Mutex::default(),
1972 });
1973 let repo = open_with(tmp.path(), Some(fetcher));
1974 let err = fetch_promised_blobs(&repo, commit).unwrap_err();
1975 assert!(
1976 matches!(err, CheckoutError::PromisedBlobsNotFetched { oid, count: 1, .. } if oid == big),
1977 "{err:?}"
1978 );
1979 }
1980
1981 #[test]
1982 fn a_repository_without_promisor_remotes_is_not_checked() {
1983 let tmp = tempfile::tempdir().unwrap();
1984 let (commit, _, _) = offloaded(tmp.path());
1985 let config = std::fs::read_to_string(tmp.path().join("config")).unwrap();
1986 let plain = config.replace("\tpromisor = true\n", "");
1987 std::fs::write(tmp.path().join("config"), plain).unwrap();
1988 let repo = open_with(tmp.path(), None);
1989 let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
1991 assert!(
1992 !matches!(err, CheckoutError::PromisedBlobs { .. }),
1993 "{err:?}"
1994 );
1995 }
1996}