use anyhow::{Context, Result};
use jj_lib::backend::CommitId;
use jj_lib::git::REMOTE_NAME_FOR_LOCAL_GIT_REPO;
use jj_lib::object_id::ObjectId as _;
use jj_lib::op_store::{RefTarget, RemoteRef};
use jj_lib::ref_name::{RefName, RemoteName};
use jj_lib::repo::Repo as _;
use jj_lib::transaction::Transaction;
use crate::config::Config;
use crate::native;
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) -> Result<bool>,
on_remote: impl Fn(&str) -> Result<bool>,
) -> Result<Decision> {
Ok(match remote {
Some(theirs) => {
if local.conflicted {
return Ok(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 => {
let mut published = !local.commits.is_empty();
for commit in &local.commits {
published &= on_remote(commit)?;
}
if published {
Decision::Forget
} else {
Decision::KeepNew
}
}
})
}
enum Remote {
Absent,
At(String),
Conflicted,
}
fn read(repo: &dyn jj_lib::repo::Repo, config: &Config) -> Vec<(Local, Remote)> {
let fork = &config.fork;
let in_namespace = |name: &str| {
name == fork.branch
|| fork.mirror_branch.as_deref() == Some(name)
|| name.starts_with(&fork.glue_prefix)
|| fork
.series_prefixes
.iter()
.any(|p| name.starts_with(p.as_str()))
};
let view = repo.view();
let remote = RemoteName::new(&fork.remote);
view.local_bookmarks()
.filter(|(name, target)| in_namespace(name.as_str()) && target.is_present())
.map(|(name, target)| {
let local = Local {
name: name.as_str().to_string(),
commits: target.added_ids().map(|id| id.hex()).collect(),
conflicted: target.has_conflict(),
};
let theirs = &view
.get_remote_bookmark(name.to_remote_symbol(remote))
.target;
let theirs = if theirs.has_conflict() {
Remote::Conflicted
} else if let Some(id) = theirs.as_normal() {
Remote::At(id.hex())
} else {
Remote::Absent
};
(local, theirs)
})
.collect()
}
pub fn reconcile(tx: &mut Transaction, config: &Config) -> Result<()> {
let remote = config.fork.remote.clone();
let remote_name = RemoteName::new(&remote);
let base = tx.base_repo().clone();
let repo = base.as_ref();
let remote_heads: Vec<CommitId> = repo
.view()
.remote_bookmarks(remote_name)
.flat_map(|(_, r)| r.target.added_ids())
.cloned()
.collect();
let id = |hex: &str| native::parse_id(hex);
let is_ancestor = |a: &str, b: &str| native::is_ancestor(repo, &id(a)?, &id(b)?);
let on_remote = |c: &str| -> Result<bool> {
let c = id(c)?;
for head in &remote_heads {
if native::is_ancestor(repo, &c, head)? {
return Ok(true);
}
}
Ok(false)
};
for (local, theirs) in read(repo, config) {
let name = &local.name;
let theirs = match theirs {
Remote::Conflicted => {
report(&format!(
"reconciled: {name} kept ({name}@{remote} is conflicted; settle it with: jj git fetch)"
));
continue;
}
Remote::At(commit) => Some(commit),
Remote::Absent => None,
};
let decision = decide(&local, theirs.as_deref(), is_ancestor, on_remote)
.with_context(|| format!("cannot reconcile {name}"))?;
let ref_name = RefName::new(name);
match decision {
Decision::Unchanged | Decision::KeepNew => {}
Decision::MoveToRemote { rule, commit } => {
tx.repo_mut()
.set_local_bookmark_target(ref_name, RefTarget::normal(id(&commit)?));
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 => {
forget(tx, ref_name);
report(&format!(
"reconciled: {name} forgotten (rule 4: deleted on {remote} after being published)"
));
}
}
}
Ok(())
}
fn forget(tx: &mut Transaction, name: &RefName) {
tx.repo_mut()
.set_local_bookmark_target(name, RefTarget::absent());
let remotes: Vec<(jj_lib::ref_name::RemoteNameBuf, RemoteRef)> = tx
.repo()
.view()
.remote_views()
.filter(|(remote, _)| *remote != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
.map(|(remote, _)| {
let r = tx
.repo()
.view()
.get_remote_bookmark(name.to_remote_symbol(remote));
(remote.to_owned(), r.clone())
})
.filter(|(_, r)| r.target.is_present() || r.is_tracked())
.collect();
for (remote, _) in remotes {
tx.repo_mut()
.untrack_remote_bookmark(name.to_remote_symbol(&remote));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn decide_ok(
local: &Local,
remote: Option<&str>,
is_ancestor: impl Fn(&str, &str) -> bool,
on_remote: impl Fn(&str) -> bool,
) -> Decision {
decide(
local,
remote,
|a, b| Ok(is_ancestor(a, b)),
|c| Ok(on_remote(c)),
)
.unwrap()
}
#[test]
fn graph_errors_are_not_absence() {
let err = decide(
&one("b"),
None,
|_, _| Ok(false),
|_| anyhow::bail!("index unreadable"),
);
assert!(
err.is_err(),
"an unreadable graph must not forget a bookmark"
);
let err = decide(
&one("a"),
Some("c"),
|_, _| anyhow::bail!("no"),
|_| Ok(true),
);
assert!(err.is_err());
}
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_ok(&one("a"), Some("c"), ancestor, published),
Decision::MoveToRemote {
rule: "1",
commit: "c".into()
}
);
}
#[test]
fn equal_is_unchanged() {
assert_eq!(
decide_ok(&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_ok(&local, Some("b"), ancestor, published),
Decision::MoveToRemote {
rule: "2",
commit: "b".into()
}
);
}
#[test]
fn rule_3_ahead_and_diverged_are_kept() {
assert_eq!(
decide_ok(&one("c"), Some("b"), ancestor, published),
Decision::KeepUnpushed { diverged: false }
);
assert_eq!(
decide_ok(&one("d"), Some("c"), ancestor, published),
Decision::KeepUnpushed { diverged: true }
);
}
#[test]
fn rule_3b_diverged_but_already_published_takes_remote() {
assert_eq!(
decide_ok(&one("d"), Some("e"), ancestor_rewritten, |c| c == "d"),
Decision::MoveToRemote {
rule: "3b",
commit: "e".into()
}
);
assert_eq!(
decide_ok(&one("d"), Some("e"), ancestor_rewritten, |_| false),
Decision::KeepUnpushed { diverged: true }
);
}
#[test]
fn rule_4_published_then_deleted_is_forgotten() {
assert_eq!(
decide_ok(&one("b"), None, ancestor, published),
Decision::Forget
);
}
#[test]
fn rule_5_unpublished_without_remote_is_kept() {
assert_eq!(
decide_ok(&one("d"), None, ancestor, published),
Decision::KeepNew
);
let mixed = Local {
name: "x".into(),
commits: vec!["a".into(), "d".into()],
conflicted: true,
};
assert_eq!(
decide_ok(&mixed, None, ancestor, published),
Decision::KeepNew
);
}
}