use git2::{Oid, Repository};
use rusqlite::{params, Connection};
use tracing::debug;
use crate::collect::errors::{CollectError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkTips {
pub head_sha: String,
pub head_ref: String,
pub tips_digest: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkScope {
pub branches: Vec<String>,
pub head_only: bool,
pub skip_merges: bool,
}
impl WalkScope {
pub fn as_key(&self) -> String {
let mut branches = self.branches.clone();
branches.sort();
branches.dedup();
let branches =
serde_json::to_string(&branches).expect("a Vec<String> always serialises to JSON");
format!(
"branches={branches};head_only={};skip_merges={}",
u8::from(self.head_only),
u8::from(self.skip_merges),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkState {
pub head_sha: String,
pub head_ref: String,
pub tips_digest: String,
pub walk_scope: String,
pub walk_complete: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FullWalkReason {
Forced,
NeverWalked,
PreviousWalkIncomplete,
ScopeChanged,
BaseUnreachable,
}
impl FullWalkReason {
pub fn as_str(self) -> &'static str {
match self {
Self::Forced => "--force was passed, so the full history is re-walked",
Self::NeverWalked => "no completed walk recorded for this repository",
Self::PreviousWalkIncomplete => "the previously recorded walk did not complete",
Self::ScopeChanged => {
"this run's branch/head-only/merge scope differs from the recorded walk"
}
Self::BaseUnreachable => {
"the previously walked commit is no longer reachable (force-push or rewrite)"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum WalkPlan {
Skip,
Incremental {
base_sha: String,
},
Full {
reason: FullWalkReason,
},
}
pub fn plan(
recorded: Option<&WalkState>,
current: &WalkTips,
scope: &WalkScope,
base_reachable: bool,
) -> WalkPlan {
let Some(state) = recorded else {
return WalkPlan::Full {
reason: FullWalkReason::NeverWalked,
};
};
if !state.walk_complete {
return WalkPlan::Full {
reason: FullWalkReason::PreviousWalkIncomplete,
};
}
if state.walk_scope != scope.as_key() {
return WalkPlan::Full {
reason: FullWalkReason::ScopeChanged,
};
}
if state.head_sha == current.head_sha && state.tips_digest == current.tips_digest {
return WalkPlan::Skip;
}
if base_reachable && !state.head_sha.is_empty() {
return WalkPlan::Incremental {
base_sha: state.head_sha.clone(),
};
}
WalkPlan::Full {
reason: FullWalkReason::BaseUnreachable,
}
}
pub fn current_walk_tips(repo: &Repository) -> Result<WalkTips> {
let (head_sha, head_ref) = match repo.head() {
Ok(head) => {
let sha = head.target().map(|oid| oid.to_string()).unwrap_or_default();
(sha, head.name().unwrap_or_default().to_string())
}
Err(_) => (String::new(), String::new()),
};
let mut pairs: Vec<String> = Vec::new();
for r in repo.references()?.flatten() {
let Some(name) = r.name() else { continue };
if !name.starts_with("refs/heads/") && !name.starts_with("refs/remotes/") {
continue;
}
let Some(oid) = r.target() else { continue };
pairs.push(format!("{name}\t{oid}"));
}
pairs.sort();
let tips_digest = blake3::hash(pairs.join("\n").as_bytes())
.to_hex()
.to_string();
Ok(WalkTips {
head_sha,
head_ref,
tips_digest,
})
}
pub fn base_is_reachable(repo: &Repository, base_sha: &str, head_sha: &str) -> bool {
if base_sha.is_empty() || head_sha.is_empty() {
return false;
}
if base_sha == head_sha {
return true;
}
let (Ok(base), Ok(head)) = (Oid::from_str(base_sha), Oid::from_str(head_sha)) else {
return false;
};
if repo.find_commit(base).is_err() {
return false;
}
repo.graph_descendant_of(head, base).unwrap_or(false)
}
pub fn load(conn: &Connection, repository: &str) -> Result<Option<WalkState>> {
let mut stmt = conn.prepare(
"SELECT head_sha, head_ref, tips_digest, walk_scope, walk_complete \
FROM repo_walk_state WHERE repository = ?1",
)?;
let mut rows = stmt.query(params![repository])?;
match rows.next()? {
Some(row) => Ok(Some(WalkState {
head_sha: row.get(0)?,
head_ref: row.get(1)?,
tips_digest: row.get(2)?,
walk_scope: row.get(3)?,
walk_complete: row.get::<_, i64>(4)? != 0,
})),
None => Ok(None),
}
}
pub fn mark_in_flight(conn: &Connection, repository: &str) -> Result<()> {
conn.execute(
"INSERT INTO repo_walk_state \
(repository, head_sha, head_ref, tips_digest, walk_scope, walk_complete, walked_at) \
VALUES (?1, '', '', '', '', 0, ?2) \
ON CONFLICT(repository) DO UPDATE SET \
walk_complete = 0, \
walked_at = excluded.walked_at",
params![repository, chrono::Utc::now().to_rfc3339()],
)
.map_err(CollectError::Db)?;
debug!(repo = repository, "marked repo walk in flight");
Ok(())
}
pub fn record_complete(
conn: &Connection,
repository: &str,
tips: &WalkTips,
scope: &WalkScope,
) -> Result<()> {
conn.execute(
"INSERT INTO repo_walk_state \
(repository, head_sha, head_ref, tips_digest, walk_scope, walk_complete, walked_at) \
VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6) \
ON CONFLICT(repository) DO UPDATE SET \
head_sha = excluded.head_sha, \
head_ref = excluded.head_ref, \
tips_digest = excluded.tips_digest, \
walk_scope = excluded.walk_scope, \
walk_complete = 1, \
walked_at = excluded.walked_at",
params![
repository,
tips.head_sha,
tips.head_ref,
tips.tips_digest,
scope.as_key(),
chrono::Utc::now().to_rfc3339(),
],
)
.map_err(CollectError::Db)?;
debug!(
repo = repository,
head = %tips.head_sha,
scope = %scope.as_key(),
"recorded completed repo walk state"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tips(head: &str, digest: &str) -> WalkTips {
WalkTips {
head_sha: head.to_string(),
head_ref: "refs/heads/main".to_string(),
tips_digest: digest.to_string(),
}
}
fn full_scope() -> WalkScope {
WalkScope::default()
}
fn state(head: &str, digest: &str, complete: bool) -> WalkState {
WalkState {
head_sha: head.to_string(),
head_ref: "refs/heads/main".to_string(),
tips_digest: digest.to_string(),
walk_scope: full_scope().as_key(),
walk_complete: complete,
}
}
#[test]
fn plan_skips_an_unchanged_head() {
let s = state("aaa", "d1", true);
assert_eq!(
plan(Some(&s), &tips("aaa", "d1"), &full_scope(), true),
WalkPlan::Skip
);
}
#[test]
fn plan_goes_incremental_when_the_head_advanced() {
let s = state("aaa", "d1", true);
assert_eq!(
plan(Some(&s), &tips("bbb", "d2"), &full_scope(), true),
WalkPlan::Incremental {
base_sha: "aaa".to_string()
}
);
}
#[test]
fn a_moved_side_branch_forces_a_walk_despite_an_unchanged_head() {
let s = state("aaa", "d1", true);
assert_ne!(
plan(Some(&s), &tips("aaa", "d2"), &full_scope(), true),
WalkPlan::Skip
);
}
#[test]
fn an_incomplete_previous_walk_forces_a_full_rewalk() {
let s = state("aaa", "d1", false);
assert_eq!(
plan(Some(&s), &tips("aaa", "d1"), &full_scope(), true),
WalkPlan::Full {
reason: FullWalkReason::PreviousWalkIncomplete
}
);
}
#[test]
fn a_comma_in_a_branch_name_is_a_distinct_scope() {
let one_odd_branch = WalkScope {
branches: vec!["a,b".to_string()],
..WalkScope::default()
};
let two_branches = WalkScope {
branches: vec!["a".to_string(), "b".to_string()],
..WalkScope::default()
};
assert_ne!(
one_odd_branch.as_key(),
two_branches.as_key(),
"a comma inside a ref name must not read as a separator"
);
let mut recorded = state("aaa", "d1", true);
recorded.walk_scope = two_branches.as_key();
assert_eq!(
plan(Some(&recorded), &tips("aaa", "d1"), &one_odd_branch, true),
WalkPlan::Full {
reason: FullWalkReason::ScopeChanged
}
);
let reordered = WalkScope {
branches: vec!["b".to_string(), "a".to_string(), "b".to_string()],
..WalkScope::default()
};
assert_eq!(two_branches.as_key(), reordered.as_key());
}
#[test]
fn a_narrower_scope_is_a_different_walk() {
let narrow = WalkScope {
branches: vec!["main".to_string()],
..WalkScope::default()
};
let mut recorded = state("aaa", "d1", true);
recorded.walk_scope = narrow.as_key();
assert_eq!(
plan(Some(&recorded), &tips("aaa", "d1"), &full_scope(), true),
WalkPlan::Full {
reason: FullWalkReason::ScopeChanged
},
"a full-scope run must not skip on a scoped run's recorded tip"
);
assert_eq!(
plan(Some(&recorded), &tips("aaa", "d1"), &narrow, true),
WalkPlan::Skip,
"the same scope still skips"
);
let head_only = WalkScope {
head_only: true,
..WalkScope::default()
};
assert_eq!(
plan(Some(&recorded), &tips("aaa", "d1"), &head_only, true),
WalkPlan::Full {
reason: FullWalkReason::ScopeChanged
}
);
let reordered = WalkScope {
branches: vec!["b".to_string(), "a".to_string()],
..WalkScope::default()
};
let ordered = WalkScope {
branches: vec!["a".to_string(), "b".to_string()],
..WalkScope::default()
};
assert_eq!(reordered.as_key(), ordered.as_key());
let no_merges = WalkScope {
skip_merges: true,
..WalkScope::default()
};
assert_ne!(no_merges.as_key(), full_scope().as_key());
}
#[test]
fn plan_names_every_full_walk_reason() {
assert_eq!(
plan(None, &tips("aaa", "d1"), &full_scope(), true),
WalkPlan::Full {
reason: FullWalkReason::NeverWalked
}
);
let s = state("aaa", "d1", true);
assert_eq!(
plan(Some(&s), &tips("bbb", "d2"), &full_scope(), false),
WalkPlan::Full {
reason: FullWalkReason::BaseUnreachable
}
);
assert!(FullWalkReason::BaseUnreachable
.as_str()
.contains("force-push"));
assert!(FullWalkReason::NeverWalked
.as_str()
.contains("no completed"));
assert!(FullWalkReason::Forced.as_str().contains("--force"));
assert!(FullWalkReason::ScopeChanged.as_str().contains("scope"));
}
#[test]
fn a_moved_side_branch_changes_the_digest() {
let a = blake3::hash(b"refs/heads/main\taaa\nrefs/heads/side\tbbb");
let b = blake3::hash(b"refs/heads/main\taaa\nrefs/heads/side\tccc");
assert_ne!(a, b);
}
}