use std::io::Write;
use anyhow::{bail, Result};
use tape_core::types::TrackNumber;
use crate::fetch::{load_installed, save_installed};
use crate::git::{self, Repository};
use crate::index::{Index, PackEntry};
use crate::store::Store;
const PUSH_ATTEMPTS: u64 = 5;
const DEFAULT_HEADS: [&str; 2] = ["refs/heads/main", "refs/heads/master"];
pub struct Spec {
pub is_forced: bool,
pub source: String,
pub destination: String,
pub malformed: Option<String>,
}
#[derive(Default)]
pub struct Decision {
pub results: Vec<String>,
pub updates: Vec<(String, String)>,
pub deletions: Vec<String>,
pub include: Vec<String>,
}
pub fn parse_specs(specs: &[String]) -> Vec<Spec> {
let mut parsed = Vec::with_capacity(specs.len());
for spec in specs {
let is_forced = spec.starts_with('+');
let body = spec.strip_prefix('+').unwrap_or(spec);
match body.split_once(':') {
Some((source, destination)) => parsed.push(Spec {
is_forced,
source: source.to_string(),
destination: destination.to_string(),
malformed: None,
}),
None => parsed.push(Spec {
is_forced: false,
source: String::new(),
destination: String::new(),
malformed: Some(body.to_string()),
}),
}
}
parsed
}
fn decide_spec(repository: &Repository, spec: &Spec, index: &Index, decision: &mut Decision) {
if let Some(bad) = &spec.malformed {
decision.results.push(format!("error {bad} malformed refspec"));
return;
}
let destination = &spec.destination;
if spec.source.is_empty() {
decision.deletions.push(destination.clone());
decision.results.push(format!("ok {destination}"));
return;
}
let Some(new_id) = repository.rev_parse(&spec.source) else {
decision.results.push(format!(
"error {destination} no such ref locally: {}",
spec.source
));
return;
};
if let Some(old_id) = index.refs.get(destination) {
if old_id == &new_id {
decision.results.push(format!("ok {destination}"));
return;
}
if repository.has_object(old_id) && repository.is_ancestor(&new_id, old_id) {
decision.results.push(format!("ok {destination}"));
return;
}
if !spec.is_forced {
if !repository.has_object(old_id) {
decision.results.push(format!(
"error {destination} remote is at {old_id}, which is not in \
this repository; fetch first"
));
return;
}
if !repository.is_ancestor(old_id, &new_id) {
decision
.results
.push(format!("error {destination} non-fast-forward"));
return;
}
}
}
decision.updates.push((destination.clone(), new_id.clone()));
decision.include.push(new_id);
decision.results.push(format!("ok {destination}"));
}
pub fn decide(repository: &Repository, specs: &[Spec], index: &Index) -> Decision {
let mut decision = Decision::default();
for spec in specs {
decide_spec(repository, spec, index, &mut decision);
}
decision
}
pub fn is_satisfied(index: &Index, decision: &Decision, pack: Option<&PackEntry>) -> bool {
if let Some(entry) = pack {
if !index.has_pack(entry.track) {
return false;
}
}
for (name, object_id) in &decision.updates {
if index.refs.get(name) != Some(object_id) {
return false;
}
}
for name in &decision.deletions {
if index.refs.contains_key(name) {
return false;
}
}
true
}
fn choose_head(repository: &Repository, index: &Index) -> Option<String> {
if let Ok(local) = repository.git(&["symbolic-ref", "HEAD"]) {
if index.refs.contains_key(&local) {
return Some(local);
}
}
for name in DEFAULT_HEADS {
if index.refs.contains_key(name) {
return Some(name.to_string());
}
}
for name in index.refs.keys() {
if name.starts_with("refs/heads/") {
return Some(name.clone());
}
}
None
}
pub fn apply(
repository: &Repository,
index: &mut Index,
decision: &Decision,
pack: Option<&PackEntry>,
) {
for name in &decision.deletions {
index.refs.remove(name);
}
for (name, object_id) in &decision.updates {
index.refs.insert(name.clone(), object_id.clone());
}
if let Some(entry) = pack {
if !index.has_pack(entry.track) {
index.packs.push(entry.clone());
}
}
let is_head_live = match index.head.as_deref() {
Some(head) => index.refs.contains_key(head),
None => false,
};
if !is_head_live {
index.head = choose_head(repository, index);
}
}
async fn store_pack(
store: &Store,
repository: &Repository,
base: &Index,
decision: &Decision,
) -> Result<Option<PackEntry>> {
if decision.include.is_empty() {
return Ok(None);
}
let mut exclude = Vec::new();
for object_id in base.tips() {
if repository.has_object(&object_id) {
exclude.push(object_id);
}
}
let pack = repository.pack_objects(&decision.include, &exclude)?;
let objects = git::pack_object_count(&pack);
if objects == 0 {
return Ok(None);
}
let plural = if objects == 1 { "" } else { "s" };
eprintln!(
"tape: writing pack of {objects} object{plural} ({} bytes)",
pack.len()
);
let entry = store.write_pack(&pack).await?;
eprintln!("tape: pack stored at track {}", entry.track);
let mut installed = load_installed(repository);
installed.insert(store.installed_key(entry.track));
save_installed(repository, &installed)?;
Ok(Some(entry))
}
async fn publish(
store: &Store,
repository: &Repository,
specs: &[Spec],
pack: Option<&PackEntry>,
) -> Result<Vec<String>> {
let mut ours: Vec<TrackNumber> = Vec::new();
let mut results = Vec::new();
for attempt in 1..=PUSH_ATTEMPTS {
let versions = store.index_versions().await?;
let head_version = versions.last().copied();
let mut base_version = None;
for version in versions.iter().rev() {
if !ours.contains(version) {
base_version = Some(*version);
break;
}
}
let mut index = match base_version {
Some(track) => store.read_index_at(track).await?,
None => Index::default(),
};
let decision = decide(repository, specs, &index);
results = decision.results.clone();
let visible = match head_version {
Some(track) if Some(track) == base_version => index.clone(),
Some(track) => store.read_index_at(track).await?,
None => Index::default(),
};
if is_satisfied(&visible, &decision, pack) {
break;
}
index.absorb_packs(&visible);
apply(repository, &mut index, &decision, pack);
index.parent = base_version.map(|track| track.0);
let written = store.write_index(&index).await?;
ours.push(written);
let after = store.index_versions().await?;
let mut below = None;
for version in after.iter().rev() {
if version.0 < written.0 {
below = Some(*version);
break;
}
}
if after.last() == Some(&written) && below == base_version {
break;
}
if attempt == PUSH_ATTEMPTS {
bail!(
"ref index kept being overwritten by a concurrent push after \
{PUSH_ATTEMPTS} attempts. Nothing was lost, every object and every \
index version is still stored, but the push needs retrying"
);
}
eprintln!("tape: concurrent push detected, merging and retrying");
}
Ok(results)
}
pub async fn push(
store: &Store,
repository: &Repository,
specs: &[String],
out: &mut impl Write,
) -> Result<()> {
store.writable()?;
let parsed = parse_specs(specs);
let base = match store.read_index().await? {
Some((index, _)) => index,
None => Index::default(),
};
let pack = store_pack(store, repository, &base, &decide(repository, &parsed, &base)).await?;
let results = publish(store, repository, &parsed, pack.as_ref()).await?;
for line in results {
writeln!(out, "{line}")?;
}
writeln!(out)?;
out.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn index_with(name: &str, object_id: &str) -> Index {
let mut index = Index::default();
index.refs.insert(name.to_string(), object_id.to_string());
index
}
fn repository() -> Repository {
Repository::at(env!("CARGO_MANIFEST_DIR"))
}
#[test]
fn plain_refspec() {
let parsed = parse_specs(&["refs/heads/main:refs/heads/main".to_string()]);
assert!(!parsed[0].is_forced);
assert_eq!(parsed[0].source, "refs/heads/main");
assert_eq!(parsed[0].destination, "refs/heads/main");
}
#[test]
fn forced_refspec() {
let parsed = parse_specs(&["+refs/heads/main:refs/heads/main".to_string()]);
assert!(parsed[0].is_forced);
assert_eq!(parsed[0].source, "refs/heads/main");
}
#[test]
fn delete_refspec() {
let parsed = parse_specs(&[":refs/heads/gone".to_string()]);
let decision = decide(
&repository(),
&parsed,
&index_with("refs/heads/gone", &"a".repeat(40)),
);
assert_eq!(decision.deletions, vec!["refs/heads/gone".to_string()]);
assert_eq!(decision.results, vec!["ok refs/heads/gone".to_string()]);
}
#[test]
fn malformed_refspec() {
let parsed = parse_specs(&["nonsense".to_string()]);
let decision = decide(&repository(), &parsed, &Index::default());
assert!(decision.updates.is_empty());
assert!(decision.results[0].starts_with("error nonsense"));
}
#[test]
fn missing_source() {
let parsed = parse_specs(&["refs/heads/nope-not-here:refs/heads/x".to_string()]);
let decision = decide(&repository(), &parsed, &Index::default());
assert!(decision.updates.is_empty());
assert!(decision.results[0].contains("no such ref locally"));
}
#[test]
fn satisfied_updates() {
let object_id = "b".repeat(40);
let mut decision = Decision::default();
decision
.updates
.push(("refs/heads/main".to_string(), object_id.clone()));
assert!(!is_satisfied(&Index::default(), &decision, None));
assert!(is_satisfied(
&index_with("refs/heads/main", &object_id),
&decision,
None
));
}
#[test]
fn satisfied_deletions() {
let mut decision = Decision::default();
decision.deletions.push("refs/heads/gone".to_string());
assert!(!is_satisfied(
&index_with("refs/heads/gone", &"c".repeat(40)),
&decision,
None
));
assert!(is_satisfied(&Index::default(), &decision, None));
}
#[test]
fn head_is_sticky() {
let mut index = index_with("refs/heads/release", &"d".repeat(40));
index.refs.insert("refs/heads/main".to_string(), "e".repeat(40));
index.head = Some("refs/heads/release".to_string());
apply(&repository(), &mut index, &Decision::default(), None);
assert_eq!(index.head.as_deref(), Some("refs/heads/release"));
}
#[test]
fn head_recovers() {
let mut index = index_with("refs/heads/main", &"f".repeat(40));
index.head = Some("refs/heads/deleted".to_string());
apply(&repository(), &mut index, &Decision::default(), None);
assert_eq!(index.head.as_deref(), Some("refs/heads/main"));
}
}