use std::path::{Path, PathBuf};
use super::git::Git;
use super::location::LedgerLocation;
pub const TIPS_FILE: &str = "remote-tips";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteHeads {
pub ledger: Option<String>,
pub docs: Option<String>,
}
pub fn remote_heads(git: &Git, location: &LedgerLocation) -> Result<RemoteHeads, String> {
let docs = location.docs();
let (args, output) = git.output([
"ls-remote",
location.remote(),
location.ref_name(),
docs.ref_name(),
])?;
if !output.status.success() {
return Err(super::git::failure_message(&args, &output));
}
Ok(parse_heads(
&String::from_utf8_lossy(&output.stdout),
location.ref_name(),
docs.ref_name(),
))
}
pub const FETCHED_LEDGER_REF: &str = "refs/tsk-fetch/ledger";
pub const FETCHED_DOCS_REF: &str = "refs/tsk-fetch/docs";
const MISSING_REF_TEXT: &str = "couldn't find remote ref";
#[derive(Debug, PartialEq, Eq)]
pub enum FetchFailure {
RefMissing,
Failed(String),
}
pub fn fetch_heads(git: &Git, location: &LedgerLocation) -> Result<RemoteHeads, FetchFailure> {
let docs = location.docs();
let ledger_spec = format!("+{}:{}", location.ref_name(), FETCHED_LEDGER_REF);
let docs_spec = format!("+{}:{}", docs.ref_name(), FETCHED_DOCS_REF);
let (args, output) = git
.output([
"fetch",
"--quiet",
"--no-tags",
location.remote(),
ledger_spec.as_str(),
docs_spec.as_str(),
])
.map_err(FetchFailure::Failed)?;
if !output.status.success() {
let failure = super::git::failure_message(&args, &output);
return Err(if failure.contains(MISSING_REF_TEXT) {
FetchFailure::RefMissing
} else {
FetchFailure::Failed(failure)
});
}
let ledger_commit = format!("{}^{{commit}}", FETCHED_LEDGER_REF);
let docs_commit = format!("{}^{{commit}}", FETCHED_DOCS_REF);
let listing = git
.run(["rev-parse", ledger_commit.as_str(), docs_commit.as_str()])
.map_err(FetchFailure::Failed)?;
let mut commits = listing.lines().map(str::to_string);
match (commits.next(), commits.next()) {
(Some(ledger), Some(docs)) => Ok(RemoteHeads {
ledger: Some(ledger),
docs: Some(docs),
}),
_ => Err(FetchFailure::Failed(
"error: git rev-parse did not name the two fetched refs".to_string(),
)),
}
}
pub fn parse_heads(listing: &str, ledger_ref: &str, docs_ref: &str) -> RemoteHeads {
let mut heads = RemoteHeads {
ledger: None,
docs: None,
};
for line in listing.lines() {
let mut fields = line.split_whitespace();
let (Some(commit), Some(name)) = (fields.next(), fields.next()) else {
continue;
};
if name == ledger_ref {
heads.ledger = Some(commit.to_string());
} else if name == docs_ref {
heads.docs = Some(commit.to_string());
}
}
heads
}
pub struct Tips {
file: PathBuf,
}
impl Tips {
pub fn new(clone_dir: &Path) -> Tips {
Tips {
file: clone_dir.join(TIPS_FILE),
}
}
fn read(&self) -> Vec<(String, String, String)> {
let Ok(text) = std::fs::read_to_string(&self.file) else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let mut fields = line.splitn(3, '\t');
Some((
fields.next()?.to_string(),
fields.next()?.to_string(),
fields.next()?.to_string(),
))
})
.collect()
}
pub fn get(&self, remote: &str, ref_name: &str) -> Option<String> {
self.read()
.into_iter()
.find(|(r, n, _)| r == remote && n == ref_name)
.map(|(_, _, commit)| commit)
}
pub fn set(&self, remote: &str, ref_name: &str, commit: Option<&str>) {
let mut entries: Vec<(String, String, String)> = self
.read()
.into_iter()
.filter(|(r, n, _)| !(r == remote && n == ref_name))
.collect();
if let Some(commit) = commit {
entries.push((remote.to_string(), ref_name.to_string(), commit.to_string()));
}
let text: String = entries
.iter()
.map(|(r, n, c)| format!("{}\t{}\t{}\n", r, n, c))
.collect();
if let Some(parent) = self.file.parent() {
if std::fs::create_dir_all(parent).is_err() {
return;
}
}
let temp = self
.file
.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&temp, text).is_ok() && std::fs::rename(&temp, &self.file).is_err() {
let _ = std::fs::remove_file(&temp);
}
}
}
pub fn commit_is_local(git: &Git, commit: &str) -> bool {
let spec = format!("{}^{{commit}}", commit);
git.output(["cat-file", "-e", spec.as_str()])
.is_ok_and(|(_, output)| output.status.success())
}
#[cfg(test)]
mod tests {
use super::*;
const LEDGER: &str = "refs/heads/tsk/ledger";
const DOCS: &str = "refs/heads/tsk/docs";
#[test]
fn the_listing_gives_the_commit_of_each_exact_ref() {
let listing = format!(
"{}\t{}\n{}\t{}\n",
"a".repeat(40),
LEDGER,
"b".repeat(40),
DOCS
);
let heads = parse_heads(&listing, LEDGER, DOCS);
assert_eq!(heads.ledger.as_deref(), Some("a".repeat(40).as_str()));
assert_eq!(heads.docs.as_deref(), Some("b".repeat(40).as_str()));
}
#[test]
fn a_ref_that_only_ends_in_the_pattern_is_not_the_ref() {
let listing = format!("{}\trefs/heads/x/refs/heads/tsk/ledger\n", "a".repeat(40));
let heads = parse_heads(&listing, LEDGER, DOCS);
assert_eq!(heads.ledger, None);
assert_eq!(heads.docs, None);
assert_eq!(
parse_heads("", LEDGER, DOCS),
RemoteHeads {
ledger: None,
docs: None
}
);
}
fn run_git(dir: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?}: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn a_combined_fetch_names_both_commits_and_says_why_it_failed() {
let root = tempfile::tempdir().unwrap();
let origin = root.path().join("origin.git");
let work = root.path().join("work");
std::fs::create_dir_all(&work).unwrap();
run_git(root.path(), &["init", "-q", "--bare", "origin.git"]);
run_git(&work, &["init", "-q", "-b", "main"]);
run_git(
&work,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
run_git(&work, &["commit", "-q", "--allow-empty", "-m", "ledger"]);
let ledger = run_git(&work, &["rev-parse", "HEAD"]);
run_git(
&work,
&["push", "-q", "origin", "HEAD:refs/heads/tsk/ledger"],
);
run_git(&work, &["commit", "-q", "--allow-empty", "-m", "docs"]);
let docs = run_git(&work, &["rev-parse", "HEAD"]);
run_git(&work, &["push", "-q", "origin", "HEAD:refs/heads/tsk/docs"]);
let clone = root.path().join("clone");
std::fs::create_dir_all(&clone).unwrap();
run_git(&clone, &["init", "-q", "-b", "main"]);
run_git(
&clone,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
let git = Git::new(&clone);
let location = LedgerLocation::in_repo();
let heads = fetch_heads(&git, &location).unwrap();
assert_eq!(heads.ledger.as_deref(), Some(ledger.as_str()));
assert_eq!(heads.docs.as_deref(), Some(docs.as_str()));
assert!(commit_is_local(&git, &ledger));
assert!(commit_is_local(&git, &docs));
assert_eq!(run_git(&clone, &["rev-parse", FETCHED_DOCS_REF]), docs);
assert_eq!(run_git(&clone, &["tag", "--list"]), "");
assert_eq!(fetch_heads(&git, &location).unwrap(), heads);
run_git(&work, &["push", "-q", "origin", ":refs/heads/tsk/docs"]);
assert_eq!(
fetch_heads(&git, &location).unwrap_err(),
FetchFailure::RefMissing
);
run_git(
&clone,
&["remote", "set-url", "origin", "/no/such/origin.git"],
);
assert!(matches!(
fetch_heads(&git, &location),
Err(FetchFailure::Failed(_))
));
}
#[test]
fn a_tip_is_recorded_replaced_and_forgotten_per_remote_and_ref() {
let dir = tempfile::tempdir().unwrap();
let tips = Tips::new(&dir.path().join("repos").join("id"));
assert_eq!(tips.get("origin", LEDGER), None);
tips.set("origin", LEDGER, Some("1111"));
tips.set("origin", DOCS, Some("2222"));
tips.set("https://nexus", LEDGER, Some("3333"));
assert_eq!(tips.get("origin", LEDGER).as_deref(), Some("1111"));
assert_eq!(tips.get("origin", DOCS).as_deref(), Some("2222"));
tips.set("origin", LEDGER, Some("4444"));
assert_eq!(tips.get("origin", LEDGER).as_deref(), Some("4444"));
assert_eq!(tips.get("https://nexus", LEDGER).as_deref(), Some("3333"));
tips.set("origin", LEDGER, None);
assert_eq!(tips.get("origin", LEDGER), None);
assert_eq!(tips.get("origin", DOCS).as_deref(), Some("2222"));
}
}