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