use std::path::Path;
use anyhow::Result;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub sha: String,
pub subject: String,
pub made_by: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Diverged {
pub branch: String,
pub remote: String,
pub local_tip: String,
pub origin_tip: String,
pub local_only: Vec<Commit>,
pub origin_only: Vec<Commit>,
pub reason: String,
}
impl std::fmt::Display for Diverged {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"local `{}` ({}) and {}/{} ({}) have diverged and carry different changes: {}",
self.branch,
crate::run::short_of(&self.local_tip),
self.remote,
self.branch,
crate::run::short_of(&self.origin_tip),
self.reason
)
}
}
impl std::error::Error for Diverged {}
async fn remote_moved(repo: &Path, remote: &str, branch: &str, pinned: &str) -> bool {
git::fetch(repo, remote, branch).await.is_ok_and(|f| f.ok())
&& git::rev_parse(repo, &format!("{remote}/{branch}"))
.await
.is_ok_and(|now| now != pinned)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stale(pub String);
impl std::fmt::Display for Stale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Stale {}
fn describe(c: &Commit) -> String {
format!(
"- `{}` {} (made by: {})",
c.sha.chars().take(7).collect::<String>(),
c.subject,
c.made_by.as_deref().unwrap_or("unknown, outside magi")
)
}
impl Diverged {
pub fn summary(&self) -> String {
summary_for(&self.branch, &self.remote)
}
pub fn choices(&self) -> Vec<String> {
let tips = format!("[local={} origin={}]", self.local_tip, self.origin_tip);
vec![
format!(
"{PUSH_LOCAL} (drops the {} commit(s) only on {}) {tips}",
self.origin_only.len(),
self.remote
),
format!(
"{KEEP_REMOTE} (drops the {} commit(s) only local) {tips}",
self.local_only.len()
),
]
}
pub fn detail(&self) -> String {
let list = |cs: &[Commit]| {
if cs.is_empty() {
"- (none)".to_owned()
} else {
cs.iter().map(describe).collect::<Vec<_>>().join("\n")
}
};
format!(
"Local `{b}` ({l}) and {r}/{b} ({o}) diverged, and magi could not show \
that one is the other rebased.\n\nWhy: {why}\n\n\
Only on the local branch:\n{local}\n\nOnly on {r}:\n{origin}\n\n\
Pushing the local branch (with a lease on {o}) drops the {r} commits above; \
taking {r} drops the local ones. Nothing has been moved yet.",
b = self.branch,
r = self.remote,
l = self.local_tip.chars().take(7).collect::<String>(),
o = self.origin_tip.chars().take(7).collect::<String>(),
why = self.reason,
local = list(&self.local_only),
origin = list(&self.origin_only),
)
}
}
const PUSH_LOCAL: &str = "Push the local branch";
const KEEP_REMOTE: &str = "Keep the remote copy";
pub fn summary_for(branch: &str, remote: &str) -> String {
format!("`{branch}` differs between this machine and {remote}: which side should magi keep?")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
PushLocal,
KeepRemote,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub side: Side,
pub local: String,
pub origin: String,
}
impl Choice {
pub fn from_answer(answer: &str) -> Option<Self> {
let side = if answer.starts_with(PUSH_LOCAL) {
Side::PushLocal
} else if answer.starts_with(KEEP_REMOTE) {
Side::KeepRemote
} else {
return None;
};
let tips = answer.split_once("[local=")?.1.strip_suffix(']')?;
let (local, origin) = tips.split_once(" origin=")?;
Some(Self {
side,
local: local.to_owned(),
origin: origin.to_owned(),
})
}
}
pub async fn apply_choice(repo: &Path, remote: &str, branch: &str, choice: &Choice) -> Result<()> {
let fetched = git::fetch(repo, remote, branch).await?;
if !fetched.ok() {
anyhow::bail!("could not read {remote}/{branch}: {}", fetched.stderr);
}
let tracking = format!("{remote}/{branch}");
let origin = git::rev_parse(repo, &tracking).await?;
let local = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
if origin != choice.origin || local != choice.local {
return Err(Stale(format!(
"`{branch}` moved since the question was asked (local {}, {remote} {}); \
the answer no longer describes it",
crate::run::short_of(&local),
crate::run::short_of(&origin)
))
.into());
}
match choice.side {
Side::PushLocal => {
let out = git::push_pinned(repo, remote, branch, &choice.origin).await?;
if !out.ok() {
if remote_moved(repo, remote, branch, &choice.origin).await {
return Err(Stale(format!(
"{remote}/{branch} moved while the owner's answer was being applied; \
the answer no longer describes it"
))
.into());
}
anyhow::bail!("push of `{branch}` was refused: {}", out.stderr);
}
}
Side::KeepRemote => {
let out = git::git_raw(repo, &["branch", "-f", branch, &choice.origin]).await?;
if !out.ok() {
anyhow::bail!("could not move `{branch}` onto {tracking}: {}", out.stderr);
}
}
}
Ok(())
}
#[derive(Debug)]
pub enum Divergence {
Placeholder,
PureRebase,
Genuine(Box<Diverged>),
}
async fn range(repo: &Path, exclude: &str, head: &str) -> Result<Vec<String>> {
let out = git::git(
repo,
&["rev-list", "--parents", &format!("{exclude}..{head}")],
)
.await?;
Ok(out.lines().map(str::to_owned).collect())
}
async fn only_empty_non_merges(repo: &Path, exclude: &str, head: &str) -> Result<bool> {
for line in range(repo, exclude, head).await? {
let mut parts = line.split_whitespace();
let (Some(sha), Some(parent), None) = (parts.next(), parts.next(), parts.next()) else {
return Ok(false);
};
if git::tree_of(repo, sha).await? != git::tree_of(repo, parent).await? {
return Ok(false);
}
}
Ok(true)
}
fn made_by(sha: &str) -> Option<String> {
crate::run::try_home()?;
let short: String = sha.chars().take(7).collect();
crate::run::list_ids().into_iter().find_map(|id| {
let state = crate::run::RunState::load(&id).ok()?;
let names_sha = state.events.iter().any(|e| e.message.contains(&short));
names_sha.then(|| format!("run {}", crate::run::short_of(&id)))
})
}
async fn commits(repo: &Path, shas: &[String]) -> Vec<Commit> {
let mut out = Vec::new();
for sha in shas {
let subject = git::git(repo, &["log", "-1", "--format=%s", sha])
.await
.unwrap_or_default();
out.push(Commit {
sha: sha.clone(),
subject,
made_by: made_by(sha),
});
}
out
}
async fn merges(repo: &Path, exclude: &str, head: &str) -> Result<Vec<String>> {
let out = git::git(
repo,
&["rev-list", "--merges", &format!("{exclude}..{head}")],
)
.await?;
Ok(out.lines().map(str::to_owned).collect())
}
pub async fn origin_missing(repo: &Path, local: &str, origin: &str) -> Result<Vec<String>> {
let (mut missing, _) = git::cherry(repo, local, origin).await?;
for sha in merges(repo, local, origin).await? {
if !missing.contains(&sha) {
missing.push(sha);
}
}
Ok(missing)
}
pub async fn classify(
repo: &Path,
remote: &str,
branch: &str,
local: &str,
origin: &str,
base: &str,
) -> Result<Divergence> {
let mb = git::git(repo, &["merge-base", local, origin]).await.ok();
if let Some(mb) = &mb
&& only_empty_non_merges(repo, mb, local).await?
{
return Ok(Divergence::Placeholder);
}
let origin_missing = origin_missing(repo, local, origin).await?;
let (local_extra, _) = git::cherry(repo, origin, local).await?;
let mut local_missing = Vec::new();
for sha in local_extra {
if !git::is_ancestor(repo, &sha, base).await {
local_missing.push(sha);
}
}
for sha in merges(repo, origin, local).await? {
if !git::is_ancestor(repo, &sha, base).await && !local_missing.contains(&sha) {
local_missing.push(sha);
}
}
if origin_missing.is_empty() && local_missing.is_empty() {
return Ok(Divergence::PureRebase);
}
let mut reasons = Vec::new();
if !local_missing.is_empty() {
reasons.push(format!(
"{} local commit(s) have no equivalent patch on {remote}",
local_missing.len()
));
}
if !origin_missing.is_empty() {
reasons.push(format!(
"{} {remote} commit(s) have no equivalent patch locally",
origin_missing.len()
));
}
Ok(Divergence::Genuine(Box::new(Diverged {
branch: branch.to_owned(),
remote: remote.to_owned(),
local_tip: local.to_owned(),
origin_tip: origin.to_owned(),
local_only: commits(repo, &local_missing).await,
origin_only: commits(repo, &origin_missing).await,
reason: format!(
"{} (a merge, a squash or a rebase with conflict resolution changes the patch \
and cannot be matched)",
reasons.join("; ")
),
})))
}
pub async fn reconcile(
repo: &Path,
remote: &str,
branch: &str,
local: &str,
origin: &str,
base: &str,
) -> Result<Reconciliation> {
match classify(repo, remote, branch, local, origin, base).await? {
Divergence::Placeholder => Ok(Reconciliation::Placeholder),
Divergence::Genuine(d) => Ok(Reconciliation::Genuine(d)),
Divergence::PureRebase => {
let out = git::push_pinned(repo, remote, branch, origin).await?;
if !out.ok() {
if remote_moved(repo, remote, branch, origin).await {
return Err(Stale(format!(
"{remote}/{branch} moved while the rebase was being pushed; \
the next attempt looks at both tips afresh"
))
.into());
}
anyhow::bail!(
"`{branch}` is a rebase of {remote}/{branch}, but the push was refused \
(someone may have pushed since {}): {}",
crate::run::short_of(origin),
out.stderr
);
}
Ok(Reconciliation::Pushed)
}
}
}
#[derive(Debug)]
pub enum Reconciliation {
Pushed,
Placeholder,
Genuine(Box<Diverged>),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proc::Quiet as _;
use std::path::PathBuf;
fn sh(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.quiet()
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn commit(dir: &Path, file: &str, body: &str, msg: &str) {
std::fs::write(dir.join(file), body).unwrap();
sh(dir, &["add", "-A"]);
sh(dir, &["commit", "-q", "-m", msg]);
}
fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
std::fs::create_dir_all(&repo).unwrap();
sh(
dir.path(),
&["init", "-q", "--bare", "-b", "main", "origin.git"],
);
sh(&repo, &["init", "-q", "-b", "main"]);
sh(&repo, &["config", "user.name", "t"]);
sh(&repo, &["config", "user.email", "t@example.com"]);
sh(
&repo,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
commit(&repo, "a.txt", "one\n", "init");
sh(&repo, &["push", "-q", "origin", "main"]);
sh(&repo, &["checkout", "-q", "-b", "work"]);
commit(&repo, "w.txt", "work\n", "the fix");
sh(&repo, &["push", "-q", "origin", "work"]);
(dir, repo, origin)
}
fn advance_and_rebase(repo: &Path) {
sh(repo, &["checkout", "-q", "main"]);
commit(repo, "b.txt", "two\n", "base moves");
sh(repo, &["push", "-q", "origin", "main"]);
sh(repo, &["checkout", "-q", "work"]);
sh(repo, &["rebase", "-q", "main"]);
}
#[tokio::test]
async fn a_merge_only_on_the_remote_is_never_a_pure_rebase() {
let (g, repo, origin) = fixture();
advance_and_rebase(&repo);
let other = g.path().join("other");
sh(
g.path(),
&["clone", "-q", origin.to_str().unwrap(), "other"],
);
sh(&other, &["config", "user.name", "o"]);
sh(&other, &["config", "user.email", "o@example.com"]);
sh(&other, &["checkout", "-q", "-b", "side", "origin/work~1"]);
commit(&other, "side.txt", "s\n", "side");
sh(&other, &["checkout", "-q", "work"]);
sh(&other, &["merge", "-q", "--no-ff", "-m", "merge", "side"]);
sh(&other, &["push", "-q", "origin", "work"]);
sh(&repo, &["fetch", "-q", "origin"]);
let local = sh(&repo, &["rev-parse", "work"]);
let remote = sh(&repo, &["rev-parse", "origin/work"]);
let base = sh(&repo, &["rev-parse", "main"]);
let r = classify(&repo, "origin", "work", &local, &remote, &base)
.await
.unwrap();
assert!(matches!(r, Divergence::Genuine(_)), "{r:?}");
}
#[tokio::test]
async fn a_rebase_is_pushed_under_a_lease_pinned_to_the_tip_seen() {
let (_g, repo, origin) = fixture();
let seen = sh(&repo, &["rev-parse", "work"]);
advance_and_rebase(&repo);
let out = git::push_pinned(&repo, "origin", "work", &seen)
.await
.unwrap();
assert!(out.ok(), "{}", out.stderr);
assert_eq!(
sh(&origin, &["rev-parse", "work"]),
sh(&repo, &["rev-parse", "work"])
);
}
#[tokio::test]
async fn a_push_from_someone_else_fails_the_pinned_lease_and_is_kept() {
let (g, repo, origin) = fixture();
let seen = sh(&repo, &["rev-parse", "work"]);
advance_and_rebase(&repo);
let other = g.path().join("other");
sh(
g.path(),
&["clone", "-q", origin.to_str().unwrap(), "other"],
);
sh(&other, &["config", "user.name", "o"]);
sh(&other, &["config", "user.email", "o@example.com"]);
sh(&other, &["checkout", "-q", "work"]);
commit(&other, "theirs.txt", "x\n", "a person's commit");
sh(&other, &["push", "-q", "origin", "work"]);
let theirs = sh(&origin, &["rev-parse", "work"]);
sh(&repo, &["fetch", "-q", "origin"]);
let out = git::push_pinned(&repo, "origin", "work", &seen)
.await
.unwrap();
assert!(!out.ok(), "the lease must refuse");
assert_eq!(sh(&origin, &["rev-parse", "work"]), theirs);
}
#[tokio::test]
async fn a_pure_rebase_divergence_reconciles_and_pushes_local() {
let (_g, repo, origin) = fixture();
let origin_tip = sh(&repo, &["rev-parse", "work"]);
advance_and_rebase(&repo);
let local = sh(&repo, &["rev-parse", "work"]);
assert_ne!(local, origin_tip);
let base = sh(&repo, &["rev-parse", "main"]);
let r = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
.await
.unwrap();
assert!(matches!(r, Reconciliation::Pushed), "{r:?}");
assert_eq!(sh(&origin, &["rev-parse", "work"]), local);
}
#[tokio::test]
async fn a_refused_lease_after_a_pure_rebase_is_stale() {
let (g, repo, origin) = fixture();
let origin_tip = sh(&repo, &["rev-parse", "work"]);
advance_and_rebase(&repo);
let local = sh(&repo, &["rev-parse", "work"]);
let base = sh(&repo, &["rev-parse", "main"]);
let other = g.path().join("other");
sh(
g.path(),
&["clone", "-q", origin.to_str().unwrap(), "other"],
);
sh(&other, &["config", "user.name", "o"]);
sh(&other, &["config", "user.email", "o@example.com"]);
sh(&other, &["checkout", "-q", "work"]);
commit(&other, "theirs.txt", "x\n", "theirs: pushed meanwhile");
sh(&other, &["push", "-q", "origin", "work"]);
let err = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
.await
.unwrap_err();
assert!(err.downcast_ref::<Stale>().is_some(), "{err:#}");
}
#[tokio::test]
async fn a_genuine_divergence_is_refused_and_names_both_sides() {
let (g, repo, origin) = fixture();
let other = g.path().join("other");
sh(
g.path(),
&["clone", "-q", origin.to_str().unwrap(), "other"],
);
sh(&other, &["config", "user.name", "o"]);
sh(&other, &["config", "user.email", "o@example.com"]);
sh(&other, &["checkout", "-q", "work"]);
commit(&other, "theirs.txt", "x\n", "theirs: another change");
sh(&other, &["push", "-q", "origin", "work"]);
sh(&repo, &["fetch", "-q", "origin"]);
commit(&repo, "mine.txt", "y\n", "mine: a different change");
let local = sh(&repo, &["rev-parse", "work"]);
let origin_tip = sh(&repo, &["rev-parse", "origin/work"]);
let base = sh(&repo, &["rev-parse", "main"]);
let r = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
.await
.unwrap();
let Reconciliation::Genuine(d) = r else {
panic!("expected Genuine, got {r:?}");
};
assert_eq!(d.local_only.len(), 1);
assert_eq!(d.origin_only.len(), 1);
assert!(d.detail().contains("mine: a different change"));
assert!(d.detail().contains("theirs: another change"));
assert_eq!(sh(&repo, &["rev-parse", "work"]), local, "local untouched");
assert_eq!(
sh(&origin, &["rev-parse", "work"]),
origin_tip,
"origin untouched"
);
}
#[tokio::test]
async fn the_owners_choice_is_applied_only_to_the_tips_shown() {
assert_eq!(Choice::from_answer("nonsense"), None);
let (_g, repo, origin) = fixture();
let old_origin = sh(&repo, &["rev-parse", "work"]);
advance_and_rebase(&repo);
let local = sh(&repo, &["rev-parse", "work"]);
let answer = |side: &str, o: &str| format!("{side} (drops) [local={local} origin={o}]");
let stale = answer(PUSH_LOCAL, &"0".repeat(40));
let c = Choice::from_answer(&stale).unwrap();
assert!(apply_choice(&repo, "origin", "work", &c).await.is_err());
assert_eq!(sh(&origin, &["rev-parse", "work"]), old_origin);
let c = Choice::from_answer(&answer(PUSH_LOCAL, &old_origin)).unwrap();
apply_choice(&repo, "origin", "work", &c).await.unwrap();
assert_eq!(sh(&origin, &["rev-parse", "work"]), local);
sh(&repo, &["checkout", "-q", "main"]);
sh(&repo, &["branch", "-f", "work", &old_origin]);
let c = Choice::from_answer(&format!(
"{KEEP_REMOTE} (drops) [local={old_origin} origin={local}]"
))
.unwrap();
apply_choice(&repo, "origin", "work", &c).await.unwrap();
assert_eq!(sh(&repo, &["rev-parse", "work"]), local);
}
#[tokio::test]
async fn an_empty_placeholder_is_recognised_but_a_revert_history_is_not() {
let (_g, repo, _origin) = fixture();
sh(&repo, &["branch", "ph", "work~1"]);
sh(&repo, &["checkout", "-q", "ph"]);
sh(
&repo,
&["commit", "-q", "--allow-empty", "-m", "placeholder"],
);
let ph = sh(&repo, &["rev-parse", "ph"]);
let work = sh(&repo, &["rev-parse", "work"]);
let base = sh(&repo, &["rev-parse", "main"]);
let r = classify(&repo, "origin", "ph", &ph, &work, &base)
.await
.unwrap();
assert!(matches!(r, Divergence::Placeholder), "{r:?}");
sh(&repo, &["checkout", "-q", "-b", "churn", "work~1"]);
commit(&repo, "t.txt", "t\n", "add");
sh(&repo, &["rm", "-q", "t.txt"]);
sh(&repo, &["commit", "-q", "-m", "remove"]);
let churn = sh(&repo, &["rev-parse", "churn"]);
let r = classify(&repo, "origin", "churn", &churn, &work, &base)
.await
.unwrap();
assert!(matches!(r, Divergence::Genuine(_)), "{r:?}");
}
}