1pub const NODE: &str = "review";
21
22pub const SEAT: &str = "sync";
24
25use std::path::Path;
26
27use anyhow::Result;
28
29use crate::git;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Commit {
34 pub sha: String,
36 pub subject: String,
38 pub made_by: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct Diverged {
46 pub branch: String,
48 pub remote: String,
50 pub local_tip: String,
52 pub origin_tip: String,
54 pub local_only: Vec<Commit>,
56 pub origin_only: Vec<Commit>,
58 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
79async 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#[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 pub fn summary(&self) -> String {
116 summary_for(&self.branch, &self.remote)
117 }
118
119 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 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
165pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Side {
173 PushLocal,
175 KeepRemote,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Choice {
182 pub side: Side,
184 pub local: String,
186 pub origin: String,
188}
189
190impl Choice {
191 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
211pub 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 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#[derive(Debug)]
261pub enum Divergence {
262 Placeholder,
265 PureRebase,
267 Genuine(Box<Diverged>),
269}
270
271async 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 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
295fn 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
323async 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
333pub 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
346pub 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 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 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
414pub 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#[derive(Debug)]
455pub enum Reconciliation {
456 Pushed,
458 Placeholder,
460 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 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 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 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 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 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 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}