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 timestamp = timestamp.as_second();
90 reflog
91 .iter()
92 .filter_map(|entry| {
93 let message = entry.message().ok()??;
96 Some((entry.committer().when().seconds(), branch_from_reflog_message(message)?))
97 })
98 .filter(|(entry_timestamp, _)| *entry_timestamp <= timestamp)
99 .max_by_key(|(entry_timestamp, _)| *entry_timestamp)
100 .map(|(_, branch)| branch)
101}
102
103pub fn create_from_default_branch(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
108 let repo = if let Some(repo) = repo {
109 repo
110 } else {
111 let path = Path::new(".");
112 &crate::repo::discover(path)
113 .context("error getting repo for creating new branch")
114 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
115 };
116
117 let commit = repo
118 .head()
119 .context("error getting head")
120 .attach_with(|| format!("branch_name={branch_name:?}"))?
121 .peel_to_commit()
122 .context("error peeling head to commit")
123 .attach_with(|| format!("branch_name={branch_name:?}"))?;
124
125 repo.branch(branch_name, &commit, false)
126 .context("error creating branch")
127 .attach_with(|| format!("branch_name={branch_name:?}"))?;
128
129 Ok(())
130}
131
132pub fn rename_current(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
143 let repo = if let Some(repo) = repo {
144 repo
145 } else {
146 let path = Path::new(".");
147 &crate::repo::discover(path)
148 .context("error getting repo for renaming current branch")
149 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
150 };
151
152 let head = repo
153 .head()
154 .context("error getting repo head")
155 .attach_with(|| format!("repo_path={}", repo.path().display()))
156 .attach_with(|| format!("branch_name={branch_name:?}"))?;
157 let current_branch_name = head
158 .shorthand()
159 .context("error invalid current branch shorthand UTF-8")
160 .attach_with(|| format!("repo_path={}", repo.path().display()))
161 .attach_with(|| format!("branch_name={branch_name:?}"))?;
162 let mut branch = repo
163 .find_branch(current_branch_name, BranchType::Local)
164 .context("error finding current branch")
165 .attach_with(|| format!("repo_path={}", repo.path().display()))
166 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
167
168 branch
169 .rename(branch_name, false)
170 .context("error renaming current branch")
171 .attach_with(|| format!("repo_path={}", repo.path().display()))
172 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
173
174 Ok(())
175}
176
177pub fn push(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
188 let repo = if let Some(repo) = repo {
189 repo
190 } else {
191 let path = Path::new(".");
192 &crate::repo::discover(path)
193 .context("error getting repo for pushing new branch")
194 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
195 };
196
197 let default_remote = crate::remote::get_default(repo)?;
198
199 let default_remote_name = default_remote
200 .name()
201 .context("error reading name of default remote")?
202 .trim_start_matches("refs/remotes/")
203 .trim_end_matches("/HEAD");
204
205 let mut remote = repo.find_remote(default_remote_name)?;
206
207 let mut callbacks = RemoteCallbacks::new();
208 callbacks.credentials(|_url, username_from_url, _allowed_types| {
209 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
210 });
211
212 let mut push_opts = git2::PushOptions::new();
213 push_opts.remote_callbacks(callbacks);
214
215 let branch_refspec = format!("refs/heads/{branch_name}");
216 remote
217 .push(&[&branch_refspec], Some(&mut push_opts))
218 .context("error pushing branch to remote")
219 .attach_with(|| format!("branch_refspec={branch_refspec:?} default_remote_name={default_remote_name:?}"))?;
220
221 Ok(())
222}
223
224pub fn get_previous(repo: &Repository) -> Option<String> {
232 let reflog = repo.reflog("HEAD").ok()?;
233 reflog.iter().find_map(|entry| {
234 let msg = entry.message().ok()??;
235 let rest = msg
236 .strip_prefix("checkout: moving from ")
237 .or_else(|| msg.strip_prefix("switch: moving from "))?;
238 Some(rest.rsplit_once(" to ")?.0.to_string())
239 })
240}
241
242pub fn get_user_email(repo: &Repository) -> rootcause::Result<Option<String>> {
249 let config = repo.config().context("error opening repo config")?;
250 match config.get_string("user.email") {
251 Ok(email) => Ok(Some(email)),
252 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
253 Err(err) => Err(report!("error reading user.email from repo config").attach(err.to_string())),
254 }
255}
256
257pub fn switch(branch_name: &str) -> Result<(), Box<CmdError>> {
262 Command::new("git")
263 .args(["switch", branch_name, "--guess"])
264 .exec()
265 .map_err(Box::new)?;
266 Ok(())
267}
268
269pub fn get_all(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
280 fetch_with_repo(repo, &[]).context("error fetching branches")?;
281
282 let mut out = vec![];
283 for branch_res in repo.branches(None).context("error enumerating branches")? {
284 let branch = branch_res.context("error getting branch result")?;
285 out.push(Branch::try_from(branch).context("error creating branch from result")?);
286 }
287
288 out.sort_unstable_by(|a, b| b.committer_date_time().cmp(a.committer_date_time()));
289
290 Ok(out)
291}
292
293pub fn get_all_no_redundant(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
303 let mut branches = get_all(repo)?;
304 remove_redundant_remotes(&mut branches);
305 Ok(branches)
306}
307
308pub fn fetch(branches: &[&str]) -> rootcause::Result<()> {
318 let repo_path = Path::new(".");
319 let repo = crate::repo::discover(repo_path)
320 .context("error getting repo for fetching branches")
321 .attach_with(|| format!("path={} branches={branches:?}", repo_path.display()))?;
322 fetch_with_repo(&repo, branches)
323}
324
325pub fn remove_redundant_remotes(branches: &mut Vec<Branch>) {
334 let local_names: HashSet<String> = branches
337 .iter()
338 .filter_map(|b| {
339 if let Branch::Local { name, .. } = b {
340 Some(name.clone())
341 } else {
342 None
343 }
344 })
345 .collect();
346
347 branches.retain(|b| match b {
348 Branch::Local { .. } => true,
349 Branch::Remote { name, .. } => {
350 let short = name.split_once('/').map_or(name.as_str(), |(_, rest)| rest);
351 !local_names.contains(short)
352 }
353 });
354}
355
356#[derive(Clone, Debug)]
358#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
359pub enum Branch {
360 Local {
362 name: String,
364 committer_email: String,
366 committer_date_time: Timestamp,
368 },
369 Remote {
371 name: String,
373 committer_email: String,
375 committer_date_time: Timestamp,
377 },
378}
379
380impl Branch {
381 pub fn name(&self) -> &str {
383 match self {
384 Self::Local { name, .. } | Self::Remote { name, .. } => name,
385 }
386 }
387
388 pub fn name_no_origin(&self) -> &str {
390 self.name().trim_start_matches("origin/")
391 }
392
393 pub fn committer_email(&self) -> &str {
395 match self {
396 Self::Local { committer_email, .. } | Self::Remote { committer_email, .. } => committer_email,
397 }
398 }
399
400 pub const fn committer_date_time(&self) -> &Timestamp {
402 match self {
403 Self::Local {
404 committer_date_time, ..
405 }
406 | Self::Remote {
407 committer_date_time, ..
408 } => committer_date_time,
409 }
410 }
411}
412
413impl<'a> TryFrom<(git2::Branch<'a>, git2::BranchType)> for Branch {
423 type Error = rootcause::Report;
424
425 fn try_from((raw_branch, branch_type): (git2::Branch<'a>, git2::BranchType)) -> Result<Self, Self::Error> {
426 let branch_name = raw_branch
427 .name()?
428 .ok_or_else(|| report!("error invalid branch name UTF-8"))
429 .attach_with(|| format!("branch_name={:?}", raw_branch.name()))?;
430 let committer = raw_branch.get().peel_to_commit()?.committer().to_owned();
431 let committer_email = committer
432 .email()
433 .context("error invalid committer email UTF-8")
434 .attach_with(|| format!("branch_name={branch_name:?}"))?
435 .to_string();
436 let committer_date_time = Timestamp::from_second(committer.when().seconds())
437 .map_err(|_| report!("error invalid commit timestamp"))
438 .attach_with(|| format!("seconds={}", committer.when().seconds()))?;
439
440 Ok(match branch_type {
441 git2::BranchType::Local => Self::Local {
442 name: branch_name.to_string(),
443 committer_email,
444 committer_date_time,
445 },
446 git2::BranchType::Remote => Self::Remote {
447 name: branch_name.to_string(),
448 committer_email,
449 committer_date_time,
450 },
451 })
452 }
453}
454
455fn fetch_with_repo(repo: &Repository, branches: &[&str]) -> rootcause::Result<()> {
457 let mut callbacks = RemoteCallbacks::new();
458 callbacks.credentials(|_url, username_from_url, _allowed_types| {
459 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
460 });
461
462 let mut fetch_opts = git2::FetchOptions::new();
463 fetch_opts.remote_callbacks(callbacks);
464
465 repo.find_remote("origin")
466 .context("error finding origin remote")?
467 .fetch(branches, Some(&mut fetch_opts), None)
468 .context("error performing fetch from origin remote")
469 .attach_with(|| format!("branches={branches:?}"))?;
470
471 Ok(())
472}
473
474fn branch_from_reflog_message(message: &str) -> Option<String> {
475 let rest = message
476 .strip_prefix("checkout: moving from ")
477 .or_else(|| message.strip_prefix("switch: moving from "))?;
478 let (_, branch) = rest.rsplit_once(" to ")?;
479 let branch = branch.trim();
480 (!branch.is_empty()).then(|| branch.to_string())
481}
482
483#[cfg(test)]
484mod tests {
485 use git2::Signature;
486 use git2::Time;
487 use rstest::rstest;
488 use test_that::prelude::*;
489
490 use super::*;
491
492 #[rstest]
493 #[case::remote_same_short_name(
494 vec![local("feature-x"), remote("origin/feature-x")],
495 vec![local("feature-x")]
496 )]
497 #[case::no_redundant(
498 vec![local("feature-x"), remote("origin/feature-y")],
499 vec![local("feature-x"), remote("origin/feature-y")]
500 )]
501 #[case::multiple_mixed(
502 vec![
503 local("feature-x"),
504 remote("origin/feature-x"),
505 remote("origin/feature-y"),
506 local("main"),
507 remote("upstream/main")
508 ],
509 vec![local("feature-x"), remote("origin/feature-y"), local("main")]
510 )]
511 #[case::different_remote_prefix(
512 vec![local("feature-x"), remote("upstream/feature-x")],
513 vec![local("feature-x")]
514 )]
515 fn remove_redundant_remotes_cases(#[case] mut input: Vec<Branch>, #[case] expected: Vec<Branch>) {
516 remove_redundant_remotes(&mut input);
517 assert_eq!(input, expected);
518 }
519
520 #[test]
521 fn test_branch_try_from_converts_local_branch_successfully() {
522 let (_temp_dir, repo) = crate::tests::init_test_repo(Some(Time::new(42, 3)));
523
524 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
525 let branch = repo.branch("test-branch", &head_commit, false).unwrap();
526
527 let branch_result = Branch::try_from((branch, git2::BranchType::Local));
528 assert_that!(branch_result, ok(anything()));
529 let result = branch_result.expect("local branch should convert");
530
531 assert_that!(
532 result,
533 eq(Branch::Local {
534 name: "test-branch".to_string(),
535 committer_email: "test@example.com".to_string(),
536 committer_date_time: Timestamp::from_second(42).unwrap(),
537 })
538 );
539 }
540
541 #[test]
542 fn test_rename_current_renames_the_current_branch() {
543 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
544
545 assert_that!(rename_current("renamed", Some(&repo)), ok(eq(())));
546
547 assert_that!(repo.head().unwrap().shorthand().unwrap(), eq("renamed"));
548 repo.find_branch("renamed", git2::BranchType::Local).unwrap();
549 assert_that!(
550 repo.find_branch("master", git2::BranchType::Local).map(|_| ()),
551 err(anything())
552 );
553 }
554
555 #[test]
556 fn test_rename_current_fails_when_target_branch_already_exists() {
557 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
558 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
559 repo.branch("existing", &head_commit, false).unwrap();
560
561 let rename_result = rename_current("existing", Some(&repo));
562 assert_that!(rename_result.as_ref(), err(anything()));
563 let err = rename_result.expect_err("renaming over existing branch should fail");
564
565 assert_that!(err.to_string(), contains_substring("error renaming current branch"));
566 }
567
568 #[test]
569 fn test_get_at_when_path_is_not_git_repo_returns_none() {
570 let temp_dir = tempfile::TempDir::new().unwrap();
571 let timestamp = Timestamp::from_second(10).unwrap();
572
573 let actual = get_at(temp_dir.path(), timestamp);
574
575 assert_that!(actual, eq(None));
576 }
577
578 #[test]
579 fn test_get_at_when_checkout_reflog_exists_returns_destination_branch_before_timestamp() {
580 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
581 append_head_reflog(&repo, 10, "checkout: moving from master to feature/a");
582 append_head_reflog(&repo, 20, "checkout: moving from feature/a to feature/b");
583 append_head_reflog(&repo, 30, "checkout: moving from feature/b to feature/c");
584
585 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(25).unwrap());
586
587 assert_that!(actual, eq(Some("feature/b".to_string())));
588 }
589
590 #[test]
591 fn test_get_at_when_switch_reflog_exists_returns_destination_branch() {
592 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
593 append_head_reflog(&repo, 10, "switch: moving from feature/a to main");
594
595 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(10).unwrap());
596
597 assert_that!(actual, eq(Some("main".to_string())));
598 }
599
600 #[test]
601 fn test_get_at_when_reflog_has_no_checkout_or_switch_before_timestamp_returns_none() {
602 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
603 append_head_reflog(&repo, 10, "commit: message");
604 append_head_reflog(&repo, 20, "checkout: moving from main");
605 append_head_reflog(&repo, 30, "switch: moving from main to ");
606
607 let actual = get_at(repo.workdir().unwrap(), Timestamp::from_second(30).unwrap());
608
609 assert_that!(actual, eq(None));
610 }
611
612 #[rstest]
613 #[case::local_variant(local("main"), "main")]
614 #[case::remote_variant(remote("origin/feature"), "origin/feature")]
615 fn test_branch_name_when_variant_returns_name(#[case] branch: Branch, #[case] expected: &str) {
616 assert_that!(branch.name(), eq(expected));
617 }
618
619 #[rstest]
620 #[case::local_no_origin(local("main"), "main")]
621 #[case::remote_origin_prefix(remote("origin/main"), "main")]
622 #[case::remote_other_prefix(remote("upstream/feature"), "upstream/feature")]
623 fn test_branch_name_no_origin_when_name_returns_trimmed(#[case] branch: Branch, #[case] expected: &str) {
624 assert_that!(branch.name_no_origin(), eq(expected));
625 }
626
627 #[rstest]
628 #[case::local_variant(
629 Branch::Local {
630 name: "test".to_string(),
631 committer_email: "a@b.com".to_string(),
632 committer_date_time: Timestamp::from_second(123_456).unwrap(),
633 },
634 Timestamp::from_second(123_456).unwrap()
635 )]
636 #[case::remote_variant(
637 Branch::Remote {
638 name: "origin/test".to_string(),
639 committer_email: "a@b.com".to_string(),
640 committer_date_time: Timestamp::from_second(654_321).unwrap(),
641 },
642 Timestamp::from_second(654_321).unwrap()
643 )]
644 fn branch_committer_date_time_when_variant_returns_date_time(#[case] branch: Branch, #[case] expected: Timestamp) {
645 assert_that!(branch.committer_date_time(), eq(&expected));
646 }
647
648 fn local(name: &str) -> Branch {
649 Branch::Local {
650 name: name.into(),
651 committer_email: String::new(),
652 committer_date_time: Timestamp::from_second(0).unwrap(),
653 }
654 }
655
656 fn remote(name: &str) -> Branch {
657 Branch::Remote {
658 name: name.into(),
659 committer_email: String::new(),
660 committer_date_time: Timestamp::from_second(0).unwrap(),
661 }
662 }
663
664 fn append_head_reflog(repo: &Repository, timestamp: i64, message: &str) {
665 let oid = repo.head().unwrap().target().unwrap();
666 let sig = Signature::new("test", "test@example.com", &Time::new(timestamp, 0)).unwrap();
667 let mut reflog = repo.reflog("HEAD").unwrap();
668 reflog.append(oid, &sig, Some(message)).unwrap();
669 reflog.write().unwrap();
670 }
671}