1pub const NODE: &str = "review";
20
21pub const SEAT: &str = "sync";
23
24use std::path::Path;
25
26use anyhow::Result;
27
28use crate::git;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Commit {
33 pub sha: String,
35 pub subject: String,
37 pub made_by: Option<String>,
40}
41
42#[derive(Debug, Clone)]
44pub struct Diverged {
45 pub branch: String,
47 pub remote: String,
49 pub local_tip: String,
51 pub origin_tip: String,
53 pub local_only: Vec<Commit>,
55 pub origin_only: Vec<Commit>,
57 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
78async 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#[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 pub fn summary(&self) -> String {
115 summary_for(&self.branch, &self.remote)
116 }
117
118 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 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
164pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum Side {
172 PushLocal,
174 KeepRemote,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Choice {
181 pub side: Side,
183 pub local: String,
185 pub origin: String,
187}
188
189impl Choice {
190 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
210pub 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 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#[derive(Debug)]
260pub enum Divergence {
261 Placeholder,
264 PureRebase,
266 Genuine(Box<Diverged>),
268}
269
270async 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 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
294fn 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
322async 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
332pub 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
345pub 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 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 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
413pub 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#[derive(Debug)]
454pub enum Reconciliation {
455 Pushed,
457 Placeholder,
459 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 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 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 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 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 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 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}