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(transparent)]
692 Library(Box<crate::error::Error>),
693 #[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
722pub type Result<T> = std::result::Result<T, CheckoutError>;
724
725pub 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
768fn 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(¤t.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#[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
829pub fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
840 cut_tree(odb, tree, paths, true)
841}
842
843pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
845 cut_tree(odb, tree, paths, false)
846}
847
848pub(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#[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#[derive(Debug, Clone, PartialEq, Eq)]
956pub struct Snapshot {
957 pub commit: ObjectId,
959 pub tree: ObjectId,
961 pub base: ObjectId,
963 pub object: Vec<u8>,
965 pub files: u64,
967 pub bytes: u64,
969}
970
971pub 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
1009fn 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#[derive(Debug, Clone, PartialEq, Eq)]
1030pub struct CheckoutUrl {
1031 pub repo_url: String,
1033 pub name: String,
1035 pub api: String,
1037}
1038
1039impl CheckoutUrl {
1040 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
1071pub struct CheckoutClient<'a> {
1073 http: &'a dyn HttpClient,
1074 url: CheckoutUrl,
1075 given: String,
1076}
1077
1078impl<'a> CheckoutClient<'a> {
1079 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 #[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 pub fn server_info(&self) -> Result<CheckoutServerInfo> {
1142 self.json(HttpMethod::Get, &self.url.api, None)
1143 }
1144
1145 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 #[must_use]
1161 pub fn bundle_url(&self, id: &str) -> String {
1162 format!("{}/{id}/bundle", self.url.api)
1163 }
1164
1165 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 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 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 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 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 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 pub fn slices(&self) -> Result<SlicesResponse> {
1275 self.server_info()?;
1276 self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
1277 }
1278
1279 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 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
1307pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1349pub struct CheckoutMarker {
1350 pub url: String,
1352 pub id: String,
1354 pub branch: String,
1356 pub paths: Vec<String>,
1358 pub roots: Vec<SnapshotRoot>,
1360 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1367 pub landed: Vec<String>,
1368}
1369
1370fn 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 #[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 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 #[must_use]
1408 pub fn last(&self) -> Option<&SnapshotRoot> {
1409 self.roots.last()
1410 }
1411
1412 #[must_use]
1415 pub fn upstream_ref(&self) -> String {
1416 upstream_ref(&self.branch)
1417 }
1418}
1419
1420#[derive(Debug)]
1422pub struct Materialized {
1423 pub repo: Repository,
1425 pub work: String,
1427 pub files: u64,
1429}
1430
1431#[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#[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}