1use std::path::Path;
18
19use anyhow::Result;
20
21use crate::git;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Commit {
26 pub sha: String,
28 pub subject: String,
30 pub made_by: Option<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Diverged {
38 pub branch: String,
40 pub remote: String,
42 pub local_tip: String,
44 pub origin_tip: String,
46 pub local_only: Vec<Commit>,
48 pub origin_only: Vec<Commit>,
50 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
71async 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#[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 pub fn summary(&self) -> String {
108 summary_for(&self.branch, &self.remote)
109 }
110
111 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 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
157pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Side {
165 PushLocal,
167 KeepRemote,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Choice {
174 pub side: Side,
176 pub local: String,
178 pub origin: String,
180}
181
182impl Choice {
183 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
203pub 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 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#[derive(Debug)]
253pub enum Divergence {
254 Placeholder,
257 PureRebase,
259 Genuine(Box<Diverged>),
261}
262
263async 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 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
287fn 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
315async 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
325pub 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
338pub 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 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 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
406pub 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#[derive(Debug)]
447pub enum Reconciliation {
448 Pushed,
450 Placeholder,
452 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 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 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 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 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 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 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}