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::registry;
use crate::render::print_json;
use serde_json::json;
use std::path::Path;
const SHOWN: usize = 20;
const NESTED: &str = " ";
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 without_store(changes: Vec<Change>) -> Vec<Change> {
changes
.into_iter()
.filter(|c| !c.file_path.replace('\\', "/").starts_with(".vivac/"))
.collect()
}
fn verdicts_of<'a>(changes: &[Change], governing: &[&'a Node], a: &'a Tree) -> Vec<Verdict<'a>> {
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)));
verdicts
}
fn split_baskets<'v, 'a>(
verdicts: &'v [Verdict<'a>],
) -> (
Vec<&'v Verdict<'a>>,
Vec<&'v Verdict<'a>>,
Vec<&'v Verdict<'a>>,
) {
let unclaimed = verdicts
.iter()
.filter(|v| v.claimed_by.is_empty())
.collect();
let stale = verdicts
.iter()
.filter(|v| !v.claimed_by.is_empty() && !v.claimed_and_open())
.collect();
let live = verdicts.iter().filter(|v| v.claimed_and_open()).collect();
(unclaimed, stale, live)
}
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()),
}
}
struct OtherLane<'a> {
name: &'a str,
unreadable: bool,
verdicts: Vec<Verdict<'a>>,
}
fn other_lanes<'a>(
a: &'a Tree,
root: &Path,
current: &str,
governing: &[&'a Node],
lanes: &[crate::brief::LaneRow<'a>],
) -> Vec<OtherLane<'a>> {
let mut out = Vec::new();
let Some(store_dir) = crate::store::store_dir() else {
return out;
};
let Some(project_id) = crate::store::first_event_id(root) else {
return out;
};
let gone = registry::lanes_with_missing_folder(&store_dir, &project_id);
for row in lanes {
if row.id == current {
continue;
}
let declared: &[Repo] = a
.lanes
.get(row.id)
.map(|s| s.repos.as_slice())
.unwrap_or(&[]);
if declared.is_empty() {
continue;
}
if gone.iter().any(|g| g == row.id) {
out.push(OtherLane {
name: row.name,
unreadable: true,
verdicts: Vec::new(),
});
continue;
}
let Some(folder) = registry::lane_folder(&store_dir, &project_id, row.id) else {
continue;
};
let Some(since) = a.vivacs.iter().rev().find(|v| v.lane == row.id) else {
continue;
};
let (changes, _moved) = repo_changes(declared, &since.anchors, &folder);
let changes = without_store(changes);
let verdicts = verdicts_of(&changes, governing, a);
if verdicts.iter().all(|v| v.claimed_and_open()) {
continue;
}
out.push(OtherLane {
name: row.name,
unreadable: false,
verdicts,
});
}
out
}
fn print_other_lanes(others: &[OtherLane]) {
if others.is_empty() {
return;
}
outln!();
outln!(" IN OTHER LANES OF THIS PRODUCT");
for lane in others {
outln!();
if lane.unreadable {
outln!(" {} folder not readable from here, skipped", lane.name);
continue;
}
outln!(" {}", lane.name);
let (unclaimed, stale, _live) = split_baskets(&lane.verdicts);
section(
"NOBODY CLAIMS THESE",
"push \"<title>\" --governs <path>",
&unclaimed,
|_| String::new(),
NESTED,
);
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(" ")
},
NESTED,
);
}
outln!();
}
pub fn reconcile(a: &Tree, root: &Path, 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 here = a.lane();
let lanes = crate::brief::all_lanes(a);
let declared: &[Repo] = a.lanes.get(here).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 anchor = anchor::detect(lane_dir);
(anchor.changed_since(&since.anchor), Vec::new())
} else {
repo_changes(declared, &since.anchors, lane_dir)
};
let changes = without_store(changes);
let governing: Vec<&Node> = a
.nodes_iter()
.filter(|n| !n.governs(a).is_empty())
.collect();
let verdicts = verdicts_of(&changes, &governing, a);
let (unclaimed, stale, live) = split_baskets(&verdicts);
if args.has("json") {
let one = |v: &Verdict, lane: &str| {
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<_>>(),
"lane": lane,
})
};
let here_name = lanes
.iter()
.find(|r| r.id == here)
.map(|r| r.name)
.unwrap_or(here);
let mut unclaimed_json: Vec<_> = unclaimed.iter().map(|v| one(v, here_name)).collect();
let mut stale_json: Vec<_> = stale.iter().map(|v| one(v, here_name)).collect();
let mut live_json: Vec<_> = live.iter().map(|v| one(v, here_name)).collect();
if args.opt("since").is_none() {
for lane in other_lanes(a, root, here, &governing, &lanes) {
let (u, s, l) = split_baskets(&lane.verdicts);
unclaimed_json.extend(u.iter().map(|v| one(v, lane.name)));
stale_json.extend(s.iter().map(|v| one(v, lane.name)));
live_json.extend(l.iter().map(|v| one(v, lane.name)));
}
}
return print_json(json!({
"since": since.alias(),
"since_ts": since.ts,
"anchor": since.anchor.short(),
"anchors": since.anchors,
"governing_nodes": governing.len(),
"changed": verdicts.len(),
"unclaimed": unclaimed_json,
"claimed_by_closed_work": stale_json,
"claimed_and_open": live_json,
}));
}
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!();
} else 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(());
} else {
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!();
}
if args.opt("since").is_some() {
outln!();
outln!(" Only this lane was measured: --since names one stop, and another lane's");
outln!(" stop points at commits this folder does not have. Run reconcile with no");
outln!(" --since to cover every lane.");
return Ok(());
}
print_other_lanes(&other_lanes(a, root, here, &governing, &lanes));
Ok(())
}
fn section(
title: &str,
action: &str,
rows: &[&Verdict],
note: impl Fn(&Verdict) -> String,
indent: &str,
) {
if rows.is_empty() {
return;
}
outln!();
outln!(
"{}",
format!(
"{indent} {} ({}){}{}",
title,
rows.len(),
" ".repeat(38usize.saturating_sub(title.len() + 4)),
action
)
.trim_end()
);
for v in rows.iter().take(SHOWN) {
outln!(
"{}",
format!(
"{indent} {:<44} {:>3} {}",
clip(&v.file, 44),
v.times,
note(v)
)
.trim_end()
);
}
if rows.len() > SHOWN {
outln!("{indent} + {} more --json", rows.len() - SHOWN);
}
}