Skip to main content

magi/
reconcile.rs

1//! Deciding what a local branch and its remote twin mean when both moved.
2//!
3//! A run's branch can be rewritten on one side only: magi rebases it locally
4//! (a retry, a fixer) and, until this module, never pushed the result, so the
5//! next attempt found `refs/heads/<b>` and `<remote>/<b>` diverged - the same
6//! change on two different bases. Picking the remote side there silently
7//! discards the rebase; picking the local one discards whatever a person
8//! pushed. [`classify`] therefore only lets magi settle the question when it
9//! can *prove* nothing is lost, and otherwise hands both sides to the operator
10//! as a [`Diverged`] that says what each one carries.
11//!
12//! The proof is patch-id, via `git cherry`: two commits that apply the same
13//! diff are the same change on different bases. A merge, a squash or a rebase
14//! that needed conflict resolution changes the patch-id and is therefore
15//! [`Divergence::Genuine`] - the safe side of every doubt.
16
17/// Node the daemon records on the divergence question (`daemon`'s `Diverged`
18/// arm). There is no agent behind it, so `crate::deputy::destructive` names it.
19pub const NODE: &str = "review";
20
21/// Seat recorded on the divergence question, with [`NODE`].
22pub const SEAT: &str = "sync";
23
24use std::path::Path;
25
26use anyhow::Result;
27
28use crate::git;
29
30/// One commit on one side of a divergence.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Commit {
33    /// Full object id.
34    pub sha: String,
35    /// First line of the message.
36    pub subject: String,
37    /// Which run made it, when a run's record names it; `None` reads as
38    /// "outside magi" as far as this machine can tell.
39    pub made_by: Option<String>,
40}
41
42/// A divergence magi could not settle by itself.
43#[derive(Debug, Clone)]
44pub struct Diverged {
45    /// Branch name.
46    pub branch: String,
47    /// Remote name.
48    pub remote: String,
49    /// Local tip sha.
50    pub local_tip: String,
51    /// Remote tip sha.
52    pub origin_tip: String,
53    /// Commits only the local branch has that no origin commit repeats.
54    pub local_only: Vec<Commit>,
55    /// Commits only the remote has that no local commit repeats.
56    pub origin_only: Vec<Commit>,
57    /// Why this was not a plain rebase.
58    pub reason: String,
59}
60
61impl std::fmt::Display for Diverged {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "local `{}` ({}) and {}/{} ({}) have diverged and carry different changes: {}",
66            self.branch,
67            crate::run::short_of(&self.local_tip),
68            self.remote,
69            self.branch,
70            crate::run::short_of(&self.origin_tip),
71            self.reason
72        )
73    }
74}
75
76impl std::error::Error for Diverged {}
77
78/// Whether `remote/branch` is now somewhere other than `pinned`, i.e. a refused
79/// lease was refused because someone pushed, not because the push is broken.
80async fn remote_moved(repo: &Path, remote: &str, branch: &str, pinned: &str) -> bool {
81    git::fetch(repo, remote, branch).await.is_ok_and(|f| f.ok())
82        && git::rev_parse(repo, &format!("{remote}/{branch}"))
83            .await
84            .is_ok_and(|now| now != pinned)
85}
86
87/// The owner's answer described tips that no longer exist: nothing was done.
88///
89/// A distinct type so the queue loop can keep pointing the task at the branch
90/// and let the next attempt ask again against the tips as they are now.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Stale(pub String);
93
94impl std::fmt::Display for Stale {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100impl std::error::Error for Stale {}
101
102fn describe(c: &Commit) -> String {
103    format!(
104        "- `{}` {} (made by: {})",
105        c.sha.chars().take(7).collect::<String>(),
106        c.subject,
107        c.made_by.as_deref().unwrap_or("unknown, outside magi")
108    )
109}
110
111impl Diverged {
112    /// The one line the operator sees first. Stable for a branch and remote:
113    /// [`Choice::from_answer`] callers match the recorded answer to it.
114    pub fn summary(&self) -> String {
115        summary_for(&self.branch, &self.remote)
116    }
117
118    /// The two answers a person may give. Each carries the tips the question
119    /// showed, so applying one can refuse if either side has moved since.
120    pub fn choices(&self) -> Vec<String> {
121        let tips = format!("[local={} origin={}]", self.local_tip, self.origin_tip);
122        vec![
123            format!(
124                "{PUSH_LOCAL} (drops the {} commit(s) only on {}) {tips}",
125                self.origin_only.len(),
126                self.remote
127            ),
128            format!(
129                "{KEEP_REMOTE} (drops the {} commit(s) only local) {tips}",
130                self.local_only.len()
131            ),
132        ]
133    }
134
135    /// Both sides, what each holds, and what choosing the other loses.
136    pub fn detail(&self) -> String {
137        let list = |cs: &[Commit]| {
138            if cs.is_empty() {
139                "- (none)".to_owned()
140            } else {
141                cs.iter().map(describe).collect::<Vec<_>>().join("\n")
142            }
143        };
144        format!(
145            "Local `{b}` ({l}) and {r}/{b} ({o}) diverged, and magi could not show \
146             that one is the other rebased.\n\nWhy: {why}\n\n\
147             Only on the local branch:\n{local}\n\nOnly on {r}:\n{origin}\n\n\
148             Pushing the local branch (with a lease on {o}) drops the {r} commits above; \
149             taking {r} drops the local ones. Nothing has been moved yet.",
150            b = self.branch,
151            r = self.remote,
152            l = self.local_tip.chars().take(7).collect::<String>(),
153            o = self.origin_tip.chars().take(7).collect::<String>(),
154            why = self.reason,
155            local = list(&self.local_only),
156            origin = list(&self.origin_only),
157        )
158    }
159}
160
161const PUSH_LOCAL: &str = "Push the local branch";
162const KEEP_REMOTE: &str = "Keep the remote copy";
163
164/// The question summary for `branch` on `remote`.
165pub fn summary_for(branch: &str, remote: &str) -> String {
166    format!("`{branch}` differs between this machine and {remote}: which side should magi keep?")
167}
168
169/// What the owner chose.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum Side {
172    /// Overwrite the remote with the local branch.
173    PushLocal,
174    /// Move the local branch onto the remote's.
175    KeepRemote,
176}
177
178/// The owner's answer to a divergence question, with the tips they were shown.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Choice {
181    /// Which side to keep.
182    pub side: Side,
183    /// Local tip the question displayed.
184    pub local: String,
185    /// Remote tip the question displayed.
186    pub origin: String,
187}
188
189impl Choice {
190    /// Read an answer as one of the offered choices; anything else (or an
191    /// answer without the tips) is `None`.
192    pub fn from_answer(answer: &str) -> Option<Self> {
193        let side = if answer.starts_with(PUSH_LOCAL) {
194            Side::PushLocal
195        } else if answer.starts_with(KEEP_REMOTE) {
196            Side::KeepRemote
197        } else {
198            return None;
199        };
200        let tips = answer.split_once("[local=")?.1.strip_suffix(']')?;
201        let (local, origin) = tips.split_once(" origin=")?;
202        Some(Self {
203            side,
204            local: local.to_owned(),
205            origin: origin.to_owned(),
206        })
207    }
208}
209
210/// Carry out the owner's decision, but only against the tips they were shown.
211///
212/// If the local branch or the remote has moved since the question was asked,
213/// what the owner approved is not what would happen, so this refuses and the
214/// next attempt asks afresh. The push pins its lease to the shown remote tip.
215pub async fn apply_choice(repo: &Path, remote: &str, branch: &str, choice: &Choice) -> Result<()> {
216    let fetched = git::fetch(repo, remote, branch).await?;
217    if !fetched.ok() {
218        anyhow::bail!("could not read {remote}/{branch}: {}", fetched.stderr);
219    }
220    let tracking = format!("{remote}/{branch}");
221    let origin = git::rev_parse(repo, &tracking).await?;
222    let local = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
223    if origin != choice.origin || local != choice.local {
224        return Err(Stale(format!(
225            "`{branch}` moved since the question was asked (local {}, {remote} {}); \
226             the answer no longer describes it",
227            crate::run::short_of(&local),
228            crate::run::short_of(&origin)
229        ))
230        .into());
231    }
232    match choice.side {
233        Side::PushLocal => {
234            let out = git::push_pinned(repo, remote, branch, &choice.origin).await?;
235            if !out.ok() {
236                // A refused lease means the remote moved after it was read:
237                // the answer is stale, not the push broken.
238                if remote_moved(repo, remote, branch, &choice.origin).await {
239                    return Err(Stale(format!(
240                        "{remote}/{branch} moved while the owner's answer was being applied; \
241                         the answer no longer describes it"
242                    ))
243                    .into());
244                }
245                anyhow::bail!("push of `{branch}` was refused: {}", out.stderr);
246            }
247        }
248        Side::KeepRemote => {
249            let out = git::git_raw(repo, &["branch", "-f", branch, &choice.origin]).await?;
250            if !out.ok() {
251                anyhow::bail!("could not move `{branch}` onto {tracking}: {}", out.stderr);
252            }
253        }
254    }
255    Ok(())
256}
257
258/// What a divergence turned out to be.
259#[derive(Debug)]
260pub enum Divergence {
261    /// Every commit only local has is empty: a placeholder the remote's work
262    /// replaced. Moving the branch onto the remote loses nothing.
263    Placeholder,
264    /// Local is the remote's change rebased onto a newer base.
265    PureRebase,
266    /// Different changes on each side.
267    Genuine(Box<Diverged>),
268}
269
270/// Commits reachable from `head` and not from `exclude`, newest first.
271async fn range(repo: &Path, exclude: &str, head: &str) -> Result<Vec<String>> {
272    let out = git::git(
273        repo,
274        &["rev-list", "--parents", &format!("{exclude}..{head}")],
275    )
276    .await?;
277    Ok(out.lines().map(str::to_owned).collect())
278}
279
280async fn only_empty_non_merges(repo: &Path, exclude: &str, head: &str) -> Result<bool> {
281    for line in range(repo, exclude, head).await? {
282        let mut parts = line.split_whitespace();
283        let (Some(sha), Some(parent), None) = (parts.next(), parts.next(), parts.next()) else {
284            // A root commit or a merge is content nobody proved redundant.
285            return Ok(false);
286        };
287        if git::tree_of(repo, sha).await? != git::tree_of(repo, parent).await? {
288            return Ok(false);
289        }
290    }
291    Ok(true)
292}
293
294/// Look up which run made `sha`: a run that recorded it in an event.
295/// Best-effort and read-only. A run merely owning the branch name is not
296/// evidence: a person may have pushed onto that branch since.
297fn made_by(sha: &str) -> Option<String> {
298    crate::run::try_home()?;
299    let short: String = sha.chars().take(7).collect();
300    crate::run::list_ids().into_iter().find_map(|id| {
301        let state = crate::run::RunState::load(&id).ok()?;
302        let names_sha = state.events.iter().any(|e| e.message.contains(&short));
303        names_sha.then(|| format!("run {}", crate::run::short_of(&id)))
304    })
305}
306
307async fn commits(repo: &Path, shas: &[String]) -> Vec<Commit> {
308    let mut out = Vec::new();
309    for sha in shas {
310        let subject = git::git(repo, &["log", "-1", "--format=%s", sha])
311            .await
312            .unwrap_or_default();
313        out.push(Commit {
314            sha: sha.clone(),
315            subject,
316            made_by: made_by(sha),
317        });
318    }
319    out
320}
321
322/// Merge commits reachable from `head` and not from `exclude`.
323async fn merges(repo: &Path, exclude: &str, head: &str) -> Result<Vec<String>> {
324    let out = git::git(
325        repo,
326        &["rev-list", "--merges", &format!("{exclude}..{head}")],
327    )
328    .await?;
329    Ok(out.lines().map(str::to_owned).collect())
330}
331
332/// What `origin` has that no `local` commit repeats: patches `git cherry`
333/// finds unmatched, plus every merge, which `git cherry` cannot see and
334/// which may carry conflict resolutions or other content of its own.
335pub async fn origin_missing(repo: &Path, local: &str, origin: &str) -> Result<Vec<String>> {
336    let (mut missing, _) = git::cherry(repo, local, origin).await?;
337    for sha in merges(repo, local, origin).await? {
338        if !missing.contains(&sha) {
339            missing.push(sha);
340        }
341    }
342    Ok(missing)
343}
344
345/// Classify a divergence between `local` and `origin` (both shas).
346///
347/// `base` is the base branch's tip: commits reachable from it are what a
348/// rebase pulled in, not local work.
349pub async fn classify(
350    repo: &Path,
351    remote: &str,
352    branch: &str,
353    local: &str,
354    origin: &str,
355    base: &str,
356) -> Result<Divergence> {
357    let mb = git::git(repo, &["merge-base", local, origin]).await.ok();
358    if let Some(mb) = &mb
359        && only_empty_non_merges(repo, mb, local).await?
360    {
361        return Ok(Divergence::Placeholder);
362    }
363
364    // What origin has that no local commit repeats, and what local has that
365    // no origin commit repeats, ignoring commits the base already holds.
366    let origin_missing = origin_missing(repo, local, origin).await?;
367    let (local_extra, _) = git::cherry(repo, origin, local).await?;
368    let mut local_missing = Vec::new();
369    for sha in local_extra {
370        if !git::is_ancestor(repo, &sha, base).await {
371            local_missing.push(sha);
372        }
373    }
374    // `git cherry` skips merge commits, so a local merge is content nobody
375    // matched: it is never proven redundant.
376    for sha in merges(repo, origin, local).await? {
377        if !git::is_ancestor(repo, &sha, base).await && !local_missing.contains(&sha) {
378            local_missing.push(sha);
379        }
380    }
381    if origin_missing.is_empty() && local_missing.is_empty() {
382        return Ok(Divergence::PureRebase);
383    }
384
385    let mut reasons = Vec::new();
386    if !local_missing.is_empty() {
387        reasons.push(format!(
388            "{} local commit(s) have no equivalent patch on {remote}",
389            local_missing.len()
390        ));
391    }
392    if !origin_missing.is_empty() {
393        reasons.push(format!(
394            "{} {remote} commit(s) have no equivalent patch locally",
395            origin_missing.len()
396        ));
397    }
398    Ok(Divergence::Genuine(Box::new(Diverged {
399        branch: branch.to_owned(),
400        remote: remote.to_owned(),
401        local_tip: local.to_owned(),
402        origin_tip: origin.to_owned(),
403        local_only: commits(repo, &local_missing).await,
404        origin_only: commits(repo, &origin_missing).await,
405        reason: format!(
406            "{} (a merge, a squash or a rebase with conflict resolution changes the patch \
407             and cannot be matched)",
408            reasons.join("; ")
409        ),
410    })))
411}
412
413/// If `local` is provably `origin`'s change rebased, push it with a lease
414/// pinned to `origin` and say so; `Ok(false)` when it is not.
415///
416/// The one place a divergence is settled without asking. A refused lease because
417/// someone pushed since `origin` was read is [`Stale`]: the next attempt
418/// classifies both tips afresh. Nothing is ever forced.
419pub async fn reconcile(
420    repo: &Path,
421    remote: &str,
422    branch: &str,
423    local: &str,
424    origin: &str,
425    base: &str,
426) -> Result<Reconciliation> {
427    match classify(repo, remote, branch, local, origin, base).await? {
428        Divergence::Placeholder => Ok(Reconciliation::Placeholder),
429        Divergence::Genuine(d) => Ok(Reconciliation::Genuine(d)),
430        Divergence::PureRebase => {
431            let out = git::push_pinned(repo, remote, branch, origin).await?;
432            if !out.ok() {
433                if remote_moved(repo, remote, branch, origin).await {
434                    return Err(Stale(format!(
435                        "{remote}/{branch} moved while the rebase was being pushed; \
436                         the next attempt looks at both tips afresh"
437                    ))
438                    .into());
439                }
440                anyhow::bail!(
441                    "`{branch}` is a rebase of {remote}/{branch}, but the push was refused \
442                     (someone may have pushed since {}): {}",
443                    crate::run::short_of(origin),
444                    out.stderr
445                );
446            }
447            Ok(Reconciliation::Pushed)
448        }
449    }
450}
451
452/// What [`reconcile`] did.
453#[derive(Debug)]
454pub enum Reconciliation {
455    /// Local was pushed over origin; keep local.
456    Pushed,
457    /// Local adds nothing; the caller may move it onto origin.
458    Placeholder,
459    /// Ask the operator.
460    Genuine(Box<Diverged>),
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::proc::Quiet as _;
467    use std::path::PathBuf;
468
469    fn sh(dir: &Path, args: &[&str]) -> String {
470        let out = std::process::Command::new("git")
471            .args(args)
472            .current_dir(dir)
473            .quiet()
474            .output()
475            .unwrap();
476        assert!(
477            out.status.success(),
478            "git {args:?}: {}",
479            String::from_utf8_lossy(&out.stderr)
480        );
481        String::from_utf8_lossy(&out.stdout).trim().to_owned()
482    }
483
484    fn commit(dir: &Path, file: &str, body: &str, msg: &str) {
485        std::fs::write(dir.join(file), body).unwrap();
486        sh(dir, &["add", "-A"]);
487        sh(dir, &["commit", "-q", "-m", msg]);
488    }
489
490    /// `repo` with a bare `origin`, `main` pushed, and `work` branched and
491    /// pushed with one commit.
492    fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
493        let dir = tempfile::tempdir().unwrap();
494        let repo = dir.path().join("repo");
495        let origin = dir.path().join("origin.git");
496        std::fs::create_dir_all(&repo).unwrap();
497        sh(
498            dir.path(),
499            &["init", "-q", "--bare", "-b", "main", "origin.git"],
500        );
501        sh(&repo, &["init", "-q", "-b", "main"]);
502        sh(&repo, &["config", "user.name", "t"]);
503        sh(&repo, &["config", "user.email", "t@example.com"]);
504        sh(
505            &repo,
506            &["remote", "add", "origin", origin.to_str().unwrap()],
507        );
508        commit(&repo, "a.txt", "one\n", "init");
509        sh(&repo, &["push", "-q", "origin", "main"]);
510        sh(&repo, &["checkout", "-q", "-b", "work"]);
511        commit(&repo, "w.txt", "work\n", "the fix");
512        sh(&repo, &["push", "-q", "origin", "work"]);
513        (dir, repo, origin)
514    }
515
516    /// Move `main` on origin forward, then rebase `work` onto it locally.
517    fn advance_and_rebase(repo: &Path) {
518        sh(repo, &["checkout", "-q", "main"]);
519        commit(repo, "b.txt", "two\n", "base moves");
520        sh(repo, &["push", "-q", "origin", "main"]);
521        sh(repo, &["checkout", "-q", "work"]);
522        sh(repo, &["rebase", "-q", "main"]);
523    }
524
525    #[tokio::test]
526    async fn a_merge_only_on_the_remote_is_never_a_pure_rebase() {
527        let (g, repo, origin) = fixture();
528        advance_and_rebase(&repo);
529
530        let other = g.path().join("other");
531        sh(
532            g.path(),
533            &["clone", "-q", origin.to_str().unwrap(), "other"],
534        );
535        sh(&other, &["config", "user.name", "o"]);
536        sh(&other, &["config", "user.email", "o@example.com"]);
537        sh(&other, &["checkout", "-q", "-b", "side", "origin/work~1"]);
538        commit(&other, "side.txt", "s\n", "side");
539        sh(&other, &["checkout", "-q", "work"]);
540        sh(&other, &["merge", "-q", "--no-ff", "-m", "merge", "side"]);
541        sh(&other, &["push", "-q", "origin", "work"]);
542        sh(&repo, &["fetch", "-q", "origin"]);
543
544        let local = sh(&repo, &["rev-parse", "work"]);
545        let remote = sh(&repo, &["rev-parse", "origin/work"]);
546        let base = sh(&repo, &["rev-parse", "main"]);
547        let r = classify(&repo, "origin", "work", &local, &remote, &base)
548            .await
549            .unwrap();
550        assert!(matches!(r, Divergence::Genuine(_)), "{r:?}");
551    }
552
553    #[tokio::test]
554    async fn a_rebase_is_pushed_under_a_lease_pinned_to_the_tip_seen() {
555        let (_g, repo, origin) = fixture();
556        let seen = sh(&repo, &["rev-parse", "work"]);
557        advance_and_rebase(&repo);
558        let out = git::push_pinned(&repo, "origin", "work", &seen)
559            .await
560            .unwrap();
561        assert!(out.ok(), "{}", out.stderr);
562        assert_eq!(
563            sh(&origin, &["rev-parse", "work"]),
564            sh(&repo, &["rev-parse", "work"])
565        );
566    }
567
568    #[tokio::test]
569    async fn a_push_from_someone_else_fails_the_pinned_lease_and_is_kept() {
570        let (g, repo, origin) = fixture();
571        let seen = sh(&repo, &["rev-parse", "work"]);
572        advance_and_rebase(&repo);
573
574        let other = g.path().join("other");
575        sh(
576            g.path(),
577            &["clone", "-q", origin.to_str().unwrap(), "other"],
578        );
579        sh(&other, &["config", "user.name", "o"]);
580        sh(&other, &["config", "user.email", "o@example.com"]);
581        sh(&other, &["checkout", "-q", "work"]);
582        commit(&other, "theirs.txt", "x\n", "a person's commit");
583        sh(&other, &["push", "-q", "origin", "work"]);
584        let theirs = sh(&origin, &["rev-parse", "work"]);
585
586        // A fetch in between must not move the lease: it is pinned.
587        sh(&repo, &["fetch", "-q", "origin"]);
588        let out = git::push_pinned(&repo, "origin", "work", &seen)
589            .await
590            .unwrap();
591        assert!(!out.ok(), "the lease must refuse");
592        assert_eq!(sh(&origin, &["rev-parse", "work"]), theirs);
593    }
594
595    #[tokio::test]
596    async fn a_pure_rebase_divergence_reconciles_and_pushes_local() {
597        let (_g, repo, origin) = fixture();
598        let origin_tip = sh(&repo, &["rev-parse", "work"]);
599        advance_and_rebase(&repo);
600        let local = sh(&repo, &["rev-parse", "work"]);
601        assert_ne!(local, origin_tip);
602        let base = sh(&repo, &["rev-parse", "main"]);
603
604        let r = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
605            .await
606            .unwrap();
607        assert!(matches!(r, Reconciliation::Pushed), "{r:?}");
608        assert_eq!(sh(&origin, &["rev-parse", "work"]), local);
609    }
610
611    #[tokio::test]
612    async fn a_refused_lease_after_a_pure_rebase_is_stale() {
613        let (g, repo, origin) = fixture();
614        let origin_tip = sh(&repo, &["rev-parse", "work"]);
615        advance_and_rebase(&repo);
616        let local = sh(&repo, &["rev-parse", "work"]);
617        let base = sh(&repo, &["rev-parse", "main"]);
618        let other = g.path().join("other");
619        sh(
620            g.path(),
621            &["clone", "-q", origin.to_str().unwrap(), "other"],
622        );
623        sh(&other, &["config", "user.name", "o"]);
624        sh(&other, &["config", "user.email", "o@example.com"]);
625        sh(&other, &["checkout", "-q", "work"]);
626        commit(&other, "theirs.txt", "x\n", "theirs: pushed meanwhile");
627        sh(&other, &["push", "-q", "origin", "work"]);
628
629        let err = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
630            .await
631            .unwrap_err();
632        assert!(err.downcast_ref::<Stale>().is_some(), "{err:#}");
633    }
634
635    #[tokio::test]
636    async fn a_genuine_divergence_is_refused_and_names_both_sides() {
637        let (g, repo, origin) = fixture();
638        let other = g.path().join("other");
639        sh(
640            g.path(),
641            &["clone", "-q", origin.to_str().unwrap(), "other"],
642        );
643        sh(&other, &["config", "user.name", "o"]);
644        sh(&other, &["config", "user.email", "o@example.com"]);
645        sh(&other, &["checkout", "-q", "work"]);
646        commit(&other, "theirs.txt", "x\n", "theirs: another change");
647        sh(&other, &["push", "-q", "origin", "work"]);
648        sh(&repo, &["fetch", "-q", "origin"]);
649        commit(&repo, "mine.txt", "y\n", "mine: a different change");
650        let local = sh(&repo, &["rev-parse", "work"]);
651        let origin_tip = sh(&repo, &["rev-parse", "origin/work"]);
652        let base = sh(&repo, &["rev-parse", "main"]);
653
654        let r = reconcile(&repo, "origin", "work", &local, &origin_tip, &base)
655            .await
656            .unwrap();
657        let Reconciliation::Genuine(d) = r else {
658            panic!("expected Genuine, got {r:?}");
659        };
660        assert_eq!(d.local_only.len(), 1);
661        assert_eq!(d.origin_only.len(), 1);
662        assert!(d.detail().contains("mine: a different change"));
663        assert!(d.detail().contains("theirs: another change"));
664        assert_eq!(sh(&repo, &["rev-parse", "work"]), local, "local untouched");
665        assert_eq!(
666            sh(&origin, &["rev-parse", "work"]),
667            origin_tip,
668            "origin untouched"
669        );
670    }
671
672    #[tokio::test]
673    async fn the_owners_choice_is_applied_only_to_the_tips_shown() {
674        assert_eq!(Choice::from_answer("nonsense"), None);
675        let (_g, repo, origin) = fixture();
676        let old_origin = sh(&repo, &["rev-parse", "work"]);
677        advance_and_rebase(&repo);
678        let local = sh(&repo, &["rev-parse", "work"]);
679        let answer = |side: &str, o: &str| format!("{side} (drops) [local={local} origin={o}]");
680
681        // A remote that moved since the question refuses.
682        let stale = answer(PUSH_LOCAL, &"0".repeat(40));
683        let c = Choice::from_answer(&stale).unwrap();
684        assert!(apply_choice(&repo, "origin", "work", &c).await.is_err());
685        assert_eq!(sh(&origin, &["rev-parse", "work"]), old_origin);
686
687        let c = Choice::from_answer(&answer(PUSH_LOCAL, &old_origin)).unwrap();
688        apply_choice(&repo, "origin", "work", &c).await.unwrap();
689        assert_eq!(sh(&origin, &["rev-parse", "work"]), local);
690
691        // Keep the remote: local is moved onto the (now identical) tip.
692        sh(&repo, &["checkout", "-q", "main"]);
693        sh(&repo, &["branch", "-f", "work", &old_origin]);
694        let c = Choice::from_answer(&format!(
695            "{KEEP_REMOTE} (drops) [local={old_origin} origin={local}]"
696        ))
697        .unwrap();
698        apply_choice(&repo, "origin", "work", &c).await.unwrap();
699        assert_eq!(sh(&repo, &["rev-parse", "work"]), local);
700    }
701
702    #[tokio::test]
703    async fn an_empty_placeholder_is_recognised_but_a_revert_history_is_not() {
704        let (_g, repo, _origin) = fixture();
705        sh(&repo, &["branch", "ph", "work~1"]);
706        sh(&repo, &["checkout", "-q", "ph"]);
707        sh(
708            &repo,
709            &["commit", "-q", "--allow-empty", "-m", "placeholder"],
710        );
711        let ph = sh(&repo, &["rev-parse", "ph"]);
712        let work = sh(&repo, &["rev-parse", "work"]);
713        let base = sh(&repo, &["rev-parse", "main"]);
714        let r = classify(&repo, "origin", "ph", &ph, &work, &base)
715            .await
716            .unwrap();
717        assert!(matches!(r, Divergence::Placeholder), "{r:?}");
718
719        // Adds a file then removes it: final diff is empty, history is not.
720        sh(&repo, &["checkout", "-q", "-b", "churn", "work~1"]);
721        commit(&repo, "t.txt", "t\n", "add");
722        sh(&repo, &["rm", "-q", "t.txt"]);
723        sh(&repo, &["commit", "-q", "-m", "remove"]);
724        let churn = sh(&repo, &["rev-parse", "churn"]);
725        let r = classify(&repo, "origin", "churn", &churn, &work, &base)
726            .await
727            .unwrap();
728        assert!(matches!(r, Divergence::Genuine(_)), "{r:?}");
729    }
730}