use anyhow::Result;
use crate::config::Config;
use crate::repo::Repo;
use crate::report;
use crate::sync::short;
pub struct Local {
pub name: String,
pub commits: Vec<String>,
pub conflicted: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
Unchanged,
MoveToRemote { rule: &'static str, commit: String },
KeepUnpushed { diverged: bool },
Forget,
KeepNew,
}
pub fn decide(
local: &Local,
remote: Option<&str>,
is_ancestor: impl Fn(&str, &str) -> bool,
on_remote: impl Fn(&str) -> bool,
) -> Decision {
match remote {
Some(theirs) => {
if local.conflicted {
return Decision::MoveToRemote {
rule: "2",
commit: theirs.to_string(),
};
}
let ours = local.commits[0].as_str();
if ours == theirs {
Decision::Unchanged
} else if is_ancestor(ours, theirs) {
Decision::MoveToRemote {
rule: "1",
commit: theirs.to_string(),
}
} else if !is_ancestor(theirs, ours) && on_remote(ours) {
Decision::MoveToRemote {
rule: "3b",
commit: theirs.to_string(),
}
} else {
Decision::KeepUnpushed {
diverged: !is_ancestor(theirs, ours),
}
}
}
None if !local.commits.is_empty() && local.commits.iter().all(|c| on_remote(c)) => {
Decision::Forget
}
None => Decision::KeepNew,
}
}
fn read(repo: &Repo, config: &Config) -> Result<Vec<(Local, Option<String>)>> {
let fork = &config.fork;
let mut args: Vec<String> = vec!["bookmark".into(), "list".into(), "--color=never".into()];
args.push("--all-remotes".into());
args.push(format!("exact:{:?}", fork.branch));
args.extend(fork.mirror_branch.iter().map(|m| format!("exact:{m:?}")));
args.extend(fork.series_prefixes.iter().map(|p| format!("glob:{p}*")));
args.push(format!("glob:{}*", fork.glue_prefix));
args.extend([
"-T".into(),
r#"name ++ "\t" ++ if(remote, remote, "") ++ "\t" ++ present ++ "\t" ++ conflict ++ "\t" ++ added_targets.map(|c| c.commit_id()).join(",") ++ "\n""#.into(),
]);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let out = repo.jj(&args)?;
let mut locals: Vec<Local> = Vec::new();
let mut remotes = std::collections::BTreeMap::new();
for line in out.lines() {
let f: Vec<&str> = line.split('\t').collect();
if f.len() != 5 || f[2] != "true" {
continue;
}
if f[1].is_empty() {
locals.push(Local {
name: f[0].to_string(),
commits: f[4].split(',').map(str::to_string).collect(),
conflicted: f[3] == "true",
});
} else if f[1] == fork.remote && !f[4].contains(',') {
remotes.insert(f[0].to_string(), f[4].to_string());
}
}
Ok(locals
.into_iter()
.map(|l| {
let remote = remotes.get(&l.name).cloned();
(l, remote)
})
.collect())
}
pub fn reconcile(repo: &Repo, config: &Config) -> Result<()> {
let remote = &config.fork.remote;
for (local, theirs) in read(repo, config)? {
let decision = decide(
&local,
theirs.as_deref(),
|a, b| repo.is_ancestor(a, b).unwrap_or(false),
|c| {
repo.revs(&format!(
"{c} & ::remote_bookmarks(remote=exact:{remote:?})"
))
.map(|ids| !ids.is_empty())
.unwrap_or(false)
},
);
let name = &local.name;
match decision {
Decision::Unchanged | Decision::KeepNew => {}
Decision::MoveToRemote { rule, commit } => {
repo.jj(&[
"bookmark",
"set",
name,
"-r",
&commit,
"--allow-backwards",
"--quiet",
])?;
let why = match rule {
"1" => "local was behind",
"2" => "local was conflicted vs",
_ => "local diverged but was already published; remote rewrote",
};
report(&format!(
"reconciled: {name} -> {} (rule {rule}: {why} {name}@{remote})",
short(&commit)
));
}
Decision::KeepUnpushed { diverged } => {
let how = if diverged {
"diverged from"
} else {
"ahead of"
};
report(&format!(
"reconciled: {name} kept (rule 3: unpushed local work, {how} {name}@{remote})"
));
}
Decision::Forget => {
repo.jj(&["bookmark", "forget", name, "--quiet"])?;
report(&format!(
"reconciled: {name} forgotten (rule 4: deleted on {remote} after being published)"
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ancestor(a: &str, b: &str) -> bool {
let up: &[&str] = match b {
"b" => &["a", "b"],
"c" => &["a", "b", "c"],
"d" => &["a", "b", "d"],
_ => &[],
};
a == b || up.contains(&a)
}
fn one(commit: &str) -> Local {
Local {
name: "patch/x".into(),
commits: vec![commit.into()],
conflicted: false,
}
}
fn published(c: &str) -> bool {
matches!(c, "a" | "b" | "c")
}
fn ancestor_rewritten(a: &str, b: &str) -> bool {
ancestor(a, b) || (b == "e" && matches!(a, "a" | "b"))
}
#[test]
fn rule_1_behind_moves_to_remote() {
assert_eq!(
decide(&one("a"), Some("c"), ancestor, published),
Decision::MoveToRemote {
rule: "1",
commit: "c".into()
}
);
}
#[test]
fn equal_is_unchanged() {
assert_eq!(
decide(&one("c"), Some("c"), ancestor, published),
Decision::Unchanged
);
}
#[test]
fn rule_2_conflicted_takes_remote_even_if_one_side_is_ahead() {
let local = Local {
name: "glue/a+b".into(),
commits: vec!["c".into(), "d".into()],
conflicted: true,
};
assert_eq!(
decide(&local, Some("b"), ancestor, published),
Decision::MoveToRemote {
rule: "2",
commit: "b".into()
}
);
}
#[test]
fn rule_3_ahead_and_diverged_are_kept() {
assert_eq!(
decide(&one("c"), Some("b"), ancestor, published),
Decision::KeepUnpushed { diverged: false }
);
assert_eq!(
decide(&one("d"), Some("c"), ancestor, published),
Decision::KeepUnpushed { diverged: true }
);
}
#[test]
fn rule_3b_diverged_but_already_published_takes_remote() {
assert_eq!(
decide(&one("d"), Some("e"), ancestor_rewritten, |c| c == "d"),
Decision::MoveToRemote {
rule: "3b",
commit: "e".into()
}
);
assert_eq!(
decide(&one("d"), Some("e"), ancestor_rewritten, |_| false),
Decision::KeepUnpushed { diverged: true }
);
}
#[test]
fn rule_4_published_then_deleted_is_forgotten() {
assert_eq!(
decide(&one("b"), None, ancestor, published),
Decision::Forget
);
}
#[test]
fn rule_5_unpublished_without_remote_is_kept() {
assert_eq!(
decide(&one("d"), None, ancestor, published),
Decision::KeepNew
);
let mixed = Local {
name: "x".into(),
commits: vec!["a".into(), "d".into()],
conflicted: true,
};
assert_eq!(decide(&mixed, None, ancestor, published), Decision::KeepNew);
}
}