use std::path::{Path, PathBuf};
use std::time::Duration;
use super::fetch::{add_worktree, ensure_worktree_of_repo};
use super::git::{failure_message, split_nul, Git};
use super::location::{LedgerLocation, Repo};
use super::push::{commit_changes, parse_push_status};
use super::refs::{is_server_refusal, refused_push, LedgerRefs};
pub const ENV_VAR: &str = "TSK_LEDGER_TRANSPORT";
pub const MARKER_FILE: &str = ".tsk-transport";
pub const GITIGNORE_FILE: &str = ".gitignore";
pub const GITIGNORE_TEXT: &str = "LOCK\noldgen/LOCK\n";
pub const REMOTE_DIR_NAME: &str = "ledger-remote";
pub const REF_FILE: &str = "ledger-ref";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
GitRemote,
FileRemote,
}
impl Transport {
pub fn as_str(self) -> &'static str {
match self {
Transport::GitRemote => "git-remote",
Transport::FileRemote => "file-remote",
}
}
pub fn parse(text: &str) -> Option<Transport> {
match text.trim() {
"git-remote" => Some(Transport::GitRemote),
"file-remote" => Some(Transport::FileRemote),
_ => None,
}
}
pub fn of_url(url: &str) -> Transport {
if url.starts_with("file://") {
Transport::FileRemote
} else {
Transport::GitRemote
}
}
}
pub fn forced() -> Result<Option<Transport>, String> {
forced_from(std::env::var(ENV_VAR).ok().as_deref())
}
fn forced_from(value: Option<&str>) -> Result<Option<Transport>, String> {
match value.map(str::trim).filter(|v| !v.is_empty()) {
None => Ok(None),
Some(text) => Transport::parse(text).map(Some).ok_or_else(|| {
format!(
"error: {}=\"{}\" is not a ledger transport; use git-remote or file-remote",
ENV_VAR, text
)
}),
}
}
pub fn for_new_ledger() -> Result<Transport, String> {
Ok(forced()?.unwrap_or(Transport::FileRemote))
}
pub fn of_commit(git: &Git, commit: &str) -> Result<Transport, String> {
let listing = git.run(["ls-tree", "--name-only", "-z", commit])?;
Ok(if split_nul(&listing).contains(&MARKER_FILE) {
Transport::FileRemote
} else {
Transport::GitRemote
})
}
pub fn file_url(dir: &Path) -> String {
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
format!("file://{}", dir.to_string_lossy())
}
pub fn initial_commit(git: &Git) -> Result<String, String> {
let marker = git.run_stdin(
["hash-object", "-w", "--stdin"],
&format!("{}\n", Transport::FileRemote.as_str()),
)?;
let ignore = git.run_stdin(["hash-object", "-w", "--stdin"], GITIGNORE_TEXT)?;
let listing = format!(
"100644 blob {}\t{}\n100644 blob {}\t{}\n",
ignore, GITIGNORE_FILE, marker, MARKER_FILE
);
let tree = git.run_stdin(["mktree"], &listing)?;
git.run_line([
"commit-tree",
tree.as_str(),
"-m",
"Create the ledger file remote",
])
}
pub fn sync_checkout(repo: &Repo, path: &Path, commit: &str) -> Result<(), String> {
if path.exists() {
let wt = Git::new(path);
ensure_worktree_of_repo(&wt, &repo.common_dir)?;
wt.run(["reset", "--hard", "--quiet", commit])?;
wt.run(["clean", "-fdq"])?;
Ok(())
} else {
add_worktree(repo, path, commit)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Published {
Unchanged(String),
Pushed(String),
Rejected(String),
}
pub fn publish(
location: &LedgerLocation,
path: &Path,
message: &str,
known_tip: Option<&str>,
) -> Result<Published, String> {
let wt = Git::new(path);
let committed = commit_changes(&wt, message)?;
let head = wt.run_line(["rev-parse", "--verify", "HEAD^{commit}"])?;
if !committed && known_tip == Some(head.as_str()) {
return Ok(Published::Unchanged(head));
}
let refspec = format!("HEAD:{}", location.ref_name());
let (args, output) = wt.output(["push", "--porcelain", location.remote(), refspec.as_str()])?;
let stdout = String::from_utf8_lossy(&output.stdout);
let custom = location.refs() == LedgerRefs::Custom;
match parse_push_status(&stdout, location.ref_name()) {
Some(status) if status.rejected() => {
if custom && is_server_refusal(&status.summary) {
return Err(refused_push(location.ref_name(), &status.summary));
}
Ok(Published::Rejected(status.summary))
}
Some(_) if output.status.success() => Ok(Published::Pushed(head)),
_ => {
let failure = failure_message(&args, &output);
if custom && is_server_refusal(&failure) {
return Err(refused_push(location.ref_name(), &failure));
}
let lower = failure.to_lowercase();
if ["non-fast-forward", "fetch first", "rejected"]
.iter()
.any(|word| lower.contains(word))
{
return Ok(Published::Rejected(
"[rejected] (non-fast-forward)".to_string(),
));
}
Err(failure)
}
}
}
pub fn retry_wait() -> Duration {
Duration::from_millis(50 + random_below(201))
}
fn random_below(bound: u64) -> u64 {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u32(std::process::id());
hasher.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
);
hasher.finish() % bound
}
pub fn read_ref_file(clone_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(clone_dir.join(REF_FILE)).ok()?;
let name = text.trim();
(!name.is_empty()).then(|| name.to_string())
}
pub fn write_ref_file(clone_dir: &Path, ref_name: &str) -> Result<(), String> {
let file = clone_dir.join(REF_FILE);
std::fs::write(&file, format!("{}\n", ref_name))
.map_err(|e| format!("error: could not write {}: {}", file.display(), e))
}
pub fn remote_dir_beside(db_path: &Path) -> PathBuf {
db_path
.parent()
.map(|dir| dir.join(REMOTE_DIR_NAME))
.unwrap_or_else(|| PathBuf::from(REMOTE_DIR_NAME))
}
pub fn store_url(db_path: &Path) -> Option<String> {
let state = std::fs::read_to_string(db_path.join(".dolt").join("repo_state.json")).ok()?;
let state: serde_json::Value = serde_json::from_str(&state).ok()?;
state
.get("remotes")?
.get(super::dolt::REMOTE)?
.get("url")?
.as_str()
.map(str::to_string)
}
pub fn point_store_at_file_remote(
db: &super::dolt::Dolt,
dir: &Path,
ref_name: &str,
) -> Result<(), String> {
db.run(["remote", "remove", super::dolt::REMOTE])?;
db.run(["remote", "add", super::dolt::REMOTE, file_url(dir).as_str()])?;
if let Some(clone_dir) = db.dir().parent() {
write_ref_file(clone_dir, ref_name)?;
}
let _ = std::fs::remove_dir_all(db.dir().join(".dolt").join("git-remote-cache"));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transports_parse_print_and_come_from_urls() {
assert_eq!(Transport::parse("git-remote"), Some(Transport::GitRemote));
assert_eq!(
Transport::parse(" file-remote\n"),
Some(Transport::FileRemote)
);
assert_eq!(Transport::parse("git"), None);
assert_eq!(Transport::FileRemote.as_str(), "file-remote");
assert_eq!(
Transport::of_url("file:///s/tsk/repos/x/ledger-remote"),
Transport::FileRemote
);
assert_eq!(Transport::of_url("git+file:///o.git"), Transport::GitRemote);
assert_eq!(
Transport::of_url("git+https://example.test/o"),
Transport::GitRemote
);
}
#[test]
fn the_environment_forces_a_transport_or_is_refused() {
assert_eq!(forced_from(None).unwrap(), None);
assert_eq!(forced_from(Some(" ")).unwrap(), None);
assert_eq!(
forced_from(Some("git-remote")).unwrap(),
Some(Transport::GitRemote)
);
let err = forced_from(Some("ftp")).unwrap_err();
assert!(err.contains("TSK_LEDGER_TRANSPORT"), "{}", err);
}
#[test]
fn the_retry_wait_is_between_50_and_250_ms() {
for _ in 0..50 {
let wait = retry_wait();
assert!(wait >= Duration::from_millis(50), "{:?}", wait);
assert!(wait <= Duration::from_millis(250), "{:?}", wait);
}
}
#[test]
fn the_ref_file_round_trips() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_ref_file(dir.path()), None);
write_ref_file(dir.path(), "refs/tsk/ledger").unwrap();
assert_eq!(
read_ref_file(dir.path()).as_deref(),
Some("refs/tsk/ledger")
);
}
#[test]
fn the_remote_directory_sits_beside_the_database() {
assert_eq!(
remote_dir_beside(Path::new("/s/tsk/repos/x/ledger-db")),
PathBuf::from("/s/tsk/repos/x/ledger-remote")
);
}
}