use crate::anchor::{self, AnchorRef, Change};
use crate::args::Args;
use crate::brief::clip;
use crate::event::{Repo, RepoAnchor};
use crate::failure::R;
use crate::glob;
use crate::model::{Node, Tree, Vivac};
use crate::output::outln;
use crate::render::print_json;
use serde_json::json;
use std::path::Path;
const SHOWN: usize = 20;
struct Verdict<'a> {
file: String,
times: usize,
claimed_by: Vec<&'a Node>,
}
impl Verdict<'_> {
fn claimed_and_open(&self) -> bool {
self.claimed_by.iter().any(|n| n.state.is_open())
}
}
struct Moved {
path: String,
from: String,
to: String,
}
fn repo_changes(
declared: &[Repo],
since_anchors: &[RepoAnchor],
lane_dir: &Path,
) -> (Vec<Change>, Vec<Moved>) {
let mut changes = Vec::new();
let mut moved = Vec::new();
for repo in declared {
let Some(entry) = since_anchors.iter().find(|r| r.path == repo.path) else {
continue;
};
let anchor::Where::Head(h) = anchor::where_of(&lane_dir.join(&repo.path)) else {
continue;
};
let differs = match (&entry.branch, &h.branch) {
(Some(a), Some(b)) => a != b,
_ => false,
};
if differs {
moved.push(Moved {
path: repo.path.clone(),
from: entry.branch.clone().unwrap_or_default(),
to: h.branch.clone().unwrap_or_default(),
});
continue;
}
let prefix = if repo.path == "." {
String::new()
} else {
format!("{}/", repo.path)
};
let reference = AnchorRef {
kind: "git".to_string(),
id: entry.sha.clone(),
};
for c in anchor::detect(&lane_dir.join(&repo.path)).changed_since(&reference) {
changes.push(Change {
file_path: format!("{prefix}{}", c.file_path),
times: c.times,
});
}
}
(changes, moved)
}
fn plural(n: usize, one: &str, many: &str) -> String {
if n == 1 {
format!("{n} {one}")
} else {
format!("{n} {many}")
}
}
fn reference<'a>(a: &'a Tree, args: &Args) -> Result<Option<&'a Vivac>, crate::failure::Failure> {
match args.opt("since") {
Some(s) => a
.vivac(s)
.map(Some)
.ok_or_else(|| crate::failure::Failure::usage(format!("No such vivac: {s}."))),
None => Ok(a.last_vivac()),
}
}
pub fn reconcile(a: &Tree, lane_dir: &Path, args: &Args) -> R {
let Some(since) = reference(a, args)? else {
outln!();
outln!(" No stop to measure from: this tree has no vivacs yet.");
outln!();
outln!(" vivac save \"<label>\"");
outln!();
return Ok(());
};
let declared: &[Repo] = a
.lanes
.get(a.lane())
.map(|s| s.repos.as_slice())
.unwrap_or(&[]);
let (changes, moved) = if declared.is_empty() {
if since.anchor.is_empty_tree() {
outln!();
outln!(
" {} has no anchor, so there is no history to read.",
since.alias()
);
outln!(" Without version control the tree cannot be contradicted; that is");
outln!(" the floor of the product and not a failure.");
outln!();
return Ok(());
}
let root = anchor::detect(lane_dir);
(root.changed_since(&since.anchor), Vec::new())
} else {
repo_changes(declared, &since.anchors, lane_dir)
};
let changes: Vec<Change> = changes
.into_iter()
.filter(|c| !c.file_path.replace('\\', "/").starts_with(".vivac/"))
.collect();
let governing: Vec<&Node> = a
.nodes_iter()
.filter(|n| !n.governs(a).is_empty())
.collect();
let mut verdicts: Vec<Verdict> = changes
.iter()
.map(|c| {
let mut claimed_by: Vec<&Node> = governing
.iter()
.filter(|n| n.governs(a).iter().any(|g| glob::covers(g, &c.file_path)))
.copied()
.collect();
claimed_by.sort_by_key(|n| (!n.state.is_open(), n.num));
Verdict {
file: c.file_path.clone(),
times: c.times,
claimed_by,
}
})
.collect();
verdicts.sort_by(|x, y| y.times.cmp(&x.times).then_with(|| x.file.cmp(&y.file)));
let unclaimed: Vec<&Verdict> = verdicts
.iter()
.filter(|v| v.claimed_by.is_empty())
.collect();
let stale: Vec<&Verdict> = verdicts
.iter()
.filter(|v| !v.claimed_by.is_empty() && !v.claimed_and_open())
.collect();
let live: Vec<&Verdict> = verdicts.iter().filter(|v| v.claimed_and_open()).collect();
if args.has("json") {
let one = |v: &Verdict| {
json!({
"file": v.file,
"changes": v.times,
"claimed_by": v.claimed_by.iter().map(|n| json!({
"alias": n.alias(),
"title": n.title(a),
"state": n.state,
})).collect::<Vec<_>>(),
})
};
return print_json(json!({
"since": since.alias(),
"since_ts": since.ts,
"anchor": since.anchor.short(),
"governing_nodes": governing.len(),
"changed": verdicts.len(),
"unclaimed": unclaimed.iter().map(|v| one(v)).collect::<Vec<_>>(),
"claimed_by_closed_work": stale.iter().map(|v| one(v)).collect::<Vec<_>>(),
"claimed_and_open": live.iter().map(|v| one(v)).collect::<Vec<_>>(),
}));
}
outln!();
outln!(
" RECONCILE - since {} {}, {}",
since.alias(),
since.anchor.short(),
plural(verdicts.len(), "file changed", "files changed")
);
for m in &moved {
outln!();
outln!(" {} {} -> {}", m.path, m.from, m.to);
outln!(
" The stop anchored {}, so what changed here belongs to",
m.from
);
outln!(" another branch and not to this stop. Nothing compared.");
}
if verdicts.is_empty() {
outln!();
if moved.is_empty() {
outln!(" Nothing changed. The tree and the work agree.");
}
outln!();
return Ok(());
}
if governing.is_empty() {
outln!();
outln!(" No node declares what it governs, so nothing here can be claimed.");
outln!(" Until some node says which files it owns, this command has nothing");
outln!(" to compare the work against.");
outln!();
outln!(" vivac push \"<title>\" --why \"<reason>\" --governs \"src/auth/**\"");
outln!();
return Ok(());
}
section(
"NOBODY CLAIMS THESE",
"push \"<title>\" --governs <path>",
&unclaimed,
|_| String::new(),
);
section(
"CLAIMED ONLY BY CLOSED WORK",
"focus <id> --reopen | block <id>",
&stale,
|v| {
v.claimed_by
.iter()
.map(|n| format!("{} [{}]", n.alias(), n.state.word(n.kind)))
.collect::<Vec<_>>()
.join(" ")
},
);
if args.has("all") {
section("CLAIMED, AND THE WORK IS OPEN", "", &live, |v| {
v.claimed_by
.iter()
.filter(|n| n.state.is_open())
.map(|n| n.alias())
.collect::<Vec<_>>()
.join(" ")
});
} else if !live.is_empty() {
outln!();
outln!(
" {} under work that is open, which is what is supposed to happen. --all",
plural(live.len(), "file", "files")
);
}
if unclaimed.is_empty() && stale.is_empty() {
outln!();
outln!(" Nothing to reconcile.");
}
outln!();
Ok(())
}
fn section(title: &str, action: &str, rows: &[&Verdict], note: impl Fn(&Verdict) -> String) {
if rows.is_empty() {
return;
}
outln!();
outln!(
"{}",
format!(
" {} ({}){}{}",
title,
rows.len(),
" ".repeat(38usize.saturating_sub(title.len() + 4)),
action
)
.trim_end()
);
for v in rows.iter().take(SHOWN) {
outln!(
"{}",
format!(" {:<44} {:>3} {}", clip(&v.file, 44), v.times, note(v)).trim_end()
);
}
if rows.len() > SHOWN {
outln!(" + {} more --json", rows.len() - SHOWN);
}
}