use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::{
dir_state, journal_exists, journal_path, journaled, migrate_store, now_secs, read_journal,
read_regular, reports, run_in, served_stores, store_migration_id, sync_dir, with_suffix,
write_synced, Built, Detected, DroppedRef, Input, JournalRead, Kind, Outcome, MAX_HELD_IDS,
STORE_BUDGET,
};
use crate::forks::{
config_entries, parse_lineage_value, read_lineage_checked, stored_lineage, StoredLineage,
FORGE_REFS,
};
use crate::mirror_record::{is_hex64, MirrorStore, StoredRecord};
const PARTIAL_FORK_AGE: Duration = Duration::from_secs(3600);
#[derive(Debug, Clone)]
struct AncestorReport {
id: String,
path: PathBuf,
held: PathBuf,
dropped: PathBuf,
roots: Vec<String>,
stamped: bool,
}
#[derive(Debug, Clone, Default)]
pub(super) struct Plan {
parent: String,
followed: bool,
reports: Vec<AncestorReport>,
gate: Option<String>,
unknown: bool,
}
impl Plan {
pub(super) fn parent(&self) -> &str {
&self.parent
}
pub(super) fn followed(&self) -> bool {
self.followed
}
pub(super) fn gate(&self) -> Option<&str> {
self.gate.as_deref()
}
pub(super) fn report_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self.reports.iter().map(|r| r.id.clone()).collect();
ids.sort();
ids.dedup();
ids
}
fn triggers(&self) -> Vec<&AncestorReport> {
self.reports.iter().filter(|r| !r.stamped).collect()
}
}
#[derive(Debug, Clone)]
enum Lineage {
Root,
Parent(String, String),
Unknown(String),
Unreadable,
}
pub(super) struct Swept {
pub(super) outcomes: Vec<Outcome>,
pub(super) listed: bool,
}
pub(super) async fn sweep(plugin: &Path, mirror_complete: bool) -> Swept {
sweep_partial_forks(plugin);
let (stores, stores_listed) = served_stores(plugin);
let mut listed = stores_listed;
let mut lineage = BTreeMap::new();
for (owner, name) in &stores {
let dir = plugin.join("repos").join(owner).join(format!("{name}.git"));
let l = match read_lineage_checked(&dir) {
Ok(StoredLineage::Parent(p)) => Lineage::Parent(p.owner, p.name),
Ok(StoredLineage::Unknown(raw)) => Lineage::Unknown(raw),
Ok(StoredLineage::None) => Lineage::Root,
Err(e) => {
tracing::error!(owner = %owner, name = %name, error = %e.kind(), "forge: repository config not read; every fork is refused");
listed = false;
Lineage::Unreadable
}
};
lineage.insert((owner.clone(), name.clone()), l);
}
let mut targets: BTreeMap<(String, String), String> = BTreeMap::new();
for (key, l) in &lineage {
match l {
Lineage::Parent(o, n) => {
targets.insert(key.clone(), format!("{o}/{n}"));
}
Lineage::Unknown(raw) => {
targets.insert(key.clone(), raw.clone());
}
Lineage::Root | Lineage::Unreadable => {}
}
}
let (journals, journals_listed) = journaled(plugin);
listed &= journals_listed;
for (owner, name) in journals {
let Some(path) = journal_path(plugin, &owner, &name) else {
continue;
};
if let JournalRead::Present(j) = read_journal(&path) {
if j.class == "fork" {
if let Some(parent) = j.parent {
targets.entry((owner, name)).or_insert(parent);
}
}
}
}
let mut outcomes = Vec::new();
let mut blocked: BTreeSet<String> = BTreeSet::new();
let mut every: Option<Result<Vec<AncestorReport>, String>> = None;
for ((owner, name), parent) in &targets {
let plan = plan_for(plugin, owner, name, parent, &lineage, &mut every);
let kind = Kind::Fork(plan.clone());
let outcome = if journal_exists(plugin, owner, name) {
migrate_store(plugin, owner, name, &kind).await
} else {
match detect(plugin, owner, name, &plan).await {
Detected::Skip | Detected::Clean => Outcome::Skipped,
_ => migrate_store(plugin, owner, name, &kind).await,
}
};
blocked.extend(stamp_blocks(&plan, outcome));
if outcome == Outcome::Ungated {
listed = false;
}
outcomes.push(outcome);
}
if mirror_complete && listed && !blocked.contains("") {
stamp_clean(plugin, &blocked, &mut every);
}
Swept { outcomes, listed }
}
fn stamp_blocks(plan: &Plan, outcome: Outcome) -> Vec<String> {
if plan.gate.is_some() || plan.unknown {
return vec![String::new()];
}
match outcome {
Outcome::Migrated | Outcome::Skipped => Vec::new(),
Outcome::Failed | Outcome::Ungated => {
let mut ids = plan.report_ids();
if plan.reports.is_empty() {
ids.push(String::new());
}
ids
}
}
}
fn plan_for(
plugin: &Path,
owner: &str,
name: &str,
parent: &str,
lineage: &BTreeMap<(String, String), Lineage>,
every: &mut Option<Result<Vec<AncestorReport>, String>>,
) -> Plan {
let mut plan = Plan {
parent: parent.to_string(),
followed: is_hex64(owner)
&& !matches!(
MirrorStore::new(plugin).load(owner, name),
StoredRecord::Missing
),
..Plan::default()
};
let mut visited = BTreeSet::from([(owner.to_string(), name.to_string())]);
let mut next = parse_lineage_value(parent).map(|p| (p.owner, p.name));
let mut orphan = false;
if next.is_none() {
plan.unknown = true;
orphan = true;
}
while let Some(at) = next.take() {
if visited.len() > lineage.len() + 1 || !visited.insert(at.clone()) {
break; }
let (o, n) = (&at.0, &at.1);
if journal_exists(plugin, o, n) {
plan.gate = Some(format!("ancestor {o}/{n} is behind a migration journal"));
return plan;
}
if is_hex64(o) {
match mirror_reports(plugin, o, n) {
Ok(found) => plan.reports.extend(found),
Err(e) => {
plan.gate = Some(format!("ancestor {o}/{n}'s migration report: {e}"));
return plan;
}
}
}
let dir = plugin.join("repos").join(o).join(format!("{n}.git"));
match dir_state(&dir) {
Ok(true) => match lineage.get(&at) {
Some(Lineage::Parent(po, pn)) => next = Some((po.clone(), pn.clone())),
Some(Lineage::Unknown(_)) => {
plan.unknown = true;
orphan = true;
}
Some(Lineage::Root) => {}
Some(Lineage::Unreadable) | None => {
plan.gate = Some(format!("ancestor {o}/{n}'s lineage could not be read"));
return plan;
}
},
Ok(false) => orphan = true,
Err(e) => {
plan.gate = Some(format!("ancestor {o}/{n}: {e}"));
return plan;
}
}
}
if orphan {
match every.get_or_insert_with(|| every_mirror_report(plugin)) {
Ok(all) => plan.reports.extend(all.iter().cloned()),
Err(e) => {
plan.gate = Some(format!("the migration reports could not be listed: {e}"));
return plan;
}
}
}
let mut seen = HashSet::new();
plan.reports.retain(|r| seen.insert(r.id.clone()));
plan
}
fn mirror_reports(plugin: &Path, owner: &str, name: &str) -> Result<Vec<AncestorReport>, String> {
let dir = plugin.join("mirror-recovery").join(owner);
let mut out = Vec::new();
for path in reports(&dir, name)? {
if let Some(r) = read_report(&path)? {
out.push(r);
}
}
Ok(out)
}
fn every_mirror_report(plugin: &Path) -> Result<Vec<AncestorReport>, String> {
let root = plugin.join("mirror-recovery");
let mut out = Vec::new();
for (owner, path) in super::names_in(&root)? {
if !is_hex64(&owner) || !dir_state(&path)? {
continue;
}
for (file, entry) in super::names_in(&path)? {
let Some(stem) = file.strip_suffix(".migration.json") else {
continue;
};
let is_report = stem
.rsplit_once(".git.")
.is_some_and(|(_, ts)| super::all_digits(ts));
if is_report {
if let Some(r) = read_report(&entry)? {
out.push(r);
}
}
}
}
Ok(out)
}
fn read_report(path: &Path) -> Result<Option<AncestorReport>, String> {
let bytes = read_regular(path).map_err(|e| format!("not read: {}", e.kind()))?;
let v: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| format!("not parsed: {e}"))?;
if v.get("class").and_then(|c| c.as_str()) != Some("mirror") {
return Ok(None);
}
let id = v
.get("id")
.and_then(|i| i.as_str())
.ok_or("no id")?
.to_string();
let file = path
.to_str()
.and_then(|p| p.strip_suffix(".migration.json"))
.ok_or("not a report name")?;
let stem = PathBuf::from(file);
let (held, dropped) = (with_suffix(&stem, ".held"), with_suffix(&stem, ".dropped"));
for f in [&held, &dropped] {
if !std::fs::symlink_metadata(f).is_ok_and(|m| m.is_file()) {
return Err("its held or dropped list is missing".into());
}
}
let roots = v
.get("copied")
.and_then(|c| c.as_array())
.map(|refs| {
refs.iter()
.filter(|r| {
r.get("ref")
.and_then(|n| n.as_str())
.is_some_and(super::is_root)
})
.filter_map(|r| r.get("sha").and_then(|s| s.as_str()))
.filter(|s| super::is_sha40(s))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Ok(Some(AncestorReport {
id,
path: path.to_path_buf(),
held,
dropped,
roots,
stamped: v.get("forksCleanAt").is_some(),
}))
}
pub(super) async fn detect(plugin: &Path, owner: &str, name: &str, plan: &Plan) -> Detected {
let served = plugin.join("repos").join(owner).join(format!("{name}.git"));
match dir_state(&served) {
Ok(true) => {}
Ok(false) => return Detected::Skip,
Err(e) => return Detected::Error(e),
}
if let Some(g) = &plan.gate {
return Detected::Error(g.clone());
}
let triggers = plan.triggers();
if triggers.is_empty() {
return Detected::Clean;
}
if rebuilt_against(plugin, owner, name, &served, &triggers) {
return Detected::Clean;
}
let files: Vec<PathBuf> = triggers.iter().map(|r| r.dropped.clone()).collect();
match holds_any(&served, files).await {
Ok(true) => Detected::Rebuild,
Ok(false) => match holds_unreachable(&served).await {
Ok(true) => Detected::Rebuild,
Ok(false) => Detected::Clean,
Err(e) => Detected::Error(e),
},
Err(e) => Detected::Error(e),
}
}
async fn holds_unreachable(git_dir: &Path) -> Result<bool, String> {
let mut all: u64 = 0;
super::for_each_line(
git_dir,
&[
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname)",
],
None,
STORE_BUDGET,
|_| {
all += 1;
Ok(super::Flow::Go)
},
)
.await?;
let mut reached: u64 = 0;
super::for_each_line(
git_dir,
&[
"rev-list",
"--objects",
"--exclude=refs/mirror/*",
"--exclude=refs/replace/*",
"--all",
],
None,
STORE_BUDGET,
|_| {
reached += 1;
Ok(super::Flow::Go)
},
)
.await?;
Ok(all > reached)
}
fn rebuilt_against(
plugin: &Path,
owner: &str,
name: &str,
served: &Path,
triggers: &[&AncestorReport],
) -> bool {
let Some(id) = store_migration_id(served) else {
return false;
};
let dir = plugin.join("mirror-recovery").join(owner);
let Ok(found) = reports(&dir, name) else {
return false;
};
found.iter().any(|path| {
let Some(v) = read_regular(path)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
else {
return false;
};
let covered: BTreeSet<&str> = v
.get("ancestors")
.and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
v.get("class").and_then(|c| c.as_str()) == Some("fork")
&& v.get("id").and_then(|i| i.as_str()) == Some(id.as_str())
&& triggers.iter().all(|t| covered.contains(t.id.as_str()))
})
}
async fn holds_any(git_dir: &Path, files: Vec<PathBuf>) -> Result<bool, String> {
let mut hit = false;
super::for_each_line_input(
git_dir,
&["cat-file", "--batch-check=%(objectname) %(objecttype)"],
Input::Files(files),
STORE_BUDGET,
|line| {
if line.ends_with(" missing") {
Ok(super::Flow::Go)
} else {
hit = true;
Ok(super::Flow::Stop)
}
},
)
.await?;
Ok(hit)
}
pub(super) fn inherited_held(plan: &Plan) -> Result<HashSet<String>, String> {
use std::io::BufRead;
let mut held = HashSet::new();
for r in &plan.reports {
let m = std::fs::symlink_metadata(&r.held).map_err(|e| format!("held list: {e}"))?;
if !m.file_type().is_file() {
return Err("a held list is not a regular file".into());
}
let f = std::fs::File::open(&r.held).map_err(|e| format!("held list: {e}"))?;
for line in std::io::BufReader::new(f).lines() {
let line = line.map_err(|e| format!("held list: {e}"))?;
if line.is_empty() {
continue;
}
if !super::is_sha40(&line) {
return Err("a held list holds something other than object ids".into());
}
if held.len() >= MAX_HELD_IDS {
return Err(format!(
"the inherited held set passed {MAX_HELD_IDS} objects"
));
}
held.insert(line);
}
}
Ok(held)
}
pub(super) async fn present_roots(source: &Path, plan: &Plan) -> Result<Vec<String>, String> {
let mut all: Vec<String> = plan
.reports
.iter()
.flat_map(|r| r.roots.iter().cloned())
.collect();
all.sort();
all.dedup();
if all.is_empty() {
return Ok(all);
}
let mut input = Vec::with_capacity(all.len() * 41);
for sha in &all {
input.extend_from_slice(sha.as_bytes());
input.push(b'\n');
}
let out = super::run_with_input(
source,
&["cat-file", "--batch-check=%(objectname) %(objecttype)"],
input,
STORE_BUDGET,
)
.await?;
if !out.status.success() {
return Err("git cat-file --batch-check failed".into());
}
let present: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.ends_with(" missing"))
.filter_map(|l| l.split(' ').next())
.filter(|id| super::is_sha40(id))
.map(str::to_string)
.collect();
Ok(present)
}
pub(super) async fn select(
source: &Path,
refs: Vec<(String, Option<String>, String)>,
built: &mut Built,
held: &HashSet<String>,
ancestor_roots: &[String],
) -> Result<(), String> {
for (sha, name, lossy) in refs {
let reason = match name.as_deref() {
None => "unsafe-name",
Some(r) if !super::safe_ref(r) || !super::is_sha40(&sha) => "unsafe-name",
Some(r) if r.starts_with("refs/mirror/") => "mirror",
Some(r) if r.starts_with("refs/replace/") => "replace",
Some(r) => {
if super::reaches_held(source, &sha, ancestor_roots, held).await? {
"reaches-held"
} else {
built.copied.insert(r.to_string(), sha);
continue;
}
}
};
built.dropped.push(DroppedRef {
name: lossy,
sha,
reason,
});
}
Ok(())
}
pub(super) async fn write_fork_config(source: &Path, staging: &Path) -> Result<(), String> {
let bytes = read_regular(&source.join("config"))
.map_err(|e| format!("the old config could not be read: {}", e.kind()))?;
let text = String::from_utf8_lossy(&bytes);
let parent = match stored_lineage(&text) {
StoredLineage::Parent(p) => p.full(),
StoredLineage::Unknown(raw) => raw,
StoredLineage::None => return Err("the old config names no forge.parent".into()),
};
let mut sets: Vec<(&str, String)> = vec![("forge.parent", parent)];
for (section, key, value) in config_entries(&text) {
match (section.as_str(), key.as_str()) {
("http", "receivepack") if matches!(value.as_str(), "true" | "false") => {
sets.push(("http.receivepack", value));
}
("uploadpack", "hiderefs") if value == FORGE_REFS => {
sets.push(("uploadpack.hideRefs", value));
}
("transfer", "hiderefs") if value == FORGE_REFS => {
sets.push(("transfer.hideRefs", value));
}
_ => {}
}
}
for (key, value) in sets {
run_in(
staging,
staging,
&["config", "--local", "--add", key, &value],
None,
Duration::from_secs(15),
None,
)
.await?;
}
Ok(())
}
fn stamp_clean(
plugin: &Path,
blocked: &BTreeSet<String>,
every: &mut Option<Result<Vec<AncestorReport>, String>>,
) {
let all = match every.get_or_insert_with(|| every_mirror_report(plugin)) {
Ok(all) => all,
Err(e) => {
tracing::warn!(error = %e, "forge: migration reports not listed; none stamped");
return;
}
};
for r in all
.iter()
.filter(|r| !r.stamped && !blocked.contains(&r.id))
{
if let Err(e) = stamp(&r.path) {
tracing::warn!(error = %e, "forge: migration report not stamped");
}
}
}
fn stamp(path: &Path) -> Result<(), String> {
let bytes = read_regular(path).map_err(|e| format!("not read: {}", e.kind()))?;
let mut v: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| format!("not parsed: {e}"))?;
let o = v.as_object_mut().ok_or("not an object")?;
o.insert("forksCleanAt".into(), now_secs().into());
let out = serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?;
let tmp = with_suffix(path, ".tmp");
write_synced(&tmp, &out).map_err(|e| format!("not written: {e}"))?;
std::fs::rename(&tmp, path).map_err(|e| format!("not written: {e}"))?;
path.parent()
.map_or(Ok(()), sync_dir)
.map_err(|e| format!("not synced: {e}"))
}
fn sweep_partial_forks(plugin: &Path) {
let Ok(owners) = super::names_in(&plugin.join("repos")) else {
return;
};
for (owner, owner_dir) in owners {
if !crate::ownership::valid_name_segment(&owner) {
continue;
}
let Ok(entries) = super::names_in(&owner_dir) else {
continue;
};
for (file, entry) in entries {
if !(file.starts_with(".fork-") && file.ends_with(".tmp")) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&entry) else {
continue;
};
let old = meta
.modified()
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age >= PARTIAL_FORK_AGE);
if !meta.is_dir() || !old {
continue;
}
let dir = plugin.join("mirror-recovery").join(&owner);
let dest = dir.join(format!("{file}.{}", now_secs()));
let moved = std::fs::create_dir_all(&dir)
.and_then(|()| std::fs::rename(&entry, &dest))
.and_then(|()| sync_dir(&dir))
.and_then(|()| sync_dir(&owner_dir));
match moved {
Ok(()) => {
tracing::info!(owner = %owner, entry = %file, "forge: stale partial fork moved to recovery");
}
Err(e) => {
tracing::warn!(owner = %owner, entry = %file, error = %e, "forge: stale partial fork not moved");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_gate_partway_up_the_lineage_blocks_every_report() {
let report = AncestorReport {
id: "r1".into(),
path: PathBuf::new(),
held: PathBuf::new(),
dropped: PathBuf::new(),
roots: Vec::new(),
stamped: false,
};
let plan = Plan {
parent: "f/sample".into(),
reports: vec![report],
gate: Some("ancestor carol/sample: transient".into()),
..Plan::default()
};
for outcome in [
Outcome::Failed,
Outcome::Ungated,
Outcome::Skipped,
Outcome::Migrated,
] {
assert!(
stamp_blocks(&plan, outcome).contains(&String::new()),
"{outcome:?}: a gated plan blocked only {:?}",
stamp_blocks(&plan, outcome)
);
}
}
}