1use std::path::PathBuf;
4
5mod diff;
7mod error;
8mod parse;
9pub mod recover;
10pub mod remote_ref;
11pub mod remove;
12mod repository;
13mod url;
14
15#[cfg(test)]
16mod test;
17
18use crate::sync::Semaphore;
31use std::sync::LazyLock;
32static HEAVY_OPS_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));
33
34pub const NULL_OID: &str = "0000000000000000000000000000000000000000";
36
37pub(crate) use diff::DiffStats;
39pub use diff::{LineDiff, parse_numstat_line};
40pub use error::{
41 FailedCommand,
43 GitError,
45 HookErrorWithHint,
47 RefContext,
49 RefType,
50 SwitchSuggestionCtx,
52 WorktrunkError,
53 add_hook_skip_hint,
55 exit_code,
56 interrupt_exit_code,
57};
58pub use parse::{parse_porcelain_z, parse_untracked_files};
59pub use recover::{current_or_recover, cwd_removed_hint};
60pub use remove::{
61 BranchDeletionMode, BranchDeletionOutcome, BranchDeletionResult, RemovalOutput, RemoveOptions,
62 delete_branch_if_safe, remove_worktree_with_cleanup, stage_worktree_removal,
63};
64pub use repository::{Branch, Repository, ResolvedWorktree, WorkingTree, set_base_path};
65pub use url::GitRemoteUrl;
66pub use url::parse_owner_repo;
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)]
84#[serde(rename_all = "kebab-case")]
85#[strum(serialize_all = "kebab-case")]
86pub enum IntegrationReason {
87 SameCommit,
93
94 Ancestor,
98
99 NoAddedChanges,
104
105 TreesMatch,
110
111 MergeAddsNothing,
118
119 PatchIdMatch,
130}
131
132impl IntegrationReason {
133 pub fn description(&self) -> &'static str {
138 match self {
139 Self::SameCommit => "same commit as",
140 Self::Ancestor => "ancestor of",
141 Self::NoAddedChanges => "no added changes on",
142 Self::TreesMatch => "tree matches",
143 Self::MergeAddsNothing => "all changes in",
144 Self::PatchIdMatch => "all changes in",
145 }
146 }
147
148 pub fn symbol(&self) -> &'static str {
153 match self {
154 Self::SameCommit => "_",
155 _ => "⊂",
156 }
157 }
158}
159
160#[derive(Debug, Default)]
169pub struct IntegrationSignals {
170 pub is_same_commit: Option<bool>,
171 pub is_ancestor: Option<bool>,
172 pub has_added_changes: Option<bool>,
173 pub trees_match: Option<bool>,
174 pub would_merge_add: Option<bool>,
175 pub is_patch_id_match: Option<bool>,
176}
177
178pub fn check_integration(signals: &IntegrationSignals) -> Option<IntegrationReason> {
186 if signals.is_same_commit == Some(true) {
188 return Some(IntegrationReason::SameCommit);
189 }
190
191 if signals.is_ancestor == Some(true) {
193 return Some(IntegrationReason::Ancestor);
194 }
195
196 if signals.has_added_changes == Some(false) {
198 return Some(IntegrationReason::NoAddedChanges);
199 }
200
201 if signals.trees_match == Some(true) {
203 return Some(IntegrationReason::TreesMatch);
204 }
205
206 if signals.would_merge_add == Some(false) {
208 return Some(IntegrationReason::MergeAddsNothing);
209 }
210
211 if signals.is_patch_id_match == Some(true) {
213 return Some(IntegrationReason::PatchIdMatch);
214 }
215
216 None
217}
218
219#[allow(clippy::field_reassign_with_default)] pub fn compute_integration_lazy(
229 repo: &Repository,
230 branch: &str,
231 target: &str,
232) -> anyhow::Result<IntegrationSignals> {
233 let mut signals = IntegrationSignals::default();
234
235 signals.is_same_commit = Some(repo.same_commit(branch, target)?);
237 if signals.is_same_commit == Some(true) {
238 return Ok(signals);
239 }
240
241 signals.is_ancestor = Some(repo.is_ancestor(branch, target)?);
243 if signals.is_ancestor == Some(true) {
244 return Ok(signals);
245 }
246
247 signals.has_added_changes = Some(repo.has_added_changes(branch, target)?);
249 if signals.has_added_changes == Some(false) {
250 return Ok(signals);
251 }
252
253 signals.trees_match = Some(repo.trees_match(branch, target)?);
255 if signals.trees_match == Some(true) {
256 return Ok(signals);
257 }
258
259 let probe = repo.merge_integration_probe(branch, target)?;
261 signals.would_merge_add = Some(probe.would_merge_add);
262 if !probe.would_merge_add {
263 return Ok(signals);
264 }
265 signals.is_patch_id_match = Some(probe.is_patch_id_match);
266
267 Ok(signals)
268}
269
270#[derive(Debug, Clone, PartialEq)]
272pub enum BranchCategory {
273 Worktree,
275 Local,
277 Remote(Vec<String>),
279}
280
281#[derive(Debug, Clone)]
283pub struct CompletionBranch {
284 pub name: String,
286 pub timestamp: i64,
288 pub category: BranchCategory,
290}
291
292pub(crate) use parse::DefaultBranchName;
294
295use crate::shell_exec::Cmd;
296
297pub fn branch_tracks_ref(
311 repo_root: &std::path::Path,
312 branch: &str,
313 expected_ref: &str,
314 expected_remote: Option<&str>,
315) -> Option<bool> {
316 let config_key = format!("branch.{}.merge", branch);
317 let output = Cmd::new("git")
318 .args(["config", "--get", &config_key])
319 .current_dir(repo_root)
320 .run()
321 .ok()?;
322
323 if !output.status.success() {
324 let branch_exists = Cmd::new("git")
327 .args([
328 "show-ref",
329 "--verify",
330 "--quiet",
331 &format!("refs/heads/{}", branch),
332 ])
333 .current_dir(repo_root)
334 .run()
335 .map(|o| o.status.success())
336 .unwrap_or(false);
337
338 return if branch_exists { Some(false) } else { None };
339 }
340
341 let merge_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
342 if merge_ref != expected_ref {
343 return Some(false);
344 }
345
346 let Some(expected_remote) = expected_remote else {
347 return Some(true);
348 };
349
350 let remote_key = format!("branch.{}.remote", branch);
351 let remote_output = Cmd::new("git")
352 .args(["config", "--get", &remote_key])
353 .current_dir(repo_root)
354 .run()
355 .ok()?;
356
357 if !remote_output.status.success() {
358 return Some(false);
359 }
360
361 let remote = String::from_utf8_lossy(&remote_output.stdout)
362 .trim()
363 .to_string();
364 Some(remote == expected_remote)
365}
366
367#[derive(
372 Debug,
373 Clone,
374 Copy,
375 PartialEq,
376 Eq,
377 serde::Serialize,
378 serde::Deserialize,
379 strum::Display,
380 strum::EnumString,
381 strum::EnumIter,
382)]
383#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
384#[serde(rename_all = "kebab-case")]
385#[strum(serialize_all = "kebab-case")]
386pub enum HookType {
387 PreSwitch,
388 PostSwitch,
389 PreStart,
390 PostStart,
391 PreCommit,
392 PostCommit,
393 PreMerge,
394 PostMerge,
395 PreRemove,
396 PostRemove,
397}
398
399#[derive(Debug, Clone)]
416pub struct BranchRef {
417 pub branch: Option<String>,
420 pub commit_sha: String,
422 pub worktree_path: Option<PathBuf>,
425 pub is_remote: bool,
431}
432
433impl BranchRef {
434 pub fn local_branch(branch: &str, commit_sha: &str) -> Self {
436 Self {
437 branch: Some(branch.to_string()),
438 commit_sha: commit_sha.to_string(),
439 worktree_path: None,
440 is_remote: false,
441 }
442 }
443
444 pub fn remote_branch(branch: &str, commit_sha: &str) -> Self {
449 Self {
450 branch: Some(branch.to_string()),
451 commit_sha: commit_sha.to_string(),
452 worktree_path: None,
453 is_remote: true,
454 }
455 }
456
457 pub fn working_tree<'a>(&self, repo: &'a Repository) -> Option<WorkingTree<'a>> {
462 self.worktree_path
463 .as_ref()
464 .map(|p| repo.worktree_at(p.clone()))
465 }
466
467 pub fn has_worktree(&self) -> bool {
469 self.worktree_path.is_some()
470 }
471}
472
473impl From<&WorktreeInfo> for BranchRef {
474 fn from(wt: &WorktreeInfo) -> Self {
475 Self {
476 branch: wt.branch.clone(),
477 commit_sha: wt.head.clone(),
478 worktree_path: Some(wt.path.clone()),
479 is_remote: false, }
481 }
482}
483
484#[derive(Debug, Clone, PartialEq, serde::Serialize)]
490pub struct WorktreeInfo {
491 pub path: PathBuf,
492 pub head: String,
493 pub branch: Option<String>,
494 pub bare: bool,
495 pub detached: bool,
496 pub locked: Option<String>,
497 pub prunable: Option<String>,
498}
499
500pub fn path_dir_name(path: &std::path::Path) -> &str {
505 path.file_name()
506 .and_then(|n| n.to_str())
507 .unwrap_or("(unknown)")
508}
509
510impl WorktreeInfo {
511 pub fn is_prunable(&self) -> bool {
516 self.prunable.is_some()
517 }
518
519 pub fn has_commits(&self) -> bool {
523 self.head != NULL_OID
524 }
525
526 pub fn dir_name(&self) -> &str {
532 path_dir_name(&self.path)
533 }
534}
535
536fn read_rebase_branch(worktree_path: &PathBuf) -> Option<String> {
547 let repo = Repository::current().ok()?;
548 let git_dir = repo.worktree_at(worktree_path).git_dir().ok()?;
549
550 for rebase_dir in ["rebase-merge", "rebase-apply"] {
552 let head_name_path = git_dir.join(rebase_dir).join("head-name");
553 if let Ok(content) = std::fs::read_to_string(head_name_path) {
554 let branch_ref = content.trim();
555 let branch = branch_ref
557 .strip_prefix("refs/heads/")
558 .unwrap_or(branch_ref)
559 .to_string();
560 return Some(branch);
561 }
562 }
563
564 None
565}
566
567pub(crate) fn finalize_worktree(mut wt: WorktreeInfo) -> WorktreeInfo {
569 if wt.detached
571 && wt.branch.is_none()
572 && let Some(branch) = read_rebase_branch(&wt.path)
573 {
574 wt.branch = Some(branch);
575 }
576 wt
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_check_integration() {
585 let cases = [
588 (
589 (
590 Some(true),
591 Some(false),
592 Some(true),
593 Some(false),
594 Some(true),
595 None,
596 ),
597 Some(IntegrationReason::SameCommit),
598 ),
599 (
600 (
601 Some(false),
602 Some(true),
603 Some(true),
604 Some(false),
605 Some(true),
606 None,
607 ),
608 Some(IntegrationReason::Ancestor),
609 ),
610 (
611 (
612 Some(false),
613 Some(false),
614 Some(false),
615 Some(false),
616 Some(true),
617 None,
618 ),
619 Some(IntegrationReason::NoAddedChanges),
620 ),
621 (
622 (
623 Some(false),
624 Some(false),
625 Some(true),
626 Some(true),
627 Some(true),
628 None,
629 ),
630 Some(IntegrationReason::TreesMatch),
631 ),
632 (
633 (
634 Some(false),
635 Some(false),
636 Some(true),
637 Some(false),
638 Some(false),
639 None,
640 ),
641 Some(IntegrationReason::MergeAddsNothing),
642 ),
643 (
644 (
646 Some(false),
647 Some(false),
648 Some(true),
649 Some(false),
650 Some(true),
651 Some(true),
652 ),
653 Some(IntegrationReason::PatchIdMatch),
654 ),
655 (
656 (
658 Some(false),
659 Some(false),
660 Some(true),
661 Some(false),
662 Some(true),
663 Some(false),
664 ),
665 None,
666 ),
667 (
668 (
669 Some(true),
670 Some(true),
671 Some(false),
672 Some(true),
673 Some(false),
674 None,
675 ),
676 Some(IntegrationReason::SameCommit),
677 ), ((None, None, None, None, None, None), None),
680 (
681 (None, Some(true), Some(false), Some(true), Some(false), None),
682 Some(IntegrationReason::Ancestor),
683 ),
684 ];
685 for ((same, ancestor, added, trees, merge, patch_id), expected) in cases {
686 let signals = IntegrationSignals {
687 is_same_commit: same,
688 is_ancestor: ancestor,
689 has_added_changes: added,
690 trees_match: trees,
691 would_merge_add: merge,
692 is_patch_id_match: patch_id,
693 };
694 assert_eq!(
695 check_integration(&signals),
696 expected,
697 "case: {same:?},{ancestor:?},{added:?},{trees:?},{merge:?},{patch_id:?}"
698 );
699 }
700 }
701
702 #[test]
703 fn test_integration_reason_description() {
704 assert_eq!(
705 IntegrationReason::SameCommit.description(),
706 "same commit as"
707 );
708 assert_eq!(IntegrationReason::Ancestor.description(), "ancestor of");
709 assert_eq!(
710 IntegrationReason::NoAddedChanges.description(),
711 "no added changes on"
712 );
713 assert_eq!(IntegrationReason::TreesMatch.description(), "tree matches");
714 assert_eq!(
715 IntegrationReason::MergeAddsNothing.description(),
716 "all changes in"
717 );
718 assert_eq!(
719 IntegrationReason::PatchIdMatch.description(),
720 "all changes in"
721 );
722 }
723
724 #[test]
725 fn test_path_dir_name() {
726 assert_eq!(
727 path_dir_name(&PathBuf::from("/home/user/repo.feature")),
728 "repo.feature"
729 );
730 assert_eq!(path_dir_name(&PathBuf::from("/")), "(unknown)");
731 assert!(!path_dir_name(&PathBuf::from("/home/user/repo/")).is_empty());
732
733 let wt = WorktreeInfo {
735 path: PathBuf::from("/repos/myrepo.feature"),
736 head: "abc123".into(),
737 branch: Some("feature".into()),
738 bare: false,
739 detached: false,
740 locked: None,
741 prunable: None,
742 };
743 assert_eq!(wt.dir_name(), "myrepo.feature");
744 }
745
746 #[test]
747 fn test_hook_type_display() {
748 use strum::IntoEnumIterator;
749
750 for hook in HookType::iter() {
752 let display = format!("{hook}");
753 assert!(
754 display.chars().all(|c| c.is_lowercase() || c == '-'),
755 "Hook {hook:?} should be kebab-case, got: {display}"
756 );
757 }
758 }
759
760 #[test]
761 fn test_branch_ref_from_worktree_info() {
762 let wt = WorktreeInfo {
763 path: PathBuf::from("/repo.feature"),
764 head: "abc123".into(),
765 branch: Some("feature".into()),
766 bare: false,
767 detached: false,
768 locked: None,
769 prunable: None,
770 };
771
772 let branch_ref = BranchRef::from(&wt);
773
774 assert_eq!(branch_ref.branch, Some("feature".to_string()));
775 assert_eq!(branch_ref.commit_sha, "abc123");
776 assert_eq!(
777 branch_ref.worktree_path,
778 Some(PathBuf::from("/repo.feature"))
779 );
780 assert!(branch_ref.has_worktree());
781 assert!(!branch_ref.is_remote); }
783
784 #[test]
785 fn test_branch_ref_local_branch() {
786 let branch_ref = BranchRef::local_branch("feature", "abc123");
787
788 assert_eq!(branch_ref.branch, Some("feature".to_string()));
789 assert_eq!(branch_ref.commit_sha, "abc123");
790 assert_eq!(branch_ref.worktree_path, None);
791 assert!(!branch_ref.has_worktree());
792 assert!(!branch_ref.is_remote);
793 }
794
795 #[test]
796 fn test_branch_ref_remote_branch() {
797 let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
798
799 assert_eq!(branch_ref.branch, Some("origin/feature".to_string()));
800 assert_eq!(branch_ref.commit_sha, "abc123");
801 assert_eq!(branch_ref.worktree_path, None);
802 assert!(!branch_ref.has_worktree());
803 assert!(branch_ref.is_remote);
804 }
805
806 #[test]
807 fn test_branch_tracks_ref_matching() {
808 let test = crate::testing::TestRepo::with_initial_commit();
809 let repo = test.path();
810
811 crate::shell_exec::Cmd::new("git")
813 .args(["branch", "pr-branch"])
814 .current_dir(repo)
815 .run()
816 .unwrap();
817 crate::shell_exec::Cmd::new("git")
818 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
819 .current_dir(repo)
820 .run()
821 .unwrap();
822 crate::shell_exec::Cmd::new("git")
823 .args(["config", "branch.pr-branch.remote", "origin"])
824 .current_dir(repo)
825 .run()
826 .unwrap();
827
828 assert_eq!(
829 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", None),
830 Some(true),
831 );
832 assert_eq!(
833 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
834 Some(true),
835 );
836 }
837
838 #[test]
839 fn test_branch_tracks_ref_different_ref() {
840 let test = crate::testing::TestRepo::with_initial_commit();
841 let repo = test.path();
842
843 crate::shell_exec::Cmd::new("git")
844 .args(["branch", "pr-branch"])
845 .current_dir(repo)
846 .run()
847 .unwrap();
848 crate::shell_exec::Cmd::new("git")
849 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
850 .current_dir(repo)
851 .run()
852 .unwrap();
853
854 assert_eq!(
856 branch_tracks_ref(repo, "pr-branch", "refs/pull/999/head", None),
857 Some(false),
858 );
859 }
860
861 #[test]
862 fn test_branch_tracks_ref_wrong_remote() {
863 let test = crate::testing::TestRepo::with_initial_commit();
864 let repo = test.path();
865
866 crate::shell_exec::Cmd::new("git")
867 .args(["branch", "pr-branch"])
868 .current_dir(repo)
869 .run()
870 .unwrap();
871 crate::shell_exec::Cmd::new("git")
872 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
873 .current_dir(repo)
874 .run()
875 .unwrap();
876 crate::shell_exec::Cmd::new("git")
877 .args(["config", "branch.pr-branch.remote", "fork"])
878 .current_dir(repo)
879 .run()
880 .unwrap();
881
882 assert_eq!(
883 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
884 Some(false),
885 );
886 }
887
888 #[test]
889 fn test_branch_tracks_ref_no_tracking_config() {
890 let test = crate::testing::TestRepo::with_initial_commit();
891 let repo = test.path();
892
893 crate::shell_exec::Cmd::new("git")
895 .args(["branch", "local-only"])
896 .current_dir(repo)
897 .run()
898 .unwrap();
899
900 assert_eq!(
902 branch_tracks_ref(repo, "local-only", "refs/pull/1/head", None),
903 Some(false),
904 );
905 }
906
907 #[test]
908 fn test_branch_tracks_ref_nonexistent_branch() {
909 let test = crate::testing::TestRepo::with_initial_commit();
910 let repo = test.path();
911
912 assert_eq!(
914 branch_tracks_ref(repo, "no-such-branch", "refs/pull/1/head", None),
915 None,
916 );
917 }
918
919 #[test]
920 fn test_branch_tracks_ref_invalid_repo_path() {
921 let bad_path = std::path::Path::new("/nonexistent/repo/path");
923 assert_eq!(
924 branch_tracks_ref(bad_path, "main", "refs/pull/1/head", None),
925 None,
926 );
927 }
928
929 #[test]
930 fn test_branch_tracks_ref_mr_ref() {
931 let test = crate::testing::TestRepo::with_initial_commit();
932 let repo = test.path();
933
934 crate::shell_exec::Cmd::new("git")
936 .args(["branch", "mr-branch"])
937 .current_dir(repo)
938 .run()
939 .unwrap();
940 crate::shell_exec::Cmd::new("git")
941 .args([
942 "config",
943 "branch.mr-branch.merge",
944 "refs/merge-requests/42/head",
945 ])
946 .current_dir(repo)
947 .run()
948 .unwrap();
949 crate::shell_exec::Cmd::new("git")
950 .args(["config", "branch.mr-branch.remote", "origin"])
951 .current_dir(repo)
952 .run()
953 .unwrap();
954
955 assert_eq!(
956 branch_tracks_ref(
957 repo,
958 "mr-branch",
959 "refs/merge-requests/42/head",
960 Some("origin"),
961 ),
962 Some(true),
963 );
964 assert_eq!(
965 branch_tracks_ref(repo, "mr-branch", "refs/pull/42/head", Some("origin")),
966 Some(false),
967 );
968 }
969
970 #[test]
971 fn test_branch_ref_detached_head() {
972 let wt = WorktreeInfo {
973 path: PathBuf::from("/repo.detached"),
974 head: "def456".into(),
975 branch: None, bare: false,
977 detached: true,
978 locked: None,
979 prunable: None,
980 };
981
982 let branch_ref = BranchRef::from(&wt);
983
984 assert_eq!(branch_ref.branch, None);
985 assert_eq!(branch_ref.commit_sha, "def456");
986 assert!(branch_ref.has_worktree());
987 }
988}