1use std::collections::HashSet;
7use std::path::Path;
8use std::process::Command;
9
10use chrono::DateTime;
11use chrono::Utc;
12use git2::BranchType;
13use git2::Cred;
14use git2::RemoteCallbacks;
15use git2::Repository;
16use rootcause::bail;
17use rootcause::prelude::ResultExt;
18use rootcause::report;
19use ytil_cmd::CmdError;
20use ytil_cmd::CmdExt;
21
22pub fn get_default() -> rootcause::Result<String> {
32 let repo_path = Path::new(".");
33 let repo = crate::repo::discover(repo_path)
34 .context("error getting repo for getting default branch")
35 .attach_with(|| format!("path={}", repo_path.display()))?;
36
37 let default_remote_ref = crate::remote::get_default(&repo)?;
38
39 let Some(target) = default_remote_ref
40 .symbolic_target()
41 .context("error reading default remote symbolic target")?
42 else {
43 bail!("error missing default branch");
44 };
45
46 Ok(target
47 .split('/')
48 .next_back()
49 .ok_or_else(|| report!("error extracting default branch_name from target"))
50 .attach_with(|| format!("target={target:?}"))?
51 .to_string())
52}
53
54pub fn get_current() -> rootcause::Result<String> {
59 let repo_path = Path::new(".");
60 let repo = crate::repo::discover(repo_path)
61 .context("error getting repo for getting current branch")
62 .attach_with(|| format!("path={}", repo_path.display()))?;
63
64 if repo
65 .head_detached()
66 .context("error checking if head is detached")
67 .attach_with(|| format!("path={}", repo_path.display()))?
68 {
69 Err(report!("error head is detached")).attach_with(|| format!("path={}", repo_path.display()))?;
70 }
71
72 let head = repo
73 .head()
74 .context("error getting head")
75 .attach_with(|| format!("path={}", repo_path.display()))?;
76 let branch_name = head
77 .shorthand()
78 .context("error invalid branch shorthand UTF-8")
79 .attach_with(|| format!("path={}", repo_path.display()))?;
80 Ok(branch_name.to_string())
81}
82
83pub fn get_at(path: &Path, timestamp: DateTime<Utc>) -> Option<String> {
88 let repo = crate::repo::discover(path).ok()?;
89 let reflog = repo.reflog("HEAD").ok()?;
90 let timestamp = timestamp.timestamp();
91 reflog
92 .iter()
93 .filter_map(|entry| {
94 let message = entry.message().ok()??;
97 Some((entry.committer().when().seconds(), branch_from_reflog_message(message)?))
98 })
99 .filter(|(entry_timestamp, _)| *entry_timestamp <= timestamp)
100 .max_by_key(|(entry_timestamp, _)| *entry_timestamp)
101 .map(|(_, branch)| branch)
102}
103
104pub fn create_from_default_branch(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
109 let repo = if let Some(repo) = repo {
110 repo
111 } else {
112 let path = Path::new(".");
113 &crate::repo::discover(path)
114 .context("error getting repo for creating new branch")
115 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
116 };
117
118 let commit = repo
119 .head()
120 .context("error getting head")
121 .attach_with(|| format!("branch_name={branch_name:?}"))?
122 .peel_to_commit()
123 .context("error peeling head to commit")
124 .attach_with(|| format!("branch_name={branch_name:?}"))?;
125
126 repo.branch(branch_name, &commit, false)
127 .context("error creating branch")
128 .attach_with(|| format!("branch_name={branch_name:?}"))?;
129
130 Ok(())
131}
132
133pub fn rename_current(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
144 let repo = if let Some(repo) = repo {
145 repo
146 } else {
147 let path = Path::new(".");
148 &crate::repo::discover(path)
149 .context("error getting repo for renaming current branch")
150 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
151 };
152
153 let head = repo
154 .head()
155 .context("error getting repo head")
156 .attach_with(|| format!("repo_path={}", repo.path().display()))
157 .attach_with(|| format!("branch_name={branch_name:?}"))?;
158 let current_branch_name = head
159 .shorthand()
160 .context("error invalid current branch shorthand UTF-8")
161 .attach_with(|| format!("repo_path={}", repo.path().display()))
162 .attach_with(|| format!("branch_name={branch_name:?}"))?;
163 let mut branch = repo
164 .find_branch(current_branch_name, BranchType::Local)
165 .context("error finding current branch")
166 .attach_with(|| format!("repo_path={}", repo.path().display()))
167 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
168
169 branch
170 .rename(branch_name, false)
171 .context("error renaming current branch")
172 .attach_with(|| format!("repo_path={}", repo.path().display()))
173 .attach_with(|| format!("current_branch_name={current_branch_name:?} branch_name={branch_name:?}"))?;
174
175 Ok(())
176}
177
178pub fn push(branch_name: &str, repo: Option<&Repository>) -> rootcause::Result<()> {
189 let repo = if let Some(repo) = repo {
190 repo
191 } else {
192 let path = Path::new(".");
193 &crate::repo::discover(path)
194 .context("error getting repo for pushing new branch")
195 .attach_with(|| format!("path={} branch={branch_name:?}", path.display()))?
196 };
197
198 let default_remote = crate::remote::get_default(repo)?;
199
200 let default_remote_name = default_remote
201 .name()
202 .context("error reading name of default remote")?
203 .trim_start_matches("refs/remotes/")
204 .trim_end_matches("/HEAD");
205
206 let mut remote = repo.find_remote(default_remote_name)?;
207
208 let mut callbacks = RemoteCallbacks::new();
209 callbacks.credentials(|_url, username_from_url, _allowed_types| {
210 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
211 });
212
213 let mut push_opts = git2::PushOptions::new();
214 push_opts.remote_callbacks(callbacks);
215
216 let branch_refspec = format!("refs/heads/{branch_name}");
217 remote
218 .push(&[&branch_refspec], Some(&mut push_opts))
219 .context("error pushing branch to remote")
220 .attach_with(|| format!("branch_refspec={branch_refspec:?} default_remote_name={default_remote_name:?}"))?;
221
222 Ok(())
223}
224
225pub fn get_previous(repo: &Repository) -> Option<String> {
233 let reflog = repo.reflog("HEAD").ok()?;
234 reflog.iter().find_map(|entry| {
235 let msg = entry.message().ok()??;
236 let rest = msg
237 .strip_prefix("checkout: moving from ")
238 .or_else(|| msg.strip_prefix("switch: moving from "))?;
239 Some(rest.rsplit_once(" to ")?.0.to_string())
240 })
241}
242
243pub fn get_user_email(repo: &Repository) -> rootcause::Result<Option<String>> {
250 let config = repo.config().context("error opening repo config")?;
251 match config.get_string("user.email") {
252 Ok(email) => Ok(Some(email)),
253 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
254 Err(err) => Err(report!("error reading user.email from repo config").attach(err.to_string())),
255 }
256}
257
258pub fn switch(branch_name: &str) -> Result<(), Box<CmdError>> {
263 Command::new("git")
264 .args(["switch", branch_name, "--guess"])
265 .exec()
266 .map_err(Box::new)?;
267 Ok(())
268}
269
270pub fn get_all(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
281 fetch_with_repo(repo, &[]).context("error fetching branches")?;
282
283 let mut out = vec![];
284 for branch_res in repo.branches(None).context("error enumerating branches")? {
285 let branch = branch_res.context("error getting branch result")?;
286 out.push(Branch::try_from(branch).context("error creating branch from result")?);
287 }
288
289 out.sort_unstable_by(|a, b| b.committer_date_time().cmp(a.committer_date_time()));
290
291 Ok(out)
292}
293
294pub fn get_all_no_redundant(repo: &Repository) -> rootcause::Result<Vec<Branch>> {
304 let mut branches = get_all(repo)?;
305 remove_redundant_remotes(&mut branches);
306 Ok(branches)
307}
308
309pub fn fetch(branches: &[&str]) -> rootcause::Result<()> {
319 let repo_path = Path::new(".");
320 let repo = crate::repo::discover(repo_path)
321 .context("error getting repo for fetching branches")
322 .attach_with(|| format!("path={} branches={branches:?}", repo_path.display()))?;
323 fetch_with_repo(&repo, branches)
324}
325
326pub fn remove_redundant_remotes(branches: &mut Vec<Branch>) {
335 let local_names: HashSet<String> = branches
338 .iter()
339 .filter_map(|b| {
340 if let Branch::Local { name, .. } = b {
341 Some(name.clone())
342 } else {
343 None
344 }
345 })
346 .collect();
347
348 branches.retain(|b| match b {
349 Branch::Local { .. } => true,
350 Branch::Remote { name, .. } => {
351 let short = name.split_once('/').map_or(name.as_str(), |(_, rest)| rest);
352 !local_names.contains(short)
353 }
354 });
355}
356
357#[derive(Clone, Debug)]
359#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
360pub enum Branch {
361 Local {
363 name: String,
365 committer_email: String,
367 committer_date_time: DateTime<Utc>,
369 },
370 Remote {
372 name: String,
374 committer_email: String,
376 committer_date_time: DateTime<Utc>,
378 },
379}
380
381impl Branch {
382 pub fn name(&self) -> &str {
384 match self {
385 Self::Local { name, .. } | Self::Remote { name, .. } => name,
386 }
387 }
388
389 pub fn name_no_origin(&self) -> &str {
391 self.name().trim_start_matches("origin/")
392 }
393
394 pub fn committer_email(&self) -> &str {
396 match self {
397 Self::Local { committer_email, .. } | Self::Remote { committer_email, .. } => committer_email,
398 }
399 }
400
401 pub const fn committer_date_time(&self) -> &DateTime<Utc> {
403 match self {
404 Self::Local {
405 committer_date_time, ..
406 }
407 | Self::Remote {
408 committer_date_time, ..
409 } => committer_date_time,
410 }
411 }
412}
413
414impl<'a> TryFrom<(git2::Branch<'a>, git2::BranchType)> for Branch {
424 type Error = rootcause::Report;
425
426 fn try_from((raw_branch, branch_type): (git2::Branch<'a>, git2::BranchType)) -> Result<Self, Self::Error> {
427 let branch_name = raw_branch
428 .name()?
429 .ok_or_else(|| report!("error invalid branch name UTF-8"))
430 .attach_with(|| format!("branch_name={:?}", raw_branch.name()))?;
431 let committer = raw_branch.get().peel_to_commit()?.committer().to_owned();
432 let committer_email = committer
433 .email()
434 .context("error invalid committer email UTF-8")
435 .attach_with(|| format!("branch_name={branch_name:?}"))?
436 .to_string();
437 let committer_date_time = DateTime::from_timestamp(committer.when().seconds(), 0)
438 .ok_or_else(|| report!("error invalid commit timestamp"))
439 .attach_with(|| format!("seconds={}", committer.when().seconds()))?;
440
441 Ok(match branch_type {
442 git2::BranchType::Local => Self::Local {
443 name: branch_name.to_string(),
444 committer_email,
445 committer_date_time,
446 },
447 git2::BranchType::Remote => Self::Remote {
448 name: branch_name.to_string(),
449 committer_email,
450 committer_date_time,
451 },
452 })
453 }
454}
455
456fn fetch_with_repo(repo: &Repository, branches: &[&str]) -> rootcause::Result<()> {
458 let mut callbacks = RemoteCallbacks::new();
459 callbacks.credentials(|_url, username_from_url, _allowed_types| {
460 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
461 });
462
463 let mut fetch_opts = git2::FetchOptions::new();
464 fetch_opts.remote_callbacks(callbacks);
465
466 repo.find_remote("origin")
467 .context("error finding origin remote")?
468 .fetch(branches, Some(&mut fetch_opts), None)
469 .context("error performing fetch from origin remote")
470 .attach_with(|| format!("branches={branches:?}"))?;
471
472 Ok(())
473}
474
475fn branch_from_reflog_message(message: &str) -> Option<String> {
476 let rest = message
477 .strip_prefix("checkout: moving from ")
478 .or_else(|| message.strip_prefix("switch: moving from "))?;
479 let (_, branch) = rest.rsplit_once(" to ")?;
480 let branch = branch.trim();
481 (!branch.is_empty()).then(|| branch.to_string())
482}
483
484#[cfg(test)]
485mod tests {
486 use git2::Signature;
487 use git2::Time;
488 use rstest::rstest;
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 assert2::assert!(let Ok(result) = Branch::try_from((branch, git2::BranchType::Local)));
528
529 pretty_assertions::assert_eq!(
530 result,
531 Branch::Local {
532 name: "test-branch".to_string(),
533 committer_email: "test@example.com".to_string(),
534 committer_date_time: DateTime::from_timestamp(42, 0).unwrap(),
535 }
536 );
537 }
538
539 #[test]
540 fn test_rename_current_renames_the_current_branch() {
541 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
542
543 assert2::assert!(let Ok(()) = rename_current("renamed", Some(&repo)));
544
545 pretty_assertions::assert_eq!(repo.head().unwrap().shorthand().unwrap(), "renamed");
546 repo.find_branch("renamed", git2::BranchType::Local).unwrap();
547 assert2::assert!(let Err(_) = repo.find_branch("master", git2::BranchType::Local));
548 }
549
550 #[test]
551 fn test_rename_current_fails_when_target_branch_already_exists() {
552 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
553 let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
554 repo.branch("existing", &head_commit, false).unwrap();
555
556 assert2::assert!(let Err(err) = rename_current("existing", Some(&repo)));
557
558 assert!(err.to_string().contains("error renaming current branch"));
559 }
560
561 #[test]
562 fn test_get_at_when_path_is_not_git_repo_returns_none() {
563 let temp_dir = tempfile::TempDir::new().unwrap();
564 let timestamp = DateTime::from_timestamp(10, 0).unwrap();
565
566 let actual = get_at(temp_dir.path(), timestamp);
567
568 pretty_assertions::assert_eq!(actual, None);
569 }
570
571 #[test]
572 fn test_get_at_when_checkout_reflog_exists_returns_destination_branch_before_timestamp() {
573 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
574 append_head_reflog(&repo, 10, "checkout: moving from master to feature/a");
575 append_head_reflog(&repo, 20, "checkout: moving from feature/a to feature/b");
576 append_head_reflog(&repo, 30, "checkout: moving from feature/b to feature/c");
577
578 let actual = get_at(repo.workdir().unwrap(), DateTime::from_timestamp(25, 0).unwrap());
579
580 pretty_assertions::assert_eq!(actual, Some("feature/b".to_string()));
581 }
582
583 #[test]
584 fn test_get_at_when_switch_reflog_exists_returns_destination_branch() {
585 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
586 append_head_reflog(&repo, 10, "switch: moving from feature/a to main");
587
588 let actual = get_at(repo.workdir().unwrap(), DateTime::from_timestamp(10, 0).unwrap());
589
590 pretty_assertions::assert_eq!(actual, Some("main".to_string()));
591 }
592
593 #[test]
594 fn test_get_at_when_reflog_has_no_checkout_or_switch_before_timestamp_returns_none() {
595 let (_temp_dir, repo) = crate::tests::init_test_repo(None);
596 append_head_reflog(&repo, 10, "commit: message");
597 append_head_reflog(&repo, 20, "checkout: moving from main");
598 append_head_reflog(&repo, 30, "switch: moving from main to ");
599
600 let actual = get_at(repo.workdir().unwrap(), DateTime::from_timestamp(30, 0).unwrap());
601
602 pretty_assertions::assert_eq!(actual, None);
603 }
604
605 #[rstest]
606 #[case::local_variant(local("main"), "main")]
607 #[case::remote_variant(remote("origin/feature"), "origin/feature")]
608 fn test_branch_name_when_variant_returns_name(#[case] branch: Branch, #[case] expected: &str) {
609 pretty_assertions::assert_eq!(branch.name(), expected);
610 }
611
612 #[rstest]
613 #[case::local_no_origin(local("main"), "main")]
614 #[case::remote_origin_prefix(remote("origin/main"), "main")]
615 #[case::remote_other_prefix(remote("upstream/feature"), "upstream/feature")]
616 fn test_branch_name_no_origin_when_name_returns_trimmed(#[case] branch: Branch, #[case] expected: &str) {
617 pretty_assertions::assert_eq!(branch.name_no_origin(), expected);
618 }
619
620 #[rstest]
621 #[case::local_variant(
622 Branch::Local {
623 name: "test".to_string(),
624 committer_email: "a@b.com".to_string(),
625 committer_date_time: DateTime::from_timestamp(123_456, 0).unwrap(),
626 },
627 DateTime::from_timestamp(123_456, 0).unwrap()
628 )]
629 #[case::remote_variant(
630 Branch::Remote {
631 name: "origin/test".to_string(),
632 committer_email: "a@b.com".to_string(),
633 committer_date_time: DateTime::from_timestamp(654_321, 0).unwrap(),
634 },
635 DateTime::from_timestamp(654_321, 0).unwrap()
636 )]
637 fn branch_committer_date_time_when_variant_returns_date_time(
638 #[case] branch: Branch,
639 #[case] expected: DateTime<Utc>,
640 ) {
641 pretty_assertions::assert_eq!(branch.committer_date_time(), &expected);
642 }
643
644 fn local(name: &str) -> Branch {
645 Branch::Local {
646 name: name.into(),
647 committer_email: String::new(),
648 committer_date_time: DateTime::from_timestamp(0, 0).unwrap(),
649 }
650 }
651
652 fn remote(name: &str) -> Branch {
653 Branch::Remote {
654 name: name.into(),
655 committer_email: String::new(),
656 committer_date_time: DateTime::from_timestamp(0, 0).unwrap(),
657 }
658 }
659
660 fn append_head_reflog(repo: &Repository, timestamp: i64, message: &str) {
661 let oid = repo.head().unwrap().target().unwrap();
662 let sig = Signature::new("test", "test@example.com", &Time::new(timestamp, 0)).unwrap();
663 let mut reflog = repo.reflog("HEAD").unwrap();
664 reflog.append(oid, &sig, Some(message)).unwrap();
665 reflog.write().unwrap();
666 }
667}