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