mod branch_refresh;
mod checkin;
mod graft;
mod graft_log;
mod guard;
mod ids;
mod landing;
mod project;
mod refresh;
mod refs;
mod store;
mod target;
#[cfg(test)]
mod testing;
mod transfer;
mod tree;
pub use branch_refresh::{refresh_branch, BranchRefresh, BranchRefreshResult};
pub use checkin::{
decide_checkin, graft_checkin_branch, set_checked_in_branch, BranchSet, CheckinDecision,
CheckinRefusal, CheckinRule,
};
pub use graft::{graft, GraftError, Grafted};
pub use graft_log::GraftLog;
pub use guard::TargetGuard;
pub use ids::{change_keys, give_change_ids, keeps_every_commit};
pub use landing::{land_integration, move_off_landed_branch, record_landed, Landing, MovedOff};
pub use project::{project, project_branch, ProjectError, ProjectedCommit, Projection};
pub use refresh::{
ensure_refreshable, refresh, BranchOnOldSnapshot, FollowedBranch, RefreshOutcome, RefreshedHead,
};
pub use store::{valid_checkout_id, CheckoutRecord, CheckoutStore, ReservedName, StoredCheckout};
pub use target::{
edit_slices, integrate, missing_paths, parse_slices, read_slices, render_slices,
same_branch_name, target_branch, valid_slice_name, Integration, SliceEdit, SlicesEdited,
TargetChange, TargetRefusal, SLICES_PATH,
};
pub use transfer::{fetch_branch, fetch_snapshot, push_for_checkin, FetchedSnapshot};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fetch::Progress;
use crate::objects::{
parse_commit, parse_tree, serialize_tree, tree_entry_cmp, ObjectId, ObjectKind, TreeEntry,
};
use crate::odb::Odb;
use crate::progress::Cancel;
use crate::repo::{InitOptions, Repository};
use crate::transport::http::{HttpClient, HttpMethod, HttpRequest};
pub const CHECKOUT_API: &str = ".grit/checkouts";
pub const SNAPSHOT_IDENT: &str = "grit-checkout <snapshot@grit-checkout>";
pub const SNAPSHOT_BASE_TRAILER: &str = "Grit-Checkout-Base";
pub const MARKER_FILE: &str = "grit-checkout.json";
const TREE_MODE: u32 = 0o040000;
const GITLINK_MODE: u32 = 0o160000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutServerInfo {
pub name: String,
pub branch: String,
pub head: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutRequest {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotRoot {
pub commit: String,
pub base: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutInfo {
pub id: String,
pub name: String,
pub branch: String,
pub paths: Vec<String>,
pub base: String,
pub root: String,
pub root_object: String,
pub tree: String,
pub roots: Vec<SnapshotRoot>,
pub files: u64,
pub bytes: u64,
pub bundle_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutState {
#[serde(flatten)]
pub info: CheckoutInfo,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tip: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkBranchRequest {
pub work: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkBranchResponse {
pub work: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchRequest {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchInfo {
pub name: String,
pub tip: String,
pub projected: String,
pub commits: usize,
#[serde(default)]
pub made: usize,
#[serde(default)]
pub reused: usize,
}
#[must_use]
pub fn target_ref(id: &str) -> String {
format!("refs/checkouts/{id}/target")
}
#[must_use]
pub fn valid_branch_name(name: &str) -> bool {
crate::check_ref_format::check_refname_format(
&format!("refs/heads/{name}"),
&crate::check_ref_format::RefNameOptions::default(),
)
.is_ok()
}
#[must_use]
pub fn out_ref(id: &str, name: &str) -> String {
format!("refs/checkouts/{id}/out/heads/{name}")
}
#[must_use]
pub fn in_ref(id: &str, name: &str) -> String {
format!("refs/checkouts/{id}/in/heads/{name}")
}
#[must_use]
pub fn upstream_ref(branch: &str) -> String {
format!("refs/remotes/origin/{branch}")
}
#[must_use]
pub fn snapshot_fetch_refspec(id: &str, branch: &str) -> String {
format!("+{}:{}", target_ref(id), upstream_ref(branch))
}
#[must_use]
pub fn out_push_refspec(id: &str) -> String {
format!("+refs/heads/*:{}", out_ref(id, "*"))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckinBranch {
pub name: String,
pub raw: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckinRequest {
pub branches: Vec<CheckinBranch>,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckinStatus {
Created,
Updated,
Unchanged,
Refused,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckinResult {
pub name: String,
pub status: CheckinStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grafted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous: Option<String>,
#[serde(default)]
pub commits: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule: Option<CheckinRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<CheckinRefusal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckinResponse {
pub results: Vec<CheckinResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ident {
pub name: String,
pub email: String,
}
impl Ident {
pub fn author<E: crate::ident_resolve::IdentityEnv>(
env: &E,
config: &crate::config::ConfigSet,
) -> std::result::Result<Self, crate::ident_resolve::IdentityError> {
use crate::ident_resolve::{resolve_email_with, resolve_name_with, IdentRole};
Ok(Self {
name: resolve_name_with(env, config, IdentRole::Author)?,
email: resolve_email_with(env, config, IdentRole::Author)?,
})
}
pub fn signature(
&self,
time: time::OffsetDateTime,
) -> std::result::Result<crate::ident::Signature, crate::ident::SignatureError> {
let signature = crate::ident::Signature::cleaned(&self.name, &self.email, time)?;
if signature.name.is_empty() {
return Ok(crate::ident::Signature {
name: signature.email.clone(),
..signature
});
}
Ok(signature)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrateRequest {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Ident>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrateStatus {
Merged,
AlreadyIntegrated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrateResponse {
pub status: IntegrateStatus,
pub name: String,
pub target: String,
pub previous: String,
pub head: String,
pub commits: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slice {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlicesResponse {
pub target: String,
pub head: String,
pub slices: Vec<Slice>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SliceRequest {
pub paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub create: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Ident>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SliceDeleteRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Ident>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SliceChange {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice: Option<Slice>,
pub target: String,
pub previous: String,
pub head: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutErrorBody {
pub error: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CheckoutError {
#[error("{url} offers no grit checkout (no {endpoint} endpoint); it may be a plain Git server, or the repository may not exist")]
NotOffered {
url: String,
endpoint: String,
},
#[error("{url}: {message} (HTTP {status})")]
Server {
url: String,
status: u16,
message: String,
},
#[error("{0} is not an http(s) URL of a repository")]
InvalidUrl(String),
#[error("{path}: {reason}")]
InvalidPath {
path: String,
reason: &'static str,
},
#[error("{path} is not in the repository at {base}")]
MissingPath {
path: String,
base: String,
},
#[error("the downloaded snapshot is {got}, but the server described {expected}")]
Mismatch {
expected: String,
got: String,
},
#[error("{0} already exists and is not empty")]
DestinationNotEmpty(PathBuf),
#[error("{0} is not a valid branch name")]
InvalidBranchName(String),
#[error("the record of checkout {id} at {path} is damaged: {detail}")]
DamagedRecord {
id: String,
path: PathBuf,
detail: String,
},
#[error("{refname} kept moving while it was being set")]
RefKeptMoving {
refname: String,
},
#[error("the work branches the server reserved are taken here (the last was {name})")]
WorkBranchTaken {
name: String,
},
#[error("the server's snapshot moved again while fetching: expected {expected}, got {got}")]
SnapshotMoved {
expected: String,
got: String,
},
#[error("the server's {name} moved again while fetching: expected {expected}, got {got}")]
BranchMoved {
name: String,
expected: String,
got: String,
},
#[error("{name} has nothing in common with the server's {name}")]
UnrelatedBranch {
name: String,
},
#[error("{failed}; HEAD is still on {branch}, which it was switched to for the rebase")]
StillSwitched {
branch: String,
failed: Box<CheckoutError>,
},
#[error("a {} is in progress", .0.description())]
OperationInProgress(crate::state::InProgressOperation),
#[error(transparent)]
Remote(Box<crate::remote::RemoteError>),
#[error(transparent)]
Pull(Box<crate::porcelain::pull::PullError>),
#[error("unexpected answer from {url}: {detail}")]
Protocol {
url: String,
detail: String,
},
#[error(transparent)]
Download(Box<crate::resumable::ResumableError>),
#[error(transparent)]
Library(Box<crate::error::Error>),
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl From<crate::error::Error> for CheckoutError {
fn from(e: crate::error::Error) -> Self {
Self::Library(Box::new(e))
}
}
impl From<crate::resumable::ResumableError> for CheckoutError {
fn from(e: crate::resumable::ResumableError) -> Self {
Self::Download(Box::new(e))
}
}
impl From<crate::remote::RemoteError> for CheckoutError {
fn from(e: crate::remote::RemoteError) -> Self {
Self::Remote(Box::new(e))
}
}
impl From<crate::porcelain::pull::PullError> for CheckoutError {
fn from(e: crate::porcelain::pull::PullError) -> Self {
Self::Pull(Box::new(e))
}
}
pub type Result<T> = std::result::Result<T, CheckoutError>;
pub fn normalize_paths(paths: &[String]) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for p in paths {
let trimmed = p.trim().trim_matches('/');
if trimmed.is_empty() {
continue;
}
if p.trim().starts_with('/') {
return Err(CheckoutError::InvalidPath {
path: p.clone(),
reason: "paths are relative to the repository root",
});
}
if trimmed
.split('/')
.any(|s| s.is_empty() || s == "." || s == "..")
{
return Err(CheckoutError::InvalidPath {
path: p.clone(),
reason: "not a plain path inside the repository",
});
}
out.push(trimmed.to_owned());
}
out.sort();
out.dedup();
let all = out.clone();
out.retain(|p| {
!all.iter().any(|q| {
q != p && p.starts_with(q.as_str()) && p.as_bytes().get(q.len()) == Some(&b'/')
})
});
Ok(out)
}
fn lookup(odb: &Odb, tree: ObjectId, path: &[u8]) -> crate::error::Result<Option<(u32, ObjectId)>> {
let mut current = (TREE_MODE, tree);
for component in path.split(|b| *b == b'/') {
if current.0 != TREE_MODE {
return Ok(None);
}
let entries = parse_tree(&odb.read(¤t.1)?.data)?;
match entries.into_iter().find(|e| e.name == component) {
Some(e) => current = (e.mode, e.oid),
None => return Ok(None),
}
}
Ok(Some(current))
}
#[derive(Default)]
struct Dir {
entries: BTreeMap<Vec<u8>, Node>,
}
enum Node {
Entry(u32, ObjectId),
Dir(Dir),
}
impl Dir {
fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
match path.iter().position(|b| *b == b'/') {
None => {
self.entries.insert(path.to_vec(), Node::Entry(mode, oid));
}
Some(i) => {
let node = self
.entries
.entry(path[..i].to_vec())
.or_insert_with(|| Node::Dir(Dir::default()));
if let Node::Dir(dir) = node {
dir.insert(&path[i + 1..], mode, oid);
}
}
}
}
fn write(self, odb: &Odb) -> crate::error::Result<ObjectId> {
let mut entries = Vec::with_capacity(self.entries.len());
for (name, node) in self.entries {
let (mode, oid) = match node {
Node::Entry(mode, oid) => (mode, oid),
Node::Dir(dir) => (TREE_MODE, dir.write(odb)?),
};
entries.push(TreeEntry { mode, name, oid });
}
entries.sort_by(|a, b| {
tree_entry_cmp(&a.name, a.mode == TREE_MODE, &b.name, b.mode == TREE_MODE)
});
odb.write(ObjectKind::Tree, &serialize_tree(&entries))
}
}
pub fn filtered_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
cut_tree(odb, tree, paths, true)
}
pub(crate) fn view_tree(odb: &Odb, tree: ObjectId, paths: &[String]) -> Result<ObjectId> {
cut_tree(odb, tree, paths, false)
}
pub(crate) fn in_view(path: &[u8], paths: &[String]) -> bool {
if paths.is_empty() {
return true;
}
let parent = path
.iter()
.rposition(|b| *b == b'/')
.map_or(&b""[..], |i| &path[..i]);
let under = |dir: &[u8], p: &[u8]| p.starts_with(dir) && p.get(dir.len()) == Some(&b'/');
parent.is_empty()
|| paths
.iter()
.map(String::as_bytes)
.any(|p| path == p || under(p, path) || under(parent, p))
}
fn cut_tree(odb: &Odb, tree: ObjectId, paths: &[String], strict: bool) -> Result<ObjectId> {
if paths.is_empty() {
return Ok(tree);
}
let mut dirs: Vec<&[u8]> = vec![b""];
for p in paths {
let p = p.as_bytes();
dirs.extend(
p.iter()
.enumerate()
.filter(|(_, b)| **b == b'/')
.map(|(i, _)| &p[..i]),
);
}
dirs.sort();
dirs.dedup();
let mut root = Dir::default();
for dir in dirs {
let oid = if dir.is_empty() {
tree
} else {
match lookup(odb, tree, dir)? {
Some((TREE_MODE, oid)) => oid,
_ => continue,
}
};
for entry in parse_tree(&odb.read(&oid)?.data)? {
if entry.mode == TREE_MODE {
continue;
}
let path = if dir.is_empty() {
entry.name.clone()
} else {
[dir, b"/", &entry.name].concat()
};
root.insert(&path, entry.mode, entry.oid);
}
}
for p in paths {
match lookup(odb, tree, p.as_bytes())? {
Some((mode, oid)) => root.insert(p.as_bytes(), mode, oid),
None if strict => {
return Err(CheckoutError::MissingPath {
path: p.clone(),
base: tree.to_hex(),
})
}
None => {}
}
}
Ok(root.write(odb)?)
}
#[must_use]
pub fn snapshot_object(
tree: ObjectId,
parent: Option<ObjectId>,
base: ObjectId,
base_time: i64,
base_message: &str,
) -> Vec<u8> {
let base = base.to_hex();
let who = format!("{SNAPSHOT_IDENT} {base_time} +0000");
let mut s = format!("tree {}\n", tree.to_hex());
if let Some(p) = parent {
s.push_str(&format!("parent {}\n", p.to_hex()));
}
let subject = base_message.lines().next().unwrap_or_default().trim();
let quoted: String = base_message
.trim_end()
.lines()
.map(|l| format!(" {l}").trim_end().to_owned() + "\n")
.collect();
s.push_str(&format!(
"author {who}\ncommitter {who}\n\nSnapshot of {}: {subject}\n\nStands for {base}:\n\n{quoted}\n{SNAPSHOT_BASE_TRAILER}: {base}\n",
&base[..base.len().min(12)]
));
s.into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
pub commit: ObjectId,
pub tree: ObjectId,
pub base: ObjectId,
pub object: Vec<u8>,
pub files: u64,
pub bytes: u64,
}
pub fn make_snapshot(
repo: &Repository,
base: ObjectId,
paths: &[String],
parent: Option<ObjectId>,
) -> Result<Snapshot> {
let odb = repo.odb();
let commit = parse_commit(&odb.read(&base)?.data)?;
let tree = filtered_tree(odb, commit.tree, paths).map_err(|e| match e {
CheckoutError::MissingPath { path, .. } => CheckoutError::MissingPath {
path,
base: base.to_hex(),
},
other => other,
})?;
let time = crate::ident::parse_signature_times(&commit.committer)
.map(|t| t.unix_seconds)
.unwrap_or(0);
let object = snapshot_object(tree, parent, base, time, &commit.message);
let id = odb.write(ObjectKind::Commit, &object)?;
let (files, bytes) = tree_stats(odb, tree)?;
Ok(Snapshot {
commit: id,
tree,
base,
object,
files,
bytes,
})
}
fn tree_stats(odb: &Odb, tree: ObjectId) -> crate::error::Result<(u64, u64)> {
let (mut files, mut bytes) = (0u64, 0u64);
let mut stack = vec![tree];
while let Some(oid) = stack.pop() {
for entry in parse_tree(&odb.read(&oid)?.data)? {
match entry.mode {
TREE_MODE => stack.push(entry.oid),
GITLINK_MODE => {}
_ => {
files += 1;
bytes += odb.read_info(&entry.oid)?.size;
}
}
}
}
Ok((files, bytes))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutUrl {
pub repo_url: String,
pub name: String,
pub api: String,
}
impl CheckoutUrl {
pub fn parse(url: &str) -> Result<Self> {
let invalid = || CheckoutError::InvalidUrl(url.to_owned());
let parsed = url::Url::parse(url).map_err(|_| invalid())?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err(invalid());
}
let path = parsed.path().trim_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
if path.is_empty() {
return Err(invalid());
}
let name = path.rsplit('/').next().unwrap_or(path).to_owned();
let mut server = parsed.clone();
server.set_path("");
server.set_query(None);
server.set_fragment(None);
let server = server.as_str().trim_end_matches('/').to_owned();
Ok(Self {
repo_url: format!("{server}/{path}.git"),
name,
api: format!("{server}/{CHECKOUT_API}/{path}"),
})
}
}
pub struct CheckoutClient<'a> {
http: &'a dyn HttpClient,
url: CheckoutUrl,
given: String,
}
impl<'a> CheckoutClient<'a> {
pub fn new(http: &'a dyn HttpClient, url: &str) -> Result<Self> {
Ok(Self {
http,
url: CheckoutUrl::parse(url)?,
given: url.to_owned(),
})
}
#[must_use]
pub fn url(&self) -> &CheckoutUrl {
&self.url
}
fn json<T: serde::de::DeserializeOwned>(
&self,
method: HttpMethod,
url: &str,
body: Option<&[u8]>,
) -> Result<T> {
let mut request = HttpRequest::new(method, url).header("Accept", "application/json");
if let Some(body) = body {
request = request
.header("Content-Type", "application/json")
.body(body);
}
let response = self.http.send(&request)?;
let status = response.status;
let bytes = response.into_bytes()?;
if status == 404 && url == self.url.api {
return Err(CheckoutError::NotOffered {
url: self.given.clone(),
endpoint: self.url.api.clone(),
});
}
if !(200..300).contains(&status) {
let message = serde_json::from_slice::<CheckoutErrorBody>(&bytes)
.map(|b| b.error)
.unwrap_or_else(|_| String::from_utf8_lossy(&bytes).trim().to_owned());
return Err(CheckoutError::Server {
url: url.to_owned(),
status,
message,
});
}
serde_json::from_slice(&bytes).map_err(|e| CheckoutError::Protocol {
url: url.to_owned(),
detail: e.to_string(),
})
}
pub fn server_info(&self) -> Result<CheckoutServerInfo> {
self.json(HttpMethod::Get, &self.url.api, None)
}
pub fn create(&self, request: &CheckoutRequest) -> Result<CheckoutInfo> {
let body = serde_json::to_vec(request).map_err(|e| CheckoutError::Protocol {
url: self.url.api.clone(),
detail: e.to_string(),
})?;
self.json(HttpMethod::Post, &self.url.api, Some(&body))
}
#[must_use]
pub fn bundle_url(&self, id: &str) -> String {
format!("{}/{id}/bundle", self.url.api)
}
pub fn show(&self, id: &str) -> Result<CheckoutState> {
self.json(HttpMethod::Get, &format!("{}/{id}", self.url.api), None)
}
fn post_json<T: Serialize, R: serde::de::DeserializeOwned>(
&self,
method: HttpMethod,
url: &str,
body: &T,
) -> Result<R> {
let body = serde_json::to_vec(body).map_err(|e| CheckoutError::Protocol {
url: url.to_owned(),
detail: e.to_string(),
})?;
self.json(method, url, Some(&body))
}
pub fn checkin(&self, id: &str, request: &CheckinRequest) -> Result<CheckinResponse> {
self.post_json(
HttpMethod::Post,
&format!("{}/{id}/checkin", self.url.api),
request,
)
}
pub fn update(&self, id: &str) -> Result<CheckoutInfo> {
self.post_json(
HttpMethod::Post,
&format!("{}/{id}/update", self.url.api),
&(),
)
}
pub fn reserve_work_branch(&self, id: &str, wanted: &str) -> Result<String> {
let reserved: WorkBranchResponse = self.post_json(
HttpMethod::Post,
&format!("{}/{id}/work", self.url.api),
&WorkBranchRequest {
work: wanted.to_owned(),
},
)?;
Ok(reserved.work)
}
pub fn branch(&self, id: &str, name: &str) -> Result<BranchInfo> {
self.post_json(
HttpMethod::Post,
&format!("{}/{id}/branch", self.url.api),
&BranchRequest {
name: name.to_owned(),
},
)
}
pub fn integrate(&self, request: &IntegrateRequest) -> Result<IntegrateResponse> {
self.post_json(
HttpMethod::Post,
&format!("{}/integrate", self.url.api),
request,
)
}
pub fn slices(&self) -> Result<SlicesResponse> {
self.server_info()?;
self.json(HttpMethod::Get, &format!("{}/slices", self.url.api), None)
}
pub fn put_slice(&self, name: &str, request: &SliceRequest) -> Result<SliceChange> {
self.post_json(
HttpMethod::Put,
&format!("{}/slices/{name}", self.url.api),
request,
)
}
pub fn delete_slice(&self, name: &str, request: &SliceDeleteRequest) -> Result<SliceChange> {
self.post_json(
HttpMethod::Delete,
&format!("{}/slices/{name}", self.url.api),
request,
)
}
}
pub fn branches_with_work(
repo: &Repository,
marker: &CheckoutMarker,
) -> Result<Vec<(String, ObjectId)>> {
let roots: Vec<ObjectId> = marker
.roots
.iter()
.filter_map(|r| ObjectId::from_hex(&r.commit).ok())
.collect();
let mut out = Vec::new();
for (refname, tip) in crate::refs::list_refs(repo.git_dir(), "refs/heads/")? {
if roots.contains(&tip) {
continue;
}
let mut on_snapshot = false;
for root in &roots {
if crate::merge_base::is_ancestor(repo, *root, tip)? {
on_snapshot = true;
break;
}
}
if on_snapshot {
let name = refname
.strip_prefix("refs/heads/")
.unwrap_or(&refname)
.to_owned();
out.push((name, tip));
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckoutMarker {
pub url: String,
pub id: String,
pub branch: String,
pub paths: Vec<String>,
pub roots: Vec<SnapshotRoot>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub landed: Vec<String>,
}
fn marker_path(git_dir: &Path) -> PathBuf {
crate::refs::common_dir(git_dir)
.unwrap_or_else(|| git_dir.to_path_buf())
.join(MARKER_FILE)
}
impl CheckoutMarker {
#[must_use]
pub fn read(git_dir: &Path) -> Option<Self> {
serde_json::from_slice(&std::fs::read(marker_path(git_dir)).ok()?).ok()
}
pub fn write(&self, git_dir: &Path) -> Result<()> {
let bytes =
serde_json::to_vec_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
let path = marker_path(git_dir);
let tmp = path.with_file_name(format!("{MARKER_FILE}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[must_use]
pub fn last(&self) -> Option<&SnapshotRoot> {
self.roots.last()
}
#[must_use]
pub fn upstream_ref(&self) -> String {
upstream_ref(&self.branch)
}
}
#[derive(Debug)]
pub struct Materialized {
pub repo: Repository,
pub work: String,
pub files: u64,
}
#[allow(clippy::too_many_arguments)]
pub fn materialize(
dest: &Path,
info: &CheckoutInfo,
bundle: &Path,
repo_url: &str,
work: &str,
progress: &mut dyn Progress,
cancel: &dyn Cancel,
) -> Result<Materialized> {
if dest.is_dir() && std::fs::read_dir(dest)?.next().is_some() {
return Err(CheckoutError::DestinationNotEmpty(dest.to_path_buf()));
}
std::fs::create_dir_all(dest)?;
let repo = Repository::init(
dest,
&InitOptions {
initial_branch: Some(work.to_owned()),
..InitOptions::default()
},
)?;
let opened = repo
.bundles()
.open(bundle)
.map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
let unbundled = opened
.unbundle_with_progress(&repo, progress, cancel)
.map_err(|e| crate::error::Error::Message(format!("bundle: {e}")))?;
let mismatch = |got: String| CheckoutError::Mismatch {
expected: info.root.clone(),
got,
};
let root = ObjectId::from_hex(&info.root).map_err(|_| mismatch(info.root.clone()))?;
let tip = unbundled
.refs
.first()
.map(|r| r.oid)
.ok_or_else(|| mismatch("an empty bundle".to_owned()))?;
if tip != root {
return Err(mismatch(tip.to_hex()));
}
let object = repo.odb().read(&root)?;
if object.data != info.root_object.as_bytes() {
return Err(mismatch(format!(
"{} with different contents",
root.to_hex()
)));
}
let git_dir = repo.git_dir().to_path_buf();
let upstream = upstream_ref(&info.branch);
crate::refs::write_ref(&git_dir, &upstream, &root)?;
crate::refs::write_symbolic_ref(&git_dir, "refs/remotes/origin/HEAD", &upstream)?;
crate::refs::write_ref(&git_dir, &format!("refs/heads/{work}"), &root)?;
crate::refs::write_symbolic_ref(&git_dir, "HEAD", &format!("refs/heads/{work}"))?;
let config_path = git_dir.join("config");
let content = std::fs::read_to_string(&config_path).unwrap_or_default();
let mut config = crate::config::ConfigFile::parse(
&config_path,
&content,
crate::config::ConfigScope::Local,
)?;
config.set("remote.origin.url", repo_url)?;
config.set(
"remote.origin.fetch",
&snapshot_fetch_refspec(&info.id, &info.branch),
)?;
config.set("remote.origin.push", &out_push_refspec(&info.id))?;
config.set("remote.origin.tagOpt", "--no-tags")?;
config.write()?;
CheckoutMarker {
url: repo_url.to_owned(),
id: info.id.clone(),
branch: info.branch.clone(),
paths: info.paths.clone(),
roots: info.roots.clone(),
landed: Vec::new(),
}
.write(&git_dir)?;
let tree = ObjectId::from_hex(&info.tree).map_err(|_| mismatch(info.tree.clone()))?;
crate::porcelain::checkout::checkout_between_trees_with_progress(
&repo, None, &tree, progress, cancel,
)?;
let files = u64::try_from(repo.index_handle().load()?.entries().len()).unwrap_or(u64::MAX);
Ok(Materialized {
repo,
work: work.to_owned(),
files,
})
}
const WORDS: &[&str] = &[
"amber", "brook", "cedar", "dune", "ember", "fern", "glade", "harbor", "iris", "juniper",
"kestrel", "lagoon", "maple", "nectar", "orchid", "pine", "quartz", "river", "sage", "thistle",
"umber", "violet", "willow", "yarrow", "zephyr", "aspen", "birch", "coral", "delta", "elm",
"flint", "grove", "hazel", "inlet", "jasper", "lark", "moss", "north", "oak", "pebble",
"quill", "reed", "slate", "tide", "vale", "wren",
];
#[must_use]
pub fn suggest_work_branch(email: Option<&str>, random: [u8; 2]) -> String {
let who: String = email
.and_then(|e| e.split('@').next())
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let who = who.trim_matches('-');
let a = WORDS[usize::from(random[0]) % WORDS.len()];
let b = WORDS[usize::from(random[1]) % WORDS.len()];
if who.is_empty() {
format!("checkout/{a}-{b}")
} else {
format!("{who}/{a}-{b}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idents_resolve_like_commit_authors_and_sign_cleanly() {
let env_of = |vars: &[(&str, &str)]| {
crate::config::environment::Environment::from_vars(
vars.iter().map(|(k, v)| ((*k).into(), (*v).into())),
std::path::PathBuf::from("/"),
)
};
let env = env_of(&[
("GIT_AUTHOR_NAME", "A U Thor"),
("GIT_AUTHOR_EMAIL", "author@example.com"),
]);
let mut config = crate::config::ConfigSet::new();
let _ = config.add_command_override("user.name", "Alice");
let _ = config.add_command_override("user.email", "alice@example.com");
assert_eq!(
Ident::author(&env, &config).ok(),
Some(Ident {
name: "A U Thor".to_owned(),
email: "author@example.com".to_owned()
}),
"the environment wins over config, as for a commit"
);
let bare = env_of(&[]);
assert_eq!(
Ident::author(&bare, &config).ok(),
Some(Ident {
name: "Alice".to_owned(),
email: "alice@example.com".to_owned()
})
);
assert!(Ident::author(&bare, &crate::config::ConfigSet::new()).is_err());
let time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
let sneaky = Ident {
name: "Eve\nencoding bogus".to_owned(),
email: "e>v<e@x".to_owned(),
};
assert_eq!(
sneaky
.signature(time)
.map(|s| s.header_line())
.ok()
.as_deref(),
Some("Eveencoding bogus <eve@x> 1700000000 +0000")
);
let nameless = Ident {
name: "<>".to_owned(),
email: "bob@example.com".to_owned(),
};
assert_eq!(
nameless.signature(time).map(|s| s.name).ok().as_deref(),
Some("bob@example.com")
);
}
#[test]
fn paths_are_normalized() {
let paths: Vec<String> = ["b/", "a/x", "a", " c ", "", "a/y"]
.iter()
.map(|s| (*s).to_owned())
.collect();
assert_eq!(
normalize_paths(&paths).ok(),
Some(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()])
);
assert!(normalize_paths(&["/abs".to_owned()]).is_err());
assert!(normalize_paths(&["a/../b".to_owned()]).is_err());
assert!(normalize_paths(&["a//b".to_owned()]).is_err());
}
#[test]
fn views_are_the_paths_and_the_files_above_them() {
let paths = vec!["src/deep".to_owned(), "docs/guide.md".to_owned()];
for (path, seen) in [
("README", true),
("src/a.rs", true),
("src/deep/b.rs", true),
("src/deep/more/c.rs", true),
("src/other/d.rs", false),
("docs/guide.md", true),
("docs/other.md", true),
("docs/api/x.md", false),
("srcx/a.rs", false),
("src/deeper/a.rs", false),
] {
assert_eq!(in_view(path.as_bytes(), &paths), seen, "{path}");
}
assert!(in_view(b"anything/at/all", &[]));
}
#[test]
fn urls_split_into_repository_and_endpoint() {
let u = CheckoutUrl::parse("http://h:8080/demo.git").ok();
assert_eq!(
u,
Some(CheckoutUrl {
repo_url: "http://h:8080/demo.git".to_owned(),
name: "demo".to_owned(),
api: "http://h:8080/.grit/checkouts/demo".to_owned(),
})
);
let nested = CheckoutUrl::parse("https://h/org/app/").ok();
assert_eq!(nested.as_ref().map(|u| u.name.as_str()), Some("app"));
assert_eq!(
nested.map(|u| u.api),
Some("https://h/.grit/checkouts/org/app".to_owned())
);
assert!(CheckoutUrl::parse("http://h/").is_err());
assert!(CheckoutUrl::parse("ssh://h/demo").is_err());
}
#[test]
fn snapshot_objects_are_deterministic() {
let tree = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").ok();
let base = ObjectId::from_hex("1111111111111111111111111111111111111111").ok();
let (Some(tree), Some(base)) = (tree, base) else {
panic!("oids");
};
let a = snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n");
assert_eq!(
a,
snapshot_object(tree, None, base, 1_700_000_000, "Fix it\n\nBody\n")
);
let text = String::from_utf8_lossy(&a);
assert!(text.starts_with("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor grit-checkout <snapshot@grit-checkout> 1700000000 +0000\n"), "{text}");
assert!(
text.contains("\n\nSnapshot of 111111111111: Fix it\n"),
"{text}"
);
assert!(
text.ends_with("Grit-Checkout-Base: 1111111111111111111111111111111111111111\n"),
"{text}"
);
}
#[test]
fn work_branches_are_named_for_the_committer() {
assert_eq!(
suggest_work_branch(Some("Alice.B@example.com"), [0, 1]),
"alice-b/amber-brook"
);
assert_eq!(suggest_work_branch(None, [2, 3]), "checkout/cedar-dune");
}
}