use anyhow::{Context, Result};
use serde::Serialize;
use crate::db::{Change, Db, Project, version_order};
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub project: String,
pub shipped: Vec<Shipped>,
pub unplanned: Vec<String>,
pub untagged: Vec<String>,
pub commits_since_tag: u32,
pub last_commit_at: Option<String>,
pub changes_recorded: u32,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct Shipped {
pub version: String,
pub date: String,
pub newly: bool,
}
impl Report {
pub fn changed(&self) -> bool {
self.shipped.iter().any(|s| s.newly) || !self.unplanned.is_empty() || self.changes_recorded > 0
}
}
struct Tag {
version: String,
date: String,
moment: String,
}
pub fn sync(db: &Db, project: &Project) -> Result<Report> {
let mut report = Report {
project: project.name.clone(),
..Report::default()
};
let repo = match gix::open(&project.path) {
Ok(repo) => repo,
Err(err) => {
report
.warnings
.push(format!("{} is not a git repository rigger can read ({err})", project.path));
return Ok(report);
}
};
let known_before = db.version_names(project.id)?;
let tags = read_tags(&repo)?;
for tag in &tags {
let change = db.mark_shipped(project.id, &tag.version, &tag.date, &tag.moment)?;
match change {
Change::Unchanged => report.shipped.push(Shipped {
version: tag.version.clone(),
date: tag.date.clone(),
newly: false,
}),
Change::Updated => report.shipped.push(Shipped {
version: tag.version.clone(),
date: tag.date.clone(),
newly: true,
}),
Change::Added => report.shipped.push(Shipped {
version: tag.version.clone(),
date: tag.date.clone(),
newly: true,
}),
}
}
let floor = known_before.iter().map(|n| version_order(n)).min();
for tag in &tags {
if !known_before.iter().any(|k| version_order(k) == version_order(&tag.version)) && floor.is_none_or(|floor| version_order(&tag.version) > floor) {
report.unplanned.push(tag.version.clone());
}
}
let tagged: Vec<&str> = tags.iter().map(|t| t.version.as_str()).collect();
for claimed in db.shipped_versions(project.id)? {
if !tagged.iter().any(|t| version_order(t) == version_order(&claimed)) {
report.untagged.push(claimed);
}
}
let newest = tags.iter().max_by_key(|t| version_order(&t.version));
let history = read_history(&repo, newest.map(|t| t.version.as_str()))?;
report.commits_since_tag = history.commits_since_tag;
report.last_commit_at = history.last_commit_at.clone();
db.record_activity(project.id, history.commits_since_tag, history.last_commit_at.as_deref())?;
for change in history.changes.iter().rev() {
if db.record_commit_event(project.id, &change.hash, &change.body, &change.moment)? == Change::Added {
report.changes_recorded += 1;
}
}
Ok(report)
}
fn read_tags(repo: &gix::Repository) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
let refs = repo.references().context("cannot read the repository's references")?;
for tag in refs.tags().context("cannot list tags")? {
let mut tag = match tag {
Ok(tag) => tag,
Err(_) => continue,
};
let name = tag.name().shorten().to_string();
if !names_a_version(&name) {
continue;
}
let Ok(id) = tag.peel_to_id() else { continue };
let Ok(object) = repo.find_object(id) else { continue };
let Ok(commit) = object.try_into_commit() else { continue };
let Ok(time) = commit.time() else { continue };
tags.push(Tag {
version: name,
date: day(time.seconds),
moment: moment(time.seconds),
});
}
Ok(tags)
}
fn names_a_version(name: &str) -> bool {
name.strip_prefix('v').is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
}
struct History {
commits_since_tag: u32,
last_commit_at: Option<String>,
changes: Vec<CommitChange>,
}
struct CommitChange {
hash: String,
body: String,
moment: String,
}
const READ_DEPTH: u32 = 300;
fn read_history(repo: &gix::Repository, newest_tag: Option<&str>) -> Result<History> {
let Ok(head) = repo.head_commit() else {
return Ok(History {
commits_since_tag: 0,
last_commit_at: None,
changes: Vec::new(),
});
};
let last_commit_at = head.time().ok().map(|t| day(t.seconds));
let boundary = newest_tag.and_then(|name| {
let mut reference = repo.find_reference(&format!("refs/tags/{name}")).ok()?;
reference.peel_to_id().ok().map(|id| id.detach())
});
let mut commits_since_tag = 0u32;
let mut past_the_tag = false;
let mut changes = Vec::new();
let mut seen = 0u32;
let walk = head.ancestors().all().context("cannot walk the history")?;
for step in walk {
let Ok(info) = step else { break };
if Some(info.id) == boundary {
past_the_tag = true;
}
if !past_the_tag {
commits_since_tag += 1;
}
if let Some(change) = read_change(repo, info.id) {
changes.push(change);
}
seen += 1;
if seen >= READ_DEPTH {
break;
}
}
Ok(History {
commits_since_tag,
last_commit_at,
changes,
})
}
fn read_change(repo: &gix::Repository, id: gix::ObjectId) -> Option<CommitChange> {
let commit = repo.find_object(id).ok()?.try_into_commit().ok()?;
let message = commit.message_raw().ok()?.to_string();
let parsed = crate::commit::parse(&message)?;
if !parsed.is_a_change() {
return None;
}
Some(CommitChange {
hash: id.to_hex().to_string(),
body: parsed.body(),
moment: commit.time().ok().map(|t| moment(t.seconds)).unwrap_or_default(),
})
}
fn moment(seconds: i64) -> String {
jiff::Timestamp::from_second(seconds).map(|t| t.to_string()).unwrap_or_default()
}
fn day(seconds: i64) -> String {
jiff::Timestamp::from_second(seconds)
.map(|t| t.to_string().split('T').next().unwrap_or_default().to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_tags_that_name_a_version_are_releases() {
assert!(names_a_version("v0.5.0"));
assert!(names_a_version("v1.9"));
assert!(!names_a_version("latest"));
assert!(!names_a_version("nightly"));
assert!(!names_a_version("v-final"));
assert!(!names_a_version("release-1.0"));
}
#[test]
fn a_timestamp_becomes_the_day_it_fell_on_in_utc() {
assert_eq!(day(1788508019), "2026-09-04"); assert_eq!(day(0), "1970-01-01");
assert_eq!(day(1788479999), "2026-09-03"); assert_eq!(day(1788480000), "2026-09-04"); }
}