use anyhow::Context as _;
use knot_git::Layout;
use knot_index::{Index, Resolved};
use knot_types::{ObjectFormat, OwnerDid, RepoDid, RepoName, RepoRkey};
use sha2::{Digest as _, Sha256};
use crate::cas::Digest;
use crate::plan::hash_fields;
/// bump this when the identity covers something new, or old and new identities can collide.
const SNAPSHOT_IDENTITY_VERSION: u32 = 2;
/// two branches can share a commit, the oid alone doesn't say which one HEAD points to
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadCapture {
pub resolved: Option<String>,
pub symbolic: Option<String>,
pub unborn: bool,
}
/// `peeled` can be a tree or blob for a tag, not only a commit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublicRef {
pub name: String,
pub raw: String,
pub peeled: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSnapshot {
pub repo_did: RepoDid,
pub owner: OwnerDid,
pub rkey: RepoRkey,
pub name: Option<RepoName>,
pub head_capture: HeadCapture,
pub public_refs: Vec<PublicRef>,
pub cob_refs: Vec<(String, String)>,
pub object_format: ObjectFormat,
}
pub fn is_cob_ref(name: &str) -> bool {
knot_git::RefClass::of_revspec(name) == knot_git::RefClass::Cob
|| name.starts_with("refs/tngl/keep/")
}
impl RepoSnapshot {
/// export reuse and route planning both key on this, keep out inputs that can't change bytes.
pub fn snapshot_identity(&self) -> String {
let mut hasher = Sha256::new();
hash_fields::u32(&mut hasher, SNAPSHOT_IDENTITY_VERSION);
hash_fields::field(&mut hasher, b"repo_did", Some(self.repo_did.as_str()));
hash_fields::field(
&mut hasher,
b"object_format",
Some(self.object_format.capability()),
);
hash_fields::field(
&mut hasher,
b"head_resolved",
self.head_capture.resolved.as_deref(),
);
hash_fields::field(
&mut hasher,
b"head_symbolic",
self.head_capture.symbolic.as_deref(),
);
hash_fields::bool_field(&mut hasher, b"head_unborn", self.head_capture.unborn);
let mut refs: Vec<&PublicRef> = self.public_refs.iter().collect();
refs.sort_by_key(|r| &r.name);
for r in refs {
hash_fields::field(&mut hasher, b"ref_raw", Some(r.name.as_str()));
hash_fields::field(&mut hasher, b"ref_raw_target", Some(r.raw.as_str()));
hash_fields::field(&mut hasher, b"ref_peeled", r.peeled.as_deref());
}
let mut cob_refs: Vec<&(String, String)> = self.cob_refs.iter().collect();
cob_refs.sort();
for (name, raw) in cob_refs {
hash_fields::field(&mut hasher, b"cob_ref", Some(name.as_str()));
hash_fields::field(&mut hasher, b"cob_ref_target", Some(raw.as_str()));
}
Digest::from_raw(hasher.finalize().into()).hex()
}
}
/// `with_ref_lock` only covers this process. the caller's repo lock keeps the others out.
/// a manual `git push` or `git gc` skips both and can still move refs under a capture.
fn capture_repo_state(repo: &knot_git::Repo) -> anyhow::Result<CapturedRepoState> {
repo.with_ref_lock(|| {
let gix_repo = repo.git();
let head_capture = match gix_repo.head() {
Ok(head) => {
let symbolic = if head.is_detached() {
None
} else {
head.referent_name()
.map(|name| String::from_utf8_lossy(name.as_bstr()).into_owned())
};
HeadCapture {
resolved: gix_repo.head_id().ok().map(|id| id.detach().to_string()),
symbolic,
unborn: head.is_unborn(),
}
}
Err(_) => HeadCapture {
resolved: None,
symbolic: None,
unborn: true,
},
};
let graph = gix_repo.commit_graph().ok();
// read afresh, `advertised_refs_for` caches per process and misses
// pushes from other processes.
let refs = repo.references()?;
let hidden = upload_hidden_refs(gix_repo);
let public_refs = (refs.iter())
.filter(|record| knot_git::RefClass::of(&record.name).is_public())
.filter(|record| !hidden_by(record.name.as_str(), &hidden))
.map(|record| {
let raw = record.target.to_hex();
let peeled = if graph
.as_ref()
.is_some_and(|g| g.lookup(record.target.object_id()).is_some())
{
None
} else {
repo.peeled_target(record.target)
.with_context(|| {
format!("peel public ref {} target {}", record.name.as_str(), raw)
})?
.map(|peeled| peeled.to_hex())
};
Ok(PublicRef {
name: record.name.as_str().to_string(),
raw,
peeled,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let mut cob_refs: Vec<(String, String)> = (refs.iter())
.filter(|record| is_cob_ref(record.name.as_str()))
.map(|record| (record.name.as_str().to_string(), record.target.to_hex()))
.collect();
cob_refs.sort();
Ok(CapturedRepoState {
head_capture,
public_refs,
cob_refs,
object_format: repo.object_format(),
})
})
}
// copies knot_git's hidden-ref matching. keep the two in step.
fn upload_hidden_refs(repo: &gix::Repository) -> Vec<String> {
let config = repo.config_snapshot();
["transfer.hideRefs", "uploadpack.hideRefs"]
.into_iter()
.filter_map(|key| config.strings(key))
.flatten()
.map(|value| value.to_string())
.collect()
}
fn hidden_by(name: &str, patterns: &[String]) -> bool {
(patterns.iter()).any(|pattern| name == pattern || name.starts_with(&format!("{pattern}/")))
}
pub(crate) fn mark_tips(repo: &knot_git::Repo) -> anyhow::Result<[String; 2]> {
let captured = capture_repo_state(repo)?;
let public: String = (captured.public_refs.iter())
.map(|r| format!("{}\n", r.raw))
.collect();
let cob: String = (captured.cob_refs.iter())
.map(|(_, raw)| format!("{raw}\n"))
.collect();
Ok([public.clone(), public + &cob])
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedRepoState {
head_capture: HeadCapture,
public_refs: Vec<PublicRef>,
cob_refs: Vec<(String, String)>,
object_format: ObjectFormat,
}
pub fn snapshot_repo(
layout: &Layout,
index: &Index,
registry: Option<&knot_cobs::Registry>,
repo_did: &RepoDid,
) -> anyhow::Result<RepoSnapshot> {
let ownership = match index.ownership_of(repo_did) {
Resolved::Ready(Some(r)) => r,
Resolved::Ready(None) => anyhow::bail!("repo {repo_did} not found in index"),
Resolved::Warming => anyhow::bail!("index registry is still warming"),
};
let repo = layout
.open(repo_did)
.with_context(|| format!("open repo {repo_did} for snapshot"))?;
let captured = capture_repo_state(&repo)?;
let name = registry
.and_then(|reg| reg.record_of(repo_did))
.map(|record| record.name.clone())
.or_else(|| RepoName::new(ownership.rkey.as_str()).ok());
Ok(RepoSnapshot {
repo_did: repo_did.clone(),
owner: ownership.owner,
name,
rkey: ownership.rkey,
head_capture: captured.head_capture,
public_refs: captured.public_refs,
cob_refs: captured.cob_refs,
object_format: captured.object_format,
})
}
pub fn snapshot_all(
layout: &Layout,
index: &Index,
registry: Option<&knot_cobs::Registry>,
) -> anyhow::Result<Vec<RepoSnapshot>> {
index
.hosted_repos()
.iter()
.map(|did| snapshot_repo(layout, index, registry, did))
.collect()
}
#[cfg(test)]
#[path = "tests/snapshot.rs"]
mod tests;