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 graft_checkin_branch, set_checked_in_branch, BranchSet, CheckinRefusal, CheckinRule,
82};
83pub use graft::{graft, Grafted};
84pub use graft_log::GraftLog;
85pub use guard::TargetGuard;
86pub use ids::{give_change_ids, keeps_every_commit};
87pub use landing::{land_integration, Landing};
88pub use project::{project, project_branch, ProjectError};
89pub use refresh::{ensure_refreshable, refresh, RefreshOutcome, RefreshedHead};
90pub use store::{valid_checkout_id, CheckoutRecord, CheckoutStore, ReservedName, StoredCheckout};
91pub use target::{
92 edit_slices, integrate, read_slices, same_branch_name, target_branch, Integration, SliceEdit,
93 SlicesEdited, TargetChange, TargetRefusal, SLICES_PATH,
94};
95pub use transfer::{fetch_branch, push_for_checkin};
96
97use std::collections::BTreeMap;
98use std::path::{Path, PathBuf};
99
100use serde::{Deserialize, Serialize};
101
102use crate::fetch::Progress;
103use crate::objects::{
104 parse_commit, parse_tree, serialize_tree, tree_entry_cmp, ObjectId, ObjectKind, TreeEntry,
105};
106use crate::odb::Odb;
107use crate::progress::Cancel;
108use crate::repo::{InitOptions, Repository};
109use crate::transport::http::{HttpClient, HttpMethod, HttpRequest};
110use crate::transport_path::redact_url_for_display;
111
112fn checkout_not_offered_message(url: &str, endpoint: &str) -> String {
113 format!(
114 "{} offers no grit checkout (no {} endpoint); it may be a plain Git server, or the repository may not exist",
115 redact_url_for_display(url),
116 redact_url_for_display(endpoint),
117 )
118}
119
120fn checkout_server_message(url: &str, message: &str, status: u16) -> String {
121 format!("{}: {message} (HTTP {status})", redact_url_for_display(url))
122}
123
124fn checkout_protocol_message(url: &str, detail: &str) -> String {
125 format!(
126 "unexpected answer from {}: {detail}",
127 redact_url_for_display(url)
128 )
129}
130
131fn checkout_invalid_url_message(url: &str) -> String {
132 format!(
133 "{} is not an http(s) URL of a repository",
134 redact_url_for_display(url)
135 )
136}
137
138pub const CHECKOUT_API: &str = ".grit/checkouts";
141
142pub(crate) const SNAPSHOT_IDENT: &str = "grit-checkout <snapshot@grit-checkout>";
144
145pub(crate) const SNAPSHOT_BASE_TRAILER: &str = "Grit-Checkout-Base";
147
148pub(crate) const MARKER_FILE: &str = "grit-checkout.json";
151
152const TREE_MODE: u32 = 0o040000;
153const GITLINK_MODE: u32 = 0o160000;
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct CheckoutServerInfo {
158 pub name: String,
160 pub branch: String,
162 pub head: String,
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168pub struct CheckoutRequest {
169 #[serde(default)]
171 pub paths: Vec<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub client: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub work: Option<String>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct SnapshotRoot {
183 pub commit: String,
185 pub base: String,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct CheckoutInfo {
192 pub id: String,
194 pub name: String,
196 pub branch: String,
198 pub paths: Vec<String>,
200 pub base: String,
202 pub root: String,
204 pub root_object: String,
206 pub tree: String,
208 pub roots: Vec<SnapshotRoot>,
210 pub files: u64,
212 pub bytes: u64,
214 pub bundle_bytes: u64,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub work: Option<String>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct CheckoutState {
226 #[serde(flatten)]
228 pub info: CheckoutInfo,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub tip: Option<String>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct WorkBranchRequest {
240 pub work: String,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct WorkBranchResponse {
247 pub work: String,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct BranchRequest {
257 pub name: String,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct BranchInfo {
265 pub name: String,
268 pub tip: String,
270 pub projected: String,
272 pub commits: usize,
274 #[serde(default)]
276 pub made: usize,
277 #[serde(default)]
281 pub reused: usize,
282}
283
284#[must_use]
286pub fn target_ref(id: &str) -> String {
287 format!("refs/checkouts/{id}/target")
288}
289
290#[must_use]
292pub fn valid_branch_name(name: &str) -> bool {
293 crate::check_ref_format::check_refname_format(
294 &format!("refs/heads/{name}"),
295 &crate::check_ref_format::RefNameOptions::default(),
296 )
297 .is_ok()
298}
299
300#[must_use]
302pub fn out_ref(id: &str, name: &str) -> String {
303 format!("refs/checkouts/{id}/out/heads/{name}")
304}
305
306#[must_use]
309pub(crate) fn in_ref(id: &str, name: &str) -> String {
310 format!("refs/checkouts/{id}/in/heads/{name}")
311}
312
313#[must_use]
316pub fn upstream_ref(branch: &str) -> String {
317 format!("refs/remotes/origin/{branch}")
318}
319
320#[must_use]
327pub(crate) fn snapshot_fetch_refspec(id: &str, branch: &str) -> String {
328 format!("+{}:{}", target_ref(id), upstream_ref(branch))
329}
330
331#[must_use]
337pub(crate) fn out_push_refspec(id: &str) -> String {
338 format!("+refs/heads/*:{}", out_ref(id, "*"))
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct CheckinBranch {
345 pub name: String,
347 pub raw: String,
349}
350
351#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
353pub struct CheckinRequest {
354 pub branches: Vec<CheckinBranch>,
356 #[serde(default)]
358 pub force: bool,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum CheckinStatus {
365 Created,
367 Updated,
369 Unchanged,
372 Refused,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct CheckinResult {
379 pub name: String,
381 pub status: CheckinStatus,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub grafted: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub previous: Option<String>,
389 #[serde(default)]
391 pub commits: usize,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub rule: Option<CheckinRule>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub message: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub reason: Option<CheckinRefusal>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct CheckinResponse {
406 pub results: Vec<CheckinResult>,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct Ident {
413 pub name: String,
415 pub email: String,
417}
418
419impl Ident {
420 pub fn author<E: crate::ident_resolve::IdentityEnv>(
436 env: &E,
437 config: &crate::config::ConfigSet,
438 ) -> std::result::Result<Self, crate::ident_resolve::IdentityError> {
439 use crate::ident_resolve::{resolve_email_with, resolve_name_with, IdentRole};
440 Ok(Self {
441 name: resolve_name_with(env, config, IdentRole::Author)?,
442 email: resolve_email_with(env, config, IdentRole::Author)?,
443 })
444 }
445
446 pub fn signature(
457 &self,
458 time: time::OffsetDateTime,
459 ) -> std::result::Result<crate::ident::Signature, crate::ident::SignatureError> {
460 let signature = crate::ident::Signature::cleaned(&self.name, &self.email, time)?;
461 if signature.name.is_empty() {
462 return Ok(crate::ident::Signature {
463 name: signature.email.clone(),
464 ..signature
465 });
466 }
467 Ok(signature)
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct IntegrateRequest {
474 pub name: String,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub author: Option<Ident>,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "snake_case")]
484pub enum IntegrateStatus {
485 Merged,
487 AlreadyIntegrated,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct IntegrateResponse {
494 pub status: IntegrateStatus,
496 pub name: String,
498 pub target: String,
500 pub previous: String,
502 pub head: String,
504 pub commits: usize,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub struct Slice {
511 pub name: String,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub description: Option<String>,
516 pub paths: Vec<String>,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522pub struct SlicesResponse {
523 pub target: String,
525 pub head: String,
527 pub slices: Vec<Slice>,
529}
530
531#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
533pub struct SliceRequest {
534 pub paths: Vec<String>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub description: Option<String>,
539 #[serde(default)]
542 pub create: bool,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub author: Option<Ident>,
546}
547
548#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
550pub struct SliceDeleteRequest {
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub author: Option<Ident>,
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub struct SliceChange {
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub slice: Option<Slice>,
562 pub target: String,
564 pub previous: String,
566 pub head: String,
569}
570
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub struct CheckoutErrorBody {
574 pub error: String,
576}
577
578#[derive(Debug, thiserror::Error)]
580pub enum CheckoutError {
581 #[error("{}", checkout_not_offered_message(.url, .endpoint))]
583 NotOffered {
584 url: String,
586 endpoint: String,
588 },
589 #[error("{}", checkout_server_message(.url, .message, *status))]
591 Server {
592 url: String,
594 status: u16,
596 message: String,
598 },
599 #[error("{}", checkout_invalid_url_message(.0))]
601 InvalidUrl(String),
602 #[error("{path}: {reason}")]
604 InvalidPath {
605 path: String,
607 reason: &'static str,
609 },
610 #[error("{path} is not in the repository at {base}")]
612 MissingPath {
613 path: String,
615 base: String,
617 },
618 #[error("the downloaded snapshot is {got}, but the server described {expected}")]
620 Mismatch {
621 expected: String,
623 got: String,
625 },
626 #[error("{0} already exists and is not empty")]
628 DestinationNotEmpty(PathBuf),
629 #[error("{0} is not a valid branch name")]
631 InvalidBranchName(String),
632 #[error("the record of checkout {id} at {path} is damaged: {detail}")]
634 DamagedRecord {
635 id: String,
637 path: PathBuf,
639 detail: String,
641 },
642 #[error("{refname} kept moving while it was being set")]
644 RefKeptMoving {
645 refname: String,
647 },
648 #[error("the work branches the server reserved are taken here (the last was {name})")]
651 WorkBranchTaken {
652 name: String,
654 },
655 #[error("the server's snapshot moved again while fetching: expected {expected}, got {got}")]
658 SnapshotMoved {
659 expected: String,
661 got: String,
663 },
664 #[error("the server's {name} moved again while fetching: expected {expected}, got {got}")]
667 BranchMoved {
668 name: String,
670 expected: String,
672 got: String,
674 },
675 #[error("{name} has nothing in common with the server's {name}")]
678 UnrelatedBranch {
679 name: String,
681 },
682 #[error("{failed}; HEAD is still on {branch}, which it was switched to for the rebase")]
686 StillSwitched {
687 branch: String,
689 failed: Box<CheckoutError>,
691 },
692 #[error("a {} is in progress", .0.description())]
695 OperationInProgress(crate::state::InProgressOperation),
696 #[error(transparent)]
698 Remote(Box<crate::remote::RemoteError>),
699 #[error(transparent)]
701 Pull(Box<crate::porcelain::pull::PullError>),
702 #[error("{}", checkout_protocol_message(.url, .detail))]
704 Protocol {
705 url: String,
707 detail: String,
709 },
710 #[error(transparent)]
712 Download(Box<crate::resumable::ResumableError>),
713 #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and this server does not fetch from its promisor remotes (uploadpack.lazyFetch is off)")]
718 PromisedBlobs {
719 oid: ObjectId,
721 count: usize,
723 },
724 #[error("the snapshot needs {count} blob(s) kept on a promisor remote, starting with {oid}, and fetching them failed: {source}")]
727 PromisedBlobsNotFetched {
728 oid: ObjectId,
730 count: usize,
732 #[source]
734 source: Box<crate::error::Error>,
735 },
736 #[error(transparent)]
738 Library(Box<crate::error::Error>),
739 #[error(transparent)]
741 Io(#[from] std::io::Error),
742}
743
744impl From<crate::error::Error> for CheckoutError {
745 fn from(e: crate::error::Error) -> Self {
746 Self::Library(Box::new(e))
747 }
748}
749
750impl From<crate::resumable::ResumableError> for CheckoutError {
751 fn from(e: crate::resumable::ResumableError) -> Self {
752 Self::Download(Box::new(e))
753 }
754}
755
756impl From<crate::remote::RemoteError> for CheckoutError {
757 fn from(e: crate::remote::RemoteError) -> Self {
758 Self::Remote(Box::new(e))
759 }
760}
761
762impl From<crate::porcelain::pull::PullError> for CheckoutError {
763 fn from(e: crate::porcelain::pull::PullError) -> Self {
764 Self::Pull(Box::new(e))
765 }
766}
767
768pub type Result<T> = std::result::Result<T, CheckoutError>;
770
771pub fn normalize_paths(paths: &[String]) -> Result<Vec<String>> {
780 let mut out: Vec<String> = Vec::new();
781 for p in paths {
782 let trimmed = p.trim().trim_matches('/');
783 if trimmed.is_empty() {
784 continue;
785 }
786 if p.trim().starts_with('/') {
787 return Err(CheckoutError::InvalidPath {
788 path: p.clone(),
789 reason: "paths are relative to the repository root",
790 });
791 }
792 if trimmed
793 .split('/')
794 .any(|s| s.is_empty() || s == "." || s == "..")
795 {
796 return Err(CheckoutError::InvalidPath {
797 path: p.clone(),
798 reason: "not a plain path inside the repository",
799 });
800 }
801 out.push(trimmed.to_owned());
802 }
803 out.sort();
804 out.dedup();
805 let all = out.clone();
806 out.retain(|p| {
807 !all.iter().any(|q| {
808 q != p && p.starts_with(q.as_str()) && p.as_bytes().get(q.len()) == Some(&b'/')
809 })
810 });
811 Ok(out)
812}
813
814fn lookup(odb: &Odb, tree: ObjectId, path: &[u8]) -> crate::error::Result<Option<(u32, ObjectId)>> {
816 let mut current = (TREE_MODE, tree);
817 for component in path.split(|b| *b == b'/') {
818 if current.0 != TREE_MODE {
819 return Ok(None);
820 }
821 let entries = parse_tree(&odb.read(¤t.1)?.data)?;
822 match entries.into_iter().find(|e| e.name == component) {
823 Some(e) => current = (e.mode, e.oid),
824 None => return Ok(None),
825 }
826 }
827 Ok(Some(current))
828}
829
830#[derive(Default)]
832struct Dir {
833 entries: BTreeMap<Vec<u8>, Node>,
834}
835
836enum Node {
837 Entry(u32, ObjectId),
838 Dir(Dir),
839}
840
841impl Dir {
842 fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
843 match path.iter().position(|b| *b == b'/') {
844 None => {
845 self.entries.insert(path.to_vec(), Node::Entry(mode, oid));
846 }
847 Some(i) => {
848 let node = self
849 .entries
850 .entry(path[..i].to_vec())
851 .or_insert_with(|| Node::Dir(Dir::default()));
852 if let Node::Dir(dir) = node {
853 dir.insert(&path[i + 1..], mode, oid);
854 }
855 }
856 }
857 }
858
859 fn write(self, odb: &Odb) -> crate::error::Result<ObjectId> {
860 let mut entries = Vec::with_capacity(self.entries.len());
861 for (name, node) in self.entries {
862 let (mode, oid) = match node {
863 Node::Entry(mode, oid) => (mode, oid),
864 Node::Dir(dir) => (TREE_MODE, dir.write(odb)?),
865 };
866 entries.push(TreeEntry { mode, name, oid });
867 }
868 entries.sort_by(|a, b| {
869 tree_entry_cmp(&a.name, a.mode == TREE_MODE, &b.name, b.mode == TREE_MODE)
870 });
871 odb.write(ObjectKind::Tree, &serialize_tree(&entries))
872 }
873}
874
875pub(crate) fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
886 cut_tree(odb, tree, paths, true)
887}
888
889pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
891 cut_tree(odb, tree, paths, false)
892}
893
894pub(crate) fn in_view(path: &[u8], paths: &[String]) -> bool {
898 if paths.is_empty() {
899 return true;
900 }
901 let parent = path
902 .iter()
903 .rposition(|b| *b == b'/')
904 .map_or(&b""[..], |i| &path[..i]);
905 let under = |dir: &[u8], p: &[u8]| p.starts_with(dir) && p.get(dir.len()) == Some(&b'/');
906 parent.is_empty()
907 || paths
908 .iter()
909 .map(String::as_bytes)
910 .any(|p| path == p || under(p, path) || under(parent, p))
911}
912
913fn cut_tree(odb: &Odb, tree: ObjectId, paths: &[String], strict: bool) -> Result<ObjectId> {
914 if paths.is_empty() {
915 return Ok(tree);
916 }
917 let mut dirs: Vec<&[u8]> = vec![b""];
918 for p in paths {
919 let p = p.as_bytes();
920 dirs.extend(
921 p.iter()
922 .enumerate()
923 .filter(|(_, b)| **b == b'/')
924 .map(|(i, _)| &p[..i]),
925 );
926 }
927 dirs.sort();
928 dirs.dedup();
929 let mut root = Dir::default();
930 for dir in dirs {
931 let oid = if dir.is_empty() {
932 tree
933 } else {
934 match lookup(odb, tree, dir)? {
935 Some((TREE_MODE, oid)) => oid,
936 _ => continue,
937 }
938 };
939 for entry in parse_tree(&odb.read(&oid)?.data)? {
940 if entry.mode == TREE_MODE {
941 continue;
942 }
943 let path = if dir.is_empty() {
944 entry.name.clone()
945 } else {
946 [dir, b"/", &entry.name].concat()
947 };
948 root.insert(&path, entry.mode, entry.oid);
949 }
950 }
951 for p in paths {
952 match lookup(odb, tree, p.as_bytes())? {
953 Some((mode, oid)) => root.insert(p.as_bytes(), mode, oid),
954 None if strict => {
955 return Err(CheckoutError::MissingPath {
956 path: p.clone(),
957 base: tree.to_hex(),
958 })
959 }
960 None => {}
961 }
962 }
963 Ok(root.write(odb)?)
964}
965
966#[must_use]
974pub(crate) fn snapshot_object(
975 tree: ObjectId,
976 parent: Option<ObjectId>,
977 base: ObjectId,
978 base_time: i64,
979 base_message: &str,
980) -> Vec<u8> {
981 let base = base.to_hex();
982 let who = format!("{SNAPSHOT_IDENT} {base_time} +0000");
983 let mut s = format!("tree {}\n", tree.to_hex());
984 if let Some(p) = parent {
985 s.push_str(&format!("parent {}\n", p.to_hex()));
986 }
987 let subject = base_message.lines().next().unwrap_or_default().trim();
988 let quoted: String = base_message
989 .trim_end()
990 .lines()
991 .map(|l| format!(" {l}").trim_end().to_owned() + "\n")
992 .collect();
993 s.push_str(&format!(
994 "author {who}\ncommitter {who}\n\nSnapshot of {}: {subject}\n\nStands for {base}:\n\n{quoted}\n{SNAPSHOT_BASE_TRAILER}: {base}\n",
995 &base[..base.len().min(12)]
996 ));
997 s.into_bytes()
998}
999
1000#[derive(Debug, Clone, PartialEq, Eq)]
1002pub struct Snapshot {
1003 pub commit: ObjectId,
1005 pub tree: ObjectId,
1007 pub base: ObjectId,
1009 pub object: Vec<u8>,
1011 pub files: u64,
1013 pub bytes: u64,
1015}
1016
1017pub fn make_snapshot(
1031 repo: &Repository,
1032 base: ObjectId,
1033 paths: &[String],
1034 parent: Option<ObjectId>,
1035) -> Result<Snapshot> {
1036 let odb = repo.odb();
1037 let commit = odb.read_commit(&base)?;
1038 let tree = filtered_tree(odb, commit.tree, paths).map_err(|e| match e {
1039 CheckoutError::MissingPath { path, .. } => CheckoutError::MissingPath {
1040 path,
1041 base: base.to_hex(),
1042 },
1043 other => other,
1044 })?;
1045 let time = crate::ident::parse_signature_times(&commit.committer)
1046 .map(|t| t.unix_seconds)
1047 .unwrap_or(0);
1048 let object = snapshot_object(tree, parent, base, time, &commit.message);
1049 fetch_promised_tree_blobs(repo, tree)?;
1050 let id = odb.write(ObjectKind::Commit, &object)?;
1051 let (files, bytes) = tree_stats(odb, tree)?;
1052 Ok(Snapshot {
1053 commit: id,
1054 tree,
1055 base,
1056 object,
1057 files,
1058 bytes,
1059 })
1060}
1061
1062pub fn fetch_promised_blobs(repo: &Repository, snapshot: ObjectId) -> Result<usize> {
1092 let tree = parse_commit(&repo.odb().read(&snapshot)?.data)?.tree;
1093 fetch_promised_tree_blobs(repo, tree)
1094}
1095
1096fn fetch_promised_tree_blobs(repo: &Repository, tree: ObjectId) -> Result<usize> {
1098 let config = repo.config()?;
1099 if !crate::promisor::repo_treats_promisor_packs(repo.git_dir(), config.as_ref()) {
1100 return Ok(0);
1101 }
1102 let odb = repo.odb();
1103 let mut blobs: Vec<(ObjectId, ObjectId)> = Vec::new();
1105 let mut seen = crate::objects::OidHashSet::default();
1106 let mut stack = vec![tree];
1107 while let Some(oid) = stack.pop() {
1108 for entry in parse_tree(&odb.read(&oid)?.data)? {
1109 match entry.mode {
1110 TREE_MODE => stack.push(entry.oid),
1111 GITLINK_MODE => {}
1112 _ if seen.insert(entry.oid) => blobs.push((entry.oid, oid)),
1113 _ => {}
1114 }
1115 }
1116 }
1117 let missing: crate::objects::OidHashSet = odb
1118 .missing_stored_oids(blobs.iter().map(|(blob, _)| *blob))?
1119 .into_iter()
1120 .collect();
1121 let Some(&(first, referrer)) = blobs.iter().find(|(blob, _)| missing.contains(blob)) else {
1122 return Ok(0);
1123 };
1124 let promised = crate::serve::promised_objects::PromisorObjects::new(repo)?;
1125 if !promised.is_promised(&first, Some(&referrer))? {
1126 return Err(crate::error::Error::ObjectNotFound(first.to_hex()).into());
1127 }
1128 let count = missing.len();
1129 if repo.caches().promisor_fetcher().is_none() {
1130 return Err(CheckoutError::PromisedBlobs { oid: first, count });
1131 }
1132 let wanted: Vec<ObjectId> = blobs
1133 .iter()
1134 .map(|(blob, _)| *blob)
1135 .filter(|blob| missing.contains(blob))
1136 .collect();
1137 let not_fetched =
1138 |oid: ObjectId, source: crate::error::Error| CheckoutError::PromisedBlobsNotFetched {
1139 oid,
1140 count,
1141 source: Box::new(source),
1142 };
1143 repo.prefetch_promised(&wanted)
1144 .map_err(|e| not_fetched(first, e))?;
1145 odb.reprepare_local_packs();
1146 odb.invalidate_packs();
1147 if let Some(&still) = odb.missing_stored_oids(wanted.iter().copied())?.first() {
1148 return Err(not_fetched(
1149 still,
1150 crate::error::Error::PromisedObjectMissing(still),
1151 ));
1152 }
1153 Ok(count)
1154}
1155
1156fn tree_stats(odb: &Odb, tree: ObjectId) -> crate::error::Result<(u64, u64)> {
1158 let (mut files, mut bytes) = (0u64, 0u64);
1159 let mut stack = vec![tree];
1160 while let Some(oid) = stack.pop() {
1161 for entry in parse_tree(&odb.read(&oid)?.data)? {
1162 match entry.mode {
1163 TREE_MODE => stack.push(entry.oid),
1164 GITLINK_MODE => {}
1165 _ => {
1166 files += 1;
1167 bytes += odb.read_info(&entry.oid)?.size;
1168 }
1169 }
1170 }
1171 }
1172 Ok((files, bytes))
1173}
1174
1175#[derive(Debug, Clone, PartialEq, Eq)]
1177pub struct CheckoutUrl {
1178 pub repo_url: String,
1180 pub name: String,
1182 pub api: String,
1184}
1185
1186impl CheckoutUrl {
1187 pub fn parse(url: &str) -> Result<Self> {
1194 let invalid = || CheckoutError::InvalidUrl(url.to_owned());
1195 let parsed = url::Url::parse(url).map_err(|_| invalid())?;
1196 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
1197 return Err(invalid());
1198 }
1199 let path = parsed.path().trim_matches('/');
1200 let path = path.strip_suffix(".git").unwrap_or(path);
1201 if path.is_empty() {
1202 return Err(invalid());
1203 }
1204 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
1205 let mut server = parsed.clone();
1206 server.set_path("");
1207 server.set_query(None);
1208 server.set_fragment(None);
1209 let server = server.as_str().trim_end_matches('/').to_owned();
1210 Ok(Self {
1211 repo_url: format!("{server}/{path}.git"),
1212 name,
1213 api: format!("{server}/{CHECKOUT_API}/{path}"),
1214 })
1215 }
1216}
1217
1218pub struct CheckoutClient<'a> {
1220 http: &'a dyn HttpClient,
1221 url: CheckoutUrl,
1222 given: String,
1223}
1224
1225impl<'a> CheckoutClient<'a> {
1226 pub fn new(http: &'a dyn HttpClient, url: &str) -> Result<Self> {
1232 Ok(Self {
1233 http,
1234 url: CheckoutUrl::parse(url)?,
1235 given: url.to_owned(),
1236 })
1237 }
1238
1239 #[must_use]
1241 pub fn url(&self) -> &CheckoutUrl {
1242 &self.url
1243 }
1244
1245 fn json<T: serde::de::DeserializeOwned>(
1246 &self,
1247 method: HttpMethod,
1248 url: &str,
1249 body: Option<&[u8]>,
1250 ) -> Result<T> {
1251 let mut request = HttpRequest::new(method, url).header("Accept", "application/json");
1252 if let Some(body) = body {
1253 request = request
1254 .header("Content-Type", "application/json")
1255 .body(body);
1256 }
1257 let response = self.http.send(&request)?;
1258 let status = response.status;
1259 let bytes = response.into_bytes()?;
1260 if status == 404 && url == self.url.api {
1261 return Err(CheckoutError::NotOffered {
1262 url: self.given.clone(),
1263 endpoint: self.url.api.clone(),
1264 });
1265 }
1266 if !(200..300).contains(&status) {
1267 let message = serde_json::from_slice::<CheckoutErrorBody>(&bytes)
1268 .map(|b| b.error)
1269 .unwrap_or_else(|_| String::from_utf8_lossy(&bytes).trim().to_owned());
1270 return Err(CheckoutError::Server {
1271 url: url.to_owned(),
1272 status,
1273 message,
1274 });
1275 }
1276 serde_json::from_slice(&bytes).map_err(|e| CheckoutError::Protocol {
1277 url: url.to_owned(),
1278 detail: e.to_string(),
1279 })
1280 }
1281
1282 pub fn server_info(&self) -> Result<CheckoutServerInfo> {
1289 self.json(HttpMethod::Get, &self.url.api, None)
1290 }
1291
1292 pub fn create(&self, request: &CheckoutRequest) -> Result<CheckoutInfo> {
1299 let body = serde_json::to_vec(request).map_err(|e| CheckoutError::Protocol {
1300 url: self.url.api.clone(),
1301 detail: e.to_string(),
1302 })?;
1303 self.json(HttpMethod::Post, &self.url.api, Some(&body))
1304 }
1305
1306 #[must_use]
1308 pub fn bundle_url(&self, id: &str) -> String {
1309 format!("{}/{id}/bundle", self.url.api)
1310 }
1311
1312 pub fn show(&self, id: &str) -> Result<CheckoutState> {
1320 self.json(HttpMethod::Get, &format!("{}/{id}", self.url.api), None)
1321 }
1322
1323 fn post_json<T: Serialize, R: serde::de::DeserializeOwned>(
1324 &self,
1325 method: HttpMethod,
1326 url: &str,
1327 body: &T,
1328 ) -> Result<R> {
1329 let body = serde_json::to_vec(body).map_err(|e| CheckoutError::Protocol {
1330 url: url.to_owned(),
1331 detail: e.to_string(),
1332 })?;
1333 self.json(method, url, Some(&body))
1334 }
1335
1336 pub fn checkin(&self, id: &str, request: &CheckinRequest) -> Result<CheckinResponse> {
1342 self.post_json(
1343 HttpMethod::Post,
1344 &format!("{}/{id}/checkin", self.url.api),
1345 request,
1346 )
1347 }
1348
1349 pub fn update(&self, id: &str) -> Result<CheckoutInfo> {
1356 self.post_json(
1357 HttpMethod::Post,
1358 &format!("{}/{id}/update", self.url.api),
1359 &(),
1360 )
1361 }
1362
1363 pub fn reserve_work_branch(&self, id: &str, wanted: &str) -> Result<String> {
1375 let reserved: WorkBranchResponse = self.post_json(
1376 HttpMethod::Post,
1377 &format!("{}/{id}/work", self.url.api),
1378 &WorkBranchRequest {
1379 work: wanted.to_owned(),
1380 },
1381 )?;
1382 Ok(reserved.work)
1383 }
1384
1385 pub fn branch(&self, id: &str, name: &str) -> Result<BranchInfo> {
1394 self.post_json(
1395 HttpMethod::Post,
1396 &format!("{}/{id}/branch", self.url.api),
1397 &BranchRequest {
1398 name: name.to_owned(),
1399 },
1400 )
1401 }
1402
1403 pub fn integrate(&self, request: &IntegrateRequest) -> Result<IntegrateResponse> {
1409 self.post_json(
1410 HttpMethod::Post,
1411 &format!("{}/integrate", self.url.api),
1412 request,
1413 )
1414 }
1415
1416 pub fn slices(&self) -> Result<SlicesResponse> {
1422 self.server_info()?;
1423 self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
1424 }
1425
1426 pub fn put_slice(&self, name: &str, request: &SliceRequest) -> Result<SliceChange> {
1433 self.post_json(
1434 HttpMethod::Put,
1435 &format!("{}/slices/{name}", self.url.api),
1436 request,
1437 )
1438 }
1439
1440 pub fn delete_slice(&self, name: &str, request: &SliceDeleteRequest) -> Result<SliceChange> {
1446 self.post_json(
1447 HttpMethod::Delete,
1448 &format!("{}/slices/{name}", self.url.api),
1449 request,
1450 )
1451 }
1452}
1453
1454pub fn branches_with_work(
1461 repo: &Repository,
1462 marker: &CheckoutMarker,
1463) -> Result<Vec<(String, ObjectId)>> {
1464 let roots: Vec<ObjectId> = marker
1465 .roots
1466 .iter()
1467 .filter_map(|r| ObjectId::from_hex(&r.commit).ok())
1468 .collect();
1469 let mut out = Vec::new();
1470 for (refname, tip) in crate::refs::list_refs(repo.git_dir(), "refs/heads/")? {
1471 if roots.contains(&tip) {
1472 continue;
1473 }
1474 let mut on_snapshot = false;
1475 for root in &roots {
1476 if crate::merge_base::is_ancestor(repo, *root, tip)? {
1477 on_snapshot = true;
1478 break;
1479 }
1480 }
1481 if on_snapshot {
1482 let name = refname
1483 .strip_prefix("refs/heads/")
1484 .unwrap_or(&refname)
1485 .to_owned();
1486 out.push((name, tip));
1487 }
1488 }
1489 Ok(out)
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1496pub struct CheckoutMarker {
1497 pub url: String,
1499 pub id: String,
1501 pub branch: String,
1503 pub paths: Vec<String>,
1505 pub roots: Vec<SnapshotRoot>,
1507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1514 pub landed: Vec<String>,
1515}
1516
1517fn marker_path(git_dir: &Path) -> PathBuf {
1520 crate::refs::common_dir(git_dir)
1521 .unwrap_or_else(|| git_dir.to_path_buf())
1522 .join(MARKER_FILE)
1523}
1524
1525impl CheckoutMarker {
1526 #[must_use]
1533 pub fn read(git_dir: &Path) -> Option<Self> {
1534 serde_json::from_slice(&std::fs::read(marker_path(git_dir)).ok()?).ok()
1535 }
1536
1537 pub fn write(&self, git_dir: &Path) -> Result<()> {
1544 let bytes =
1545 serde_json::to_vec_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
1546 let path = marker_path(git_dir);
1547 let tmp = path.with_file_name(format!("{MARKER_FILE}.tmp"));
1548 std::fs::write(&tmp, bytes)?;
1549 std::fs::rename(&tmp, path)?;
1550 Ok(())
1551 }
1552
1553 #[must_use]
1555 pub fn last(&self) -> Option<&SnapshotRoot> {
1556 self.roots.last()
1557 }
1558
1559 #[must_use]
1562 pub fn upstream_ref(&self) -> String {
1563 upstream_ref(&self.branch)
1564 }
1565}
1566
1567#[derive(Debug)]
1569pub struct Materialized {
1570 pub repo: Repository,
1572 pub work: String,
1574 pub files: u64,
1576}
1577
1578#[allow(clippy::too_many_arguments)]
1594pub fn materialize(
1595 dest: &Path,
1596 info: &CheckoutInfo,
1597 bundle: &Path,
1598 repo_url: &str,
1599 work: &str,
1600 progress: &mut dyn Progress,
1601 cancel: &dyn Cancel,
1602) -> Result<Materialized> {
1603 if dest.is_dir() && std::fs::read_dir(dest)?.next().is_some() {
1604 return Err(CheckoutError::DestinationNotEmpty(dest.to_path_buf()));
1605 }
1606 std::fs::create_dir_all(dest)?;
1607 let repo = Repository::init(
1608 dest,
1609 &InitOptions {
1610 initial_branch: Some(work.to_owned()),
1611 ..InitOptions::default()
1612 },
1613 )?;
1614 let opened = repo
1615 .bundles()
1616 .open(bundle)
1617 .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1618 let unbundled = opened
1619 .unbundle_with_progress(&repo, progress, cancel)
1620 .map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
1621 let mismatch = |got: String| CheckoutError::Mismatch {
1622 expected: info.root.clone(),
1623 got,
1624 };
1625 let root = ObjectId::from_hex(&info.root).map_err(|_| mismatch(info.root.clone()))?;
1626 let tip = unbundled
1627 .refs
1628 .first()
1629 .map(|r| r.oid)
1630 .ok_or_else(|| mismatch("an empty bundle".to_owned()))?;
1631 if tip != root {
1632 return Err(mismatch(tip.to_hex()));
1633 }
1634 let object = repo.odb().read(&root)?;
1635 if object.data != info.root_object.as_bytes() {
1636 return Err(mismatch(format!(
1637 "{} with different contents",
1638 root.to_hex()
1639 )));
1640 }
1641 let git_dir = repo.git_dir().to_path_buf();
1642 let upstream = upstream_ref(&info.branch);
1643 crate::refs::write_ref(&git_dir, &upstream, &root)?;
1644 crate::refs::write_symbolic_ref(&git_dir, "refs/remotes/origin/HEAD", &upstream)?;
1645 crate::refs::write_ref(&git_dir, &format!("refs/heads/{work}"), &root)?;
1646 crate::refs::write_symbolic_ref(&git_dir, "HEAD", &format!("refs/heads/{work}"))?;
1647 let config_path = git_dir.join("config");
1648 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1649 let mut config = crate::config::ConfigFile::parse(
1650 &config_path,
1651 &content,
1652 crate::config::ConfigScope::Local,
1653 )?;
1654 config.set("remote.origin.url", repo_url)?;
1655 config.set(
1656 "remote.origin.fetch",
1657 &snapshot_fetch_refspec(&info.id, &info.branch),
1658 )?;
1659 config.set("remote.origin.push", &out_push_refspec(&info.id))?;
1660 config.set("remote.origin.tagOpt", "--no-tags")?;
1661 config.write()?;
1662 CheckoutMarker {
1663 url: repo_url.to_owned(),
1664 id: info.id.clone(),
1665 branch: info.branch.clone(),
1666 paths: info.paths.clone(),
1667 roots: info.roots.clone(),
1668 landed: Vec::new(),
1669 }
1670 .write(&git_dir)?;
1671 let tree = ObjectId::from_hex(&info.tree).map_err(|_| mismatch(info.tree.clone()))?;
1672 crate::porcelain::checkout::checkout_between_trees_with_progress(
1673 &repo, None, &tree, progress, cancel,
1674 )?;
1675 let files = u64::try_from(repo.index_handle().load()?.entries().len()).unwrap_or(u64::MAX);
1676 Ok(Materialized {
1677 repo,
1678 work: work.to_owned(),
1679 files,
1680 })
1681}
1682
1683const WORDS: &[&str] = &[
1684 "amber", "brook", "cedar", "dune", "ember", "fern", "glade", "harbor", "iris", "juniper",
1685 "kestrel", "lagoon", "maple", "nectar", "orchid", "pine", "quartz", "river", "sage", "thistle",
1686 "umber", "violet", "willow", "yarrow", "zephyr", "aspen", "birch", "coral", "delta", "elm",
1687 "flint", "grove", "hazel", "inlet", "jasper", "lark", "moss", "north", "oak", "pebble",
1688 "quill", "reed", "slate", "tide", "vale", "wren",
1689];
1690
1691#[must_use]
1694pub fn suggest_work_branch(email: Option<&str>, random: [u8; 2]) -> String {
1695 let who: String = email
1696 .and_then(|e| e.split('@').next())
1697 .unwrap_or_default()
1698 .chars()
1699 .map(|c| {
1700 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1701 c.to_ascii_lowercase()
1702 } else {
1703 '-'
1704 }
1705 })
1706 .collect();
1707 let who = who.trim_matches('-');
1708 let a = WORDS[usize::from(random[0]) % WORDS.len()];
1709 let b = WORDS[usize::from(random[1]) % WORDS.len()];
1710 if who.is_empty() {
1711 format!("checkout/{a}-{b}")
1712 } else {
1713 format!("{who}/{a}-{b}")
1714 }
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720
1721 #[test]
1722 fn idents_resolve_like_commit_authors_and_sign_cleanly() {
1723 let env_of = |vars: &[(&str, &str)]| {
1724 crate::config::environment::Environment::from_vars(
1725 vars.iter().map(|(k, v)| ((*k).into(), (*v).into())),
1726 std::path::PathBuf::from("/"),
1727 )
1728 };
1729 let env = env_of(&[
1730 ("GIT_AUTHOR_NAME", "A U Thor"),
1731 ("GIT_AUTHOR_EMAIL", "author@example.com"),
1732 ]);
1733 let mut config = crate::config::ConfigSet::new();
1734 let _ = config.add_command_override("user.name", "Alice");
1735 let _ = config.add_command_override("user.email", "alice@example.com");
1736 assert_eq!(
1737 Ident::author(&env, &config).ok(),
1738 Some(Ident {
1739 name: "A U Thor".to_owned(),
1740 email: "author@example.com".to_owned()
1741 }),
1742 "the environment wins over config, as for a commit"
1743 );
1744 let bare = env_of(&[]);
1745 assert_eq!(
1746 Ident::author(&bare, &config).ok(),
1747 Some(Ident {
1748 name: "Alice".to_owned(),
1749 email: "alice@example.com".to_owned()
1750 })
1751 );
1752 assert!(Ident::author(&bare, &crate::config::ConfigSet::new()).is_err());
1753
1754 let time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
1755 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1756 let sneaky = Ident {
1757 name: "Eve\nencoding bogus".to_owned(),
1758 email: "e>v<e@x".to_owned(),
1759 };
1760 assert_eq!(
1761 sneaky
1762 .signature(time)
1763 .map(|s| s.header_line())
1764 .ok()
1765 .as_deref(),
1766 Some("Eveencoding bogus <eve@x> 1700000000 +0000")
1767 );
1768 let nameless = Ident {
1769 name: "<>".to_owned(),
1770 email: "bob@example.com".to_owned(),
1771 };
1772 assert_eq!(
1773 nameless.signature(time).map(|s| s.name).ok().as_deref(),
1774 Some("bob@example.com")
1775 );
1776 }
1777
1778 #[test]
1779 fn paths_are_normalized() {
1780 let paths: Vec<String> = ["b/", "a/x", "a", " c ", "", "a/y"]
1781 .iter()
1782 .map(|s| (*s).to_owned())
1783 .collect();
1784 assert_eq!(
1785 normalize_paths(&paths).ok(),
1786 Some(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
1787 );
1788 assert!(normalize_paths(&["/abs".to_owned()]).is_err());
1789 assert!(normalize_paths(&["a/../b".to_owned()]).is_err());
1790 assert!(normalize_paths(&["a//b".to_owned()]).is_err());
1791 }
1792
1793 #[test]
1794 fn views_are_the_paths_and_the_files_above_them() {
1795 let paths = vec!["src/deep".to_owned(), "docs/guide.md".to_owned()];
1796 for (path, seen) in [
1797 ("README", true),
1798 ("src/a.rs", true),
1799 ("src/deep/b.rs", true),
1800 ("src/deep/more/c.rs", true),
1801 ("src/other/d.rs", false),
1802 ("docs/guide.md", true),
1803 ("docs/other.md", true),
1804 ("docs/api/x.md", false),
1805 ("srcx/a.rs", false),
1806 ("src/deeper/a.rs", false),
1807 ] {
1808 assert_eq!(in_view(path.as_bytes(), &paths), seen, "{path}");
1809 }
1810 assert!(in_view(b"anything/at/all", &[]));
1811 }
1812
1813 #[test]
1814 fn urls_split_into_repository_and_endpoint() {
1815 let u = CheckoutUrl::parse("http://h:8080/demo.git").ok();
1816 assert_eq!(
1817 u,
1818 Some(CheckoutUrl {
1819 repo_url: "http://h:8080/demo.git".to_owned(),
1820 name: "demo".to_owned(),
1821 api: "http://h:8080/.grit/checkouts/demo".to_owned(),
1822 })
1823 );
1824 let nested = CheckoutUrl::parse("https://h/org/app/").ok();
1825 assert_eq!(nested.as_ref().map(|u| u.name.as_str()), Some("app"));
1826 assert_eq!(
1827 nested.map(|u| u.api),
1828 Some("https://h/.grit/checkouts/org/app".to_owned())
1829 );
1830 assert!(CheckoutUrl::parse("http://h/").is_err());
1831 assert!(CheckoutUrl::parse("ssh://h/demo").is_err());
1832 }
1833
1834 #[test]
1835 fn snapshot_objects_are_deterministic() {
1836 let tree = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").ok();
1837 let base = ObjectId::from_hex("1111111111111111111111111111111111111111").ok();
1838 let (Some(tree), Some(base)) = (tree, base) else {
1839 panic!("oids");
1840 };
1841 let a = snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n");
1842 assert_eq!(
1843 a,
1844 snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n")
1845 );
1846 let text = String::from_utf8_lossy(&a);
1847 assert!(text.starts_with("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor grit-checkout <snapshot@grit-checkout> 1700000000 +0000\n"), "{text}");
1848 assert!(
1849 text.contains("\n\nSnapshot of 111111111111: Fix it\n"),
1850 "{text}"
1851 );
1852 assert!(
1853 text.ends_with("Grit-Checkout-Base: 1111111111111111111111111111111111111111\n"),
1854 "{text}"
1855 );
1856 }
1857
1858 #[test]
1859 fn work_branches_are_named_for_the_committer() {
1860 assert_eq!(
1861 suggest_work_branch(Some("Alice.B@example.com"), [0, 1]),
1862 "alice-b/amber-brook"
1863 );
1864 assert_eq!(suggest_work_branch(None, [2, 3]), "checkout/cedar-dune");
1865 }
1866
1867 fn offloaded(dir: &Path) -> (ObjectId, ObjectId, Vec<u8>) {
1871 use crate::objects::ObjectKind;
1872 let repo = Repository::init(dir, &InitOptions::new().with_bare(true)).unwrap();
1873 let odb = repo.odb();
1874 let small = odb
1875 .write_loose_materialize(ObjectKind::Blob, b"a\n")
1876 .unwrap();
1877 let data = vec![b'x'; 4096];
1878 let big = odb
1879 .write_loose_materialize(ObjectKind::Blob, &data)
1880 .unwrap();
1881 let tree_of = |entries: &[(&str, &str, ObjectId)]| {
1882 let mut body = Vec::new();
1883 for (mode, name, oid) in entries {
1884 body.extend_from_slice(format!("{mode} {name}\0").as_bytes());
1885 body.extend_from_slice(oid.as_bytes());
1886 }
1887 odb.write_loose_materialize(ObjectKind::Tree, &body)
1888 .unwrap()
1889 };
1890 let src = tree_of(&[("100644", "a", small), ("100644", "big", big)]);
1891 let root = tree_of(&[("40000", "src", src)]);
1892 let commit =
1893 format!("tree {root}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nc\n");
1894 let commit = odb
1895 .write_loose_materialize(ObjectKind::Commit, commit.as_bytes())
1896 .unwrap();
1897 for (oids, promisor) in [(vec![commit, root, src], true), (vec![small], false)] {
1898 let pack = crate::pack_objects::build_pack_from_send_list(
1899 odb,
1900 &oids,
1901 &std::collections::HashSet::new(),
1902 &crate::pack_objects::PackBuildOptions::default(),
1903 &std::collections::HashMap::new(),
1904 )
1905 .unwrap();
1906 crate::index_pack::install_pack_bytes(
1907 pack,
1908 odb,
1909 &crate::index_pack::IngestPackOptions {
1910 mark_promisor_pack: promisor,
1911 ..Default::default()
1912 },
1913 )
1914 .unwrap();
1915 }
1916 crate::prune_packed::prune_packed_objects(odb.objects_dir(), Default::default()).unwrap();
1917 let hex = big.to_hex();
1918 std::fs::remove_file(dir.join("objects").join(&hex[..2]).join(&hex[2..])).unwrap();
1919 let mut config = std::fs::read_to_string(dir.join("config")).unwrap();
1920 config.push_str("[remote \"lop\"]\n\turl = /nowhere\n\tpromisor = true\n");
1921 std::fs::write(dir.join("config"), config).unwrap();
1922 crate::pack::clear_pack_cache();
1923 (commit, big, data)
1924 }
1925
1926 struct StubFetcher {
1928 blobs: Vec<(ObjectId, Vec<u8>)>,
1929 calls: std::sync::Mutex<Vec<Vec<ObjectId>>>,
1930 }
1931
1932 impl crate::promisor_fetch::PromisorFetcher for StubFetcher {
1933 fn fetch_missing(&self, repo: &Repository, oids: &[ObjectId]) -> crate::error::Result<()> {
1934 self.calls
1935 .lock()
1936 .unwrap_or_else(|e| e.into_inner())
1937 .push(oids.to_vec());
1938 for (oid, data) in &self.blobs {
1939 if oids.contains(oid) {
1940 repo.odb()
1941 .write_loose_materialize(crate::objects::ObjectKind::Blob, data)?;
1942 }
1943 }
1944 Ok(())
1945 }
1946 }
1947
1948 fn open_with(dir: &Path, fetcher: Option<std::sync::Arc<StubFetcher>>) -> Repository {
1949 let fetcher =
1950 fetcher.map(|f| f as std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>);
1951 Repository::open(
1952 dir,
1953 &crate::config::environment::RepositoryOptions::empty().with_promisor_fetcher(fetcher),
1954 )
1955 .unwrap()
1956 }
1957
1958 #[test]
1959 fn a_snapshot_of_offloaded_blobs_is_refused_without_lazy_fetch() {
1960 let tmp = tempfile::tempdir().unwrap();
1961 let (commit, big, _) = offloaded(tmp.path());
1962 let repo = open_with(tmp.path(), None);
1963 let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
1964 assert!(
1965 matches!(err, CheckoutError::PromisedBlobs { oid, count: 1 } if oid == big),
1966 "{err:?}"
1967 );
1968 assert!(err.to_string().contains("uploadpack.lazyFetch"), "{err}");
1969 }
1970
1971 #[test]
1972 fn a_snapshot_of_offloaded_blobs_fetches_them_in_one_request() {
1973 let tmp = tempfile::tempdir().unwrap();
1974 let (commit, big, data) = offloaded(tmp.path());
1975 let fetcher = std::sync::Arc::new(StubFetcher {
1976 blobs: vec![(big, data)],
1977 calls: std::sync::Mutex::default(),
1978 });
1979 let repo = open_with(tmp.path(), Some(std::sync::Arc::clone(&fetcher)));
1980 let snap = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap();
1981 assert_eq!((snap.files, snap.bytes), (2, 4096 + 2));
1982 assert_eq!(*fetcher.calls.lock().unwrap(), vec![vec![big]]);
1983 assert_eq!(fetch_promised_blobs(&repo, snap.commit).unwrap(), 0);
1985 assert_eq!(fetcher.calls.lock().unwrap().len(), 1);
1986 }
1987
1988 #[test]
1989 fn a_fetch_that_brings_nothing_is_reported() {
1990 let tmp = tempfile::tempdir().unwrap();
1991 let (commit, big, _) = offloaded(tmp.path());
1992 let fetcher = std::sync::Arc::new(StubFetcher {
1993 blobs: Vec::new(),
1994 calls: std::sync::Mutex::default(),
1995 });
1996 let repo = open_with(tmp.path(), Some(fetcher));
1997 let err = fetch_promised_blobs(&repo, commit).unwrap_err();
1998 assert!(
1999 matches!(err, CheckoutError::PromisedBlobsNotFetched { oid, count: 1, .. } if oid == big),
2000 "{err:?}"
2001 );
2002 }
2003
2004 #[test]
2005 fn checkout_error_display_redacts_url_credentials() {
2006 const TOKEN: &str = "grit_07ec1be79abd2747c4eecaef7d7c5123c98d7cd1";
2007 let url = format!("http://admin%40x.com:{TOKEN}@127.0.0.1:8080/.grit/checkouts/demo");
2008 let endpoint = format!("{url}/integrate");
2009 let samples = [
2010 CheckoutError::NotOffered {
2011 url: url.clone(),
2012 endpoint: endpoint.clone(),
2013 }
2014 .to_string(),
2015 CheckoutError::Server {
2016 url: endpoint.clone(),
2017 status: 409,
2018 message: "cursoragent/pebble conflicts with master in lib.rs".to_owned(),
2019 }
2020 .to_string(),
2021 CheckoutError::Protocol {
2022 url: endpoint.clone(),
2023 detail: "not json".to_owned(),
2024 }
2025 .to_string(),
2026 CheckoutError::InvalidUrl(url.clone()).to_string(),
2027 ];
2028 assert!(
2029 samples[0].contains("/.grit/checkouts/demo"),
2030 "path missing: {}",
2031 samples[0]
2032 );
2033 for text in &samples {
2034 assert!(!text.contains(TOKEN), "token leaked: {text}");
2035 assert!(
2036 !text.contains("admin%40x.com") && !text.contains("admin@x.com"),
2037 "user leaked: {text}"
2038 );
2039 assert!(text.contains("127.0.0.1:8080"), "host missing: {text}");
2040 }
2041 }
2042
2043 #[test]
2044 fn a_repository_without_promisor_remotes_is_not_checked() {
2045 let tmp = tempfile::tempdir().unwrap();
2046 let (commit, _, _) = offloaded(tmp.path());
2047 let config = std::fs::read_to_string(tmp.path().join("config")).unwrap();
2048 let plain = config.replace("\tpromisor = true\n", "");
2049 std::fs::write(tmp.path().join("config"), plain).unwrap();
2050 let repo = open_with(tmp.path(), None);
2051 let err = make_snapshot(&repo, commit, &["src".to_owned()], None).unwrap_err();
2053 assert!(
2054 !matches!(err, CheckoutError::PromisedBlobs { .. }),
2055 "{err:?}"
2056 );
2057 }
2058}