1use std::collections::HashSet;
7use std::path::Path;
8use std::process::Command;
9
10use git2::BranchType;
11use git2::Cred;
12use git2::RemoteCallbacks;
13use git2::Repository;
14use jiff::Timestamp;
15use rootcause::bail;
16use rootcause::prelude::ResultExt;
17use rootcause::report;
18use ytil_cmd::CmdError;
19use ytil_cmd::CmdExt;
20
21pub fn get_default() -> rootcause::Result<String> {
31 let repo_path = Path::new(".");
32 let repo = crate::repo::discover(repo_path)
33 .context("error getting repo for getting default branch")
34 .attach_with(|| format!("path={}", repo_path.display()))?;
35
36 let default_remote_ref = crate::remote::get_default(&repo)?;
37
38 let Some(target) = default_remote_ref
39 .symbolic_target()
40 .context("error reading default remote symbolic target")?
41 else {
42 bail!("error missing default branch");
43 };
44
45 Ok(target
46 .split('/')
47 .next_back()
48 .ok_or_else(|| report!("error extracting default branch_name from target"))
49 .attach_with(|| format!("target={target:?}"))?
50 .to_string())
51}
52
53pub fn get_current() -> rootcause::Result<String> {
58 let repo_path = Path::new(".");
59 let repo = crate::repo::discover(repo_path)
60 .context("error getting repo for getting current branch")
61 .attach_with(|| format!("path={}", repo_path.display()))?;
62
63 if repo
64 .head_detached()
65 .context("error checking if head is detached")
66 .attach_with(|| format!("path={}", repo_path.display()))?
67 {
68 Err(report!("error head is detached")).attach_with(|| format!("path={}", repo_path.display()))?;
69 }
70
71 let head = repo
72 .head()
73 .context("error getting head")
74 .attach_with(|| format!("path={}", repo_path.display()))?;
75 let branch_name = head
76 .shorthand()
77 .context("error invalid branch shorthand UTF-8")
78 .attach_with(|| format!("path={}", repo_path.display()))?;
79 Ok(branch_name.to_string())
80}
81
82pub fn get_at(path: &Path, timestamp: Timestamp) -> Option<String> {
87 let repo = crate::repo::discover(path).ok()?;
88 let reflog = repo.reflog("HEAD").ok()?;
89 let entries = reflog_entries(&reflog);
90 branch_at(&entries, timestamp)
91}
92
93#[must_use]
97pub fn get_at_many(path: &Path, timestamps: &[Timestamp]) -> Vec<Option<String>> {
98 let Some(repo) = crate::repo::discover(path).ok() else {
99 return timestamps.iter().map(|_| None).collect();
100 };
101 let Some(reflog) = repo.reflog("HEAD").ok() else {
102 return timestamps.iter().map(|_| None).collect();
103 };
104 let mut entries = reflog_entries(&reflog);
105 entries.sort_unstable_by_key(|(entry_timestamp, _)| *entry_timestamp);
106
107 timestamps
108 .iter()
109 .map(|timestamp| branch_at_sorted(&entries, *timestamp))
110 .collect()
111}
112
113fn reflog_entries(reflog: &git2::Reflog) -> Vec<(i64, String)> {
114 reflog
115 .iter()
116 .filter_map(|entry| {
117 let message = entry.message().ok()??;
120 Some((entry.committer().when().seconds(), branch_from_reflog_message(message)?))
121 })
122 .collect()
123}
124
125fn branch_at(entries: &[(i64, String)], timestamp: Timestamp) -> Option<String> {
126 let timestamp = timestamp.as_second();
127 entries
128 .iter()
129 .filter(|(entry_timestamp, _)| *entry_timestamp <= timestamp)
130 .max_by_key(|(entry_timestamp, _)| *entry_timestamp)
131 .map(|(_, branch)| branch.clone())
132}
133
134fn branch_at_sorted(entries: &[(i64, String)], timestamp: Timestamp) -> Option<String> {
135 let timestamp = timestamp.as_second();
136 let index = entries.partition_point(|(entry_timestamp, _)| *entry_timestamp <= timestamp);
137 index
138 .checked_sub(1)
139 .and_then(|index| entries.get(index))
140 .map(|(_, branch)| branch.clone())
141}
142
143pub fn create_from_default_branch(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
148 let repo = if let Some(repo) = repo {
149 repo
150 } else {
151 let path = Path::new(".");
152 &crate::repo::discover(path)
153 .context("error getting repo for creating new branch")
154 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
155 };
156
157 let commit = repo
158 .head()
159 .context("error getting head")
160 .attach_with(|| format!("branch_name={branch_name:?}"))?
161 .peel_to_commit()
162 .context("error peeling head to commit")
163 .attach_with(|| format!("branch_name={branch_name:?}"))?;
164
165 repo.branch(branch_name, &commit, false)
166 .context("error creating branch")
167 .attach_with(|| format!("branch_name={branch_name:?}"))?;
168
169 Ok(())
170}
171
172pub fn rename_current(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
183 let repo = if let Some(repo) = repo {
184 repo
185 } else {
186 let path = Path::new(".");
187 &crate::repo::discover(path)
188 .context("error getting repo for renaming current branch")
189 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
190 };
191
192 let head = repo
193 .head()
194 .context("error getting repo head")
195 .attach_with(|| format!("repo_path={}", repo.path().display()))
196 .attach_with(|| format!("branch_name={branch_name:?}"))?;
197 let current_branch_name = head
198 .shorthand()
199 .context("error invalid current branch shorthand UTF-8")
200 .attach_with(|| format!("repo_path={}", repo.path().display()))
201 .attach_with(|| format!("branch_name={branch_name:?}"))?;
202 let mut branch = repo
203 .find_branch(current_branch_name, BranchType::Local)
204 .context("error finding current branch")
205 .attach_with(|| format!("repo_path={}", repo.path().display()))
206 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
207
208 branch
209 .rename(branch_name, false)
210 .context("error renaming current branch")
211 .attach_with(|| format!("repo_path={}", repo.path().display()))
212 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
213
214 Ok(())
215}
216
217pub fn push(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
228 let repo = if let Some(repo) = repo {
229 repo
230 } else {
231 let path = Path::new(".");
232 &crate::repo::discover(path)
233 .context("error getting repo for pushing new branch")
234 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
235 };
236
237 let default_remote = crate::remote::get_default(repo)?;
238
239 let default_remote_name = default_remote
240 .name()
241 .context("error reading name of default remote")?
242 .trim_start_matches("refs/remotes/")
243 .trim_end_matches("/HEAD");
244
245 let mut remote = repo.find_remote(default_remote_name)?;
246
247 let mut callbacks = RemoteCallbacks::new();
248 callbacks.credentials(|_url, username_from_url, _allowed_types| {
249 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
250 });
251
252 let mut push_opts = git2::PushOptions::new();
253 push_opts.remote_callbacks(callbacks);
254
255 let branch_refspec = format!("refs/heads/{branch_name}");
256 remote
257 .push(&[&branch_refspec], Some(&mut push_opts))
258 .context("error pushing branch to remote")
259 .attach_with(|| format!("branch_refspec={branch_refspec:?} default_remote_name={default_remote_name:?}"))?;
260
261 Ok(())
262}
263
264pub fn get_previous(repo: &Repository) -> Option<String> {
272 let reflog = repo.reflog("HEAD").ok()?;
273 reflog.iter().find_map(|entry| {
274 let msg = entry.message().ok()??;
275 let rest = msg
276 .strip_prefix("checkout: moving from ")
277 .or_else(|| msg.strip_prefix("switch: moving from "))?;
278 Some(rest.rsplit_once(" to ")?.0.to_string())
279 })
280}
281
282pub fn get_user_email(repo: &Repository) -> rootcause::Result<Option<String>> {
289 let config = repo.config().context("error opening repo config")?;
290 match config.get_string("user.email") {
291 Ok(email) => Ok(Some(email)),
292 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
293 Err(err) => Err(report!("error reading user.email from repo config").attach(err.to_string())),
294 }
295}
296
297pub fn switch(branch_name: &str) -> Result<(), Box<CmdError>> {
302 Command::new("git")
303 .args(["switch", branch_name, "--guess"])
304 .exec()
305 .map_err(Box::new)?;
306 Ok(())
307}
308
309pub fn get_all(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
320 fetch_with_repo(repo, &[]).context("error fetching branches")?;
321
322 let mut out = vec![];
323 for branch_res in repo.branches(None).context("error enumerating branches")? {
324 let branch = branch_res.context("error getting branch result")?;
325 out.push(Branch::try_from(branch).context("error creating branch from result")?);
326 }
327
328 out.sort_unstable_by(|a, b| b.committer_date_time().cmp(a.committer_date_time()));
329
330 Ok(out)
331}
332
333pub fn get_all_no_redundant(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
343 let mut branches = get_all(repo)?;
344 remove_redundant_remotes(&mut branches);
345 Ok(branches)
346}
347
348pub fn fetch(branches: &[&str]) -> rootcause::Result<()> {
358 let repo_path = Path::new(".");
359 let repo = crate::repo::discover(repo_path)
360 .context("error getting repo for fetching branches")
361 .attach_with(|| format!("path={} branches={branches:?}", repo_path.display()))?;
362 fetch_with_repo(&repo, branches)
363}
364
365pub fn remove_redundant_remotes(branches: &mut Vec<Branch>) {
374 let local_names: HashSet<String> = branches
377 .iter()
378 .filter_map(|b| {
379 if let Branch::Local { name, .. } = b {
380 Some(name.clone())
381 } else {
382 None
383 }
384 })
385 .collect();
386
387 branches.retain(|b| match b {
388 Branch::Local { .. } => true,
389 Branch::Remote { name, .. } => {
390 let short = name.split_once('/').map_or(name.as_str(), |(_, rest)| rest);
391 !local_names.contains(short)
392 }
393 });
394}
395
396#[derive(Clone, Debug)]
398#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
399pub enum Branch {
400 Local {
402 name: String,
404 committer_email: String,
406 committer_date_time: Timestamp,
408 },
409 Remote {
411 name: String,
413 committer_email: String,
415 committer_date_time: Timestamp,
417 },
418}
419
420impl Branch {
421 pub fn name(&self) -> &str {
423 match self {
424 Self::Local { name, .. } | Self::Remote { name, .. } => name,
425 }
426 }
427
428 pub fn name_no_origin(&self) -> &str {
430 self.name().trim_start_matches("origin/")
431 }
432
433 pub fn committer_email(&self) -> &str {
435 match self {
436 Self::Local { committer_email, .. } | Self::Remote { committer_email, .. } => committer_email,
437 }
438 }
439
440 pub const fn committer_date_time(&self) -> &Timestamp {
442 match self {
443 Self::Local {
444 committer_date_time, ..
445 }
446 | Self::Remote {
447 committer_date_time, ..
448 } => committer_date_time,
449 }
450 }
451}
452
453impl<'a> TryFrom<(git2::Branch<'a>, git2::BranchType)> for Branch {
463 type Error = rootcause::Report;
464
465 fn try_from((raw_branch, branch_type): (git2::Branch<'a>, git2::BranchType)) -> Result<Self, Self::Error> {
466 let branch_name = raw_branch
467 .name()?
468 .ok_or_else(|| report!("error invalid branch name UTF-8"))
469 .attach_with(|| format!("branch_name={:?}", raw_branch.name()))?;
470 let committer = raw_branch.get().peel_to_commit()?.committer().to_owned();
471 let committer_email = committer
472 .email()
473 .context("error invalid committer email UTF-8")
474 .attach_with(|| format!("branch_name={branch_name:?}"))?
475 .to_string();
476 let committer_date_time = Timestamp::from_second(committer.when().seconds())
477 .map_err(|_| report!("error invalid commit timestamp"))
478 .attach_with(|| format!("seconds={}", committer.when().seconds()))?;
479
480 Ok(match branch_type {
481 git2::BranchType::Local => Self::Local {
482 name: branch_name.to_string(),
483 committer_email,
484 committer_date_time,
485 },
486 git2::BranchType::Remote => Self::Remote {
487 name: branch_name.to_string(),
488 committer_email,
489 committer_date_time,
490 },
491 })
492 }
493}
494
495fn fetch_with_repo(repo: &Repository, branches: &[&str]) -> rootcause::Result<()> {
497 let mut callbacks = RemoteCallbacks::new();
498 callbacks.credentials(|_url, username_from_url, _allowed_types| {
499 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
500 });
501
502 let mut fetch_opts = git2::FetchOptions::new();
503 fetch_opts.remote_callbacks(callbacks);
504
505 repo.find_remote("origin")
506 .context("error finding origin remote")?
507 .fetch(branches, Some(&mut fetch_opts), None)
508 .context("error performing fetch from origin remote")
509 .attach_with(|| format!("branches={branches:?}"))?;
510
511 Ok(())
512}
513
514fn branch_from_reflog_message(message: &str) -> Option<String> {
515 let rest = message
516 .strip_prefix("checkout: moving from ")
517 .or_else(|| message.strip_prefix("switch: moving from "))?;
518 let (_, branch) = rest.rsplit_once(" to ")?;
519 let branch = branch.trim();
520 (!branch.is_empty()).then(|| branch.to_string())
521}
522
523#[cfg(test)]
524mod tests {
525 use git2::Signature;
526 use git2::Time;
527 use rstest::rstest;
528 use test_that::prelude::*;
529
530 use super::*;
531
532 #[rstest]
533 #[case::remote_same_short_name(
534 vec![local("feature-x"), remote("origin/feature-x")],
535 vec![local("feature-x")]
536 )]
537 #[case::no_redundant(
538 vec![local("feature-x"), remote("origin/feature-y")],
539 vec![local("feature-x"), remote("origin/feature-y")]
540 )]
541 #[case::multiple_mixed(
542 vec![
543 local("feature-x"),
544 remote("origin/feature-x"),
545 remote("origin/feature-y"),
546 local("main"),
547 remote("upstream/main")
548 ],
549 vec![local("feature-x"), remote("origin/feature-y"), local("main")]
550 )]
551 #[case::different_remote_prefix(
552 vec![local("feature-x"), remote("upstream/feature-x")],
553 vec![local("feature-x")]
554 )]
555 fn test_remove_redundant_remotes_cases(#[case] mut input: Vec<Branch>, #[case] expected: Vec<Branch>) {
556 remove_redundant_remotes(&mut input);
557 assert_eq!(input, expected);
558 }
559
560 #[test]
561 fn test_branch_try_from_converts_local_branch_successfully() {
562 let (_temp_dir, repo) = crate::tests::init_test_repo(Some(Time::new(42, 3)));
563
564 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
565 let branch = repo.branch("test-branch", &head_commit, false).unwrap();
566
567 let branch_result = Branch::try_from((branch, git2::BranchType::Local));
568 assert_that!(branch_result, ok(anything()));
569 let result = branch_result.expect("local branch should convert");
570
571 assert_that!(
572 result,
573 eq(Branch::Local {
574 name: "test-branch".to_string(),
575 committer_email: "test@example.com".to_string(),
576 committer_date_time: Timestamp::from_second(42).unwrap(),
577 })
578 );
579 }
580
581 #[test]
582 fn test_rename_current_renames_the_current_branch() {
583 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
584
585 assert_that!(rename_current("renamed", Some(&repo)), ok(eq(())));
586
587 assert_that!(repo.head().unwrap().shorthand().unwrap(), eq("renamed"));
588 repo.find_branch("renamed", git2::BranchType::Local).unwrap();
589 assert_that!(
590 repo.find_branch("master", git2::BranchType::Local).map(|_| ()),
591 err(anything())
592 );
593 }
594
595 #[test]
596 fn test_rename_current_fails_when_target_branch_already_exists() {
597 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
598 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
599 repo.branch("existing", &head_commit, false).unwrap();
600
601 let rename_result = rename_current("existing", Some(&repo));
602 assert_that!(rename_result.as_ref(), err(anything()));
603 let err = rename_result.expect_err("renaming over existing branch should fail");
604
605 assert_that!(err.to_string(), contains_substring("error renaming current branch"));
606 }
607
608 #[test]
609 fn test_get_at_when_path_is_not_git_repo_returns_none() {
610 let temp_dir = tempfile::TempDir::new().unwrap();
611 let timestamp = Timestamp::from_second(10).unwrap();
612
613 let actual = get_at(temp_dir.path(), timestamp);
614
615 assert_that!(actual, eq(None));
616 }
617
618 #[test]
619 fn test_get_at_when_checkout_reflog_exists_returns_destination_branch_before_timestamp() {
620 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
621 append_head_reflog(&repo, 10, "checkout: moving from master to feature/a");
622 append_head_reflog(&repo, 20, "checkout: moving from feature/a to feature/b");
623 append_head_reflog(&repo, 30, "checkout: moving from feature/b to feature/c");
624
625 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(25).unwrap());
626
627 assert_that!(actual, eq(Some("feature/b".to_string())));
628 }
629
630 #[test]
631 fn test_get_at_many_when_checkout_reflog_exists_returns_branches_for_all_timestamps() {
632 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
633 append_head_reflog(&repo, 10, "checkout: moving from master to feature/a");
634 append_head_reflog(&repo, 20, "checkout: moving from feature/a to feature/b");
635 append_head_reflog(&repo, 30, "checkout: moving from feature/b to feature/c");
636 let timestamps = [
637 Timestamp::from_second(5).unwrap(),
638 Timestamp::from_second(20).unwrap(),
639 Timestamp::from_second(35).unwrap(),
640 ];
641
642 let actual = get_at_many(repo.workdir().unwrap(), ×tamps);
643
644 assert_that!(
645 actual,
646 eq(vec![None, Some("feature/b".to_string()), Some("feature/c".to_string())])
647 );
648 }
649
650 #[test]
651 fn test_get_at_when_switch_reflog_exists_returns_destination_branch() {
652 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
653 append_head_reflog(&repo, 10, "switch: moving from feature/a to main");
654
655 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(10).unwrap());
656
657 assert_that!(actual, eq(Some("main".to_string())));
658 }
659
660 #[test]
661 fn test_get_at_when_reflog_has_no_checkout_or_switch_before_timestamp_returns_none() {
662 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
663 append_head_reflog(&repo, 10, "commit: message");
664 append_head_reflog(&repo, 20, "checkout: moving from main");
665 append_head_reflog(&repo, 30, "switch: moving from main to ");
666
667 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(30).unwrap());
668
669 assert_that!(actual, eq(None));
670 }
671
672 #[rstest]
673 #[case::local_variant(local("main"), "main")]
674 #[case::remote_variant(remote("origin/feature"), "origin/feature")]
675 fn test_branch_name_when_variant_returns_name(#[case] branch: Branch, #[case] expected: &str) {
676 assert_that!(branch.name(), eq(expected));
677 }
678
679 #[rstest]
680 #[case::local_no_origin(local("main"), "main")]
681 #[case::remote_origin_prefix(remote("origin/main"), "main")]
682 #[case::remote_other_prefix(remote("upstream/feature"), "upstream/feature")]
683 fn test_branch_name_no_origin_when_name_returns_trimmed(#[case] branch: Branch, #[case] expected: &str) {
684 assert_that!(branch.name_no_origin(), eq(expected));
685 }
686
687 #[rstest]
688 #[case::local_variant(
689 Branch::Local {
690 name: "test".to_string(),
691 committer_email: "a@b.com".to_string(),
692 committer_date_time: Timestamp::from_second(123_456).unwrap(),
693 },
694 Timestamp::from_second(123_456).unwrap()
695 )]
696 #[case::remote_variant(
697 Branch::Remote {
698 name: "origin/test".to_string(),
699 committer_email: "a@b.com".to_string(),
700 committer_date_time: Timestamp::from_second(654_321).unwrap(),
701 },
702 Timestamp::from_second(654_321).unwrap()
703 )]
704 fn test_branch_committer_date_time_when_variant_returns_date_time(
705 #[case] branch: Branch,
706 #[case] expected: Timestamp,
707 ) {
708 assert_that!(branch.committer_date_time(), eq(&expected));
709 }
710
711 fn local(name: &str) -> Branch {
712 Branch::Local {
713 name: name.into(),
714 committer_email: String::new(),
715 committer_date_time: Timestamp::from_second(0).unwrap(),
716 }
717 }
718
719 fn remote(name: &str) -> Branch {
720 Branch::Remote {
721 name: name.into(),
722 committer_email: String::new(),
723 committer_date_time: Timestamp::from_second(0).unwrap(),
724 }
725 }
726
727 fn append_head_reflog(repo: &Repository, timestamp: i64, message: &str) {
728 let oid = repo.head().unwrap().target().unwrap();
729 let sig = Signature::new("test", "test@example.com", &Time::new(timestamp, 0)).unwrap();
730 let mut reflog = repo.reflog("HEAD").unwrap();
731 reflog.append(oid, &sig, Some(message)).unwrap();
732 reflog.write().unwrap();
733 }
734}