Skip to main content

ytil_git/
branch.rs

1//! Branch operations for Git repositories.
2//!
3//! Provides functions for retrieving default and current branch names, creating new branches,
4//! switching branches, fetching branches from remotes, and listing all branches with metadata.
5
6use 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
21/// Retrieves the default branch name from the Git repository.
22///
23/// Iterates over all configured remotes and returns the branch name pointed to by the first valid
24/// `refs/remotes/{remote}/HEAD` reference.
25///
26/// # Errors
27/// - If the repository cannot be opened.
28/// - If no remote has a valid `HEAD` reference.
29/// - If the branch name cannot be extracted from the reference target.
30pub 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
53/// Get current branch name (fails if HEAD detached).
54///
55/// # Errors
56/// - Repository discovery fails or HEAD is detached.
57pub 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
82/// Returns the branch checked out at or before `timestamp`, inferred from `HEAD` reflog.
83///
84/// Returns [`None`] when `path` is not in a repository, `HEAD` has no usable
85/// reflog, or no checkout/switch entry exists before the timestamp.
86pub 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/// Returns the branch checked out at or before each timestamp.
94///
95/// Opens and scans the `HEAD` reflog once for all timestamps.
96#[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            // git2 decodes reflog messages lazily; unreadable or missing messages cannot
118            // prove branch history, so this lookup skips only that entry.
119            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
143/// Create a new local branch at current HEAD (no checkout).
144///
145/// # Errors
146/// - Repository discovery, HEAD resolution, or branch creation fails.
147pub 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
172/// Rename the current local branch.
173///
174/// Mirrors `git branch -m <branch_name>` semantics: the rename is not forced,
175/// so it fails when the target branch already exists.
176///
177/// # Errors
178/// - Repository discovery fails.
179/// - HEAD is detached or the current branch cannot be resolved.
180/// - The current local branch cannot be found.
181/// - The branch rename fails.
182pub 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
217/// Pushes a branch to the default remote.
218///
219/// Uses the default remote (determined by the first valid `refs/remotes/{remote}/HEAD` reference)
220/// to push the specified branch.
221///
222/// # Errors
223/// - Repository discovery fails.
224/// - No default remote can be determined.
225/// - The default remote cannot be found.
226/// - Pushing the branch fails.
227pub 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
264/// Returns the name of the previously checked-out branch (`@{-1}`), if any.
265///
266/// Walks the HEAD reflog looking for the most recent checkout/switch entry and
267/// extracts the source branch name from the message.
268///
269/// Returns [`None`] when there is no recorded previous branch (e.g. fresh clone)
270/// or the reflog cannot be read.
271pub 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
282/// Returns the configured Git user email for the repository, if present.
283///
284/// Looks up `user.email` using the repository's config resolution order.
285///
286/// # Errors
287/// - Reading repository configuration fails.
288pub 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
297/// Checkout a branch or detach HEAD.
298///
299/// # Errors
300/// - `git switch` command fails.
301pub 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
309/// Fetches all branches from the 'origin' remote and returns all local and remote [`Branch`]es
310/// sorted by last committer date (most recent first).
311///
312/// # Errors
313/// - The 'origin' remote cannot be found.
314/// - Performing `git fetch` for all branches fails.
315/// - Enumerating branches fails.
316/// - A branch name is not valid UTF-8.
317/// - Resolving the branch tip commit fails.
318/// - Converting the committer timestamp into a [`Timestamp`] fails.
319pub 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
333/// Retrieves all branches without redundant remote duplicates.
334///
335/// # Errors
336/// - The 'origin' remote cannot be found.
337/// - Performing `git fetch` for all branches fails.
338/// - Enumerating branches fails.
339/// - A branch name is not valid UTF-8.
340/// - Resolving the branch tip commit fails.
341/// - Converting the committer timestamp into a [`Timestamp`] fails.
342pub 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
348/// Fetches the specified branch names from the `origin` remote.
349///
350/// Used before switching to a branch that may only exist remotely
351/// (e.g. derived from a GitHub PR URL).
352///
353/// # Errors
354/// - The repository cannot be discovered.
355/// - The `origin` remote cannot be found.
356/// - Performing `git fetch` for the requested branches fails.
357pub 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
365/// Removes remote branches that have a corresponding local branch of the same
366/// shortened name.
367///
368/// A remote branch is considered redundant if its name after the first `/`
369/// (e.g. `origin/feature-x` -> `feature-x`) matches a local branch name.
370///
371/// After this function returns, each remaining [`Branch::Remote`] has no local
372/// counterpart with the same short name.
373pub fn remove_redundant_remotes(branches: &mut Vec<Branch>) {
374    // Collect local branch names as owned `String`s. An owned `HashSet` is required because
375    // `retain` takes `&mut self`, which conflicts with any `&str` borrows into the same vec.
376    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/// Local or remote branch with metadata about the last commit.
397#[derive(Clone, Debug)]
398#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
399pub enum Branch {
400    /// Local branch (under `refs/heads/`).
401    Local {
402        /// The name of the branch (without refs/heads/ or refs/remotes/ prefix).
403        name: String,
404        /// The email address of the last committer.
405        committer_email: String,
406        /// The date and time when the last commit was made.
407        committer_date_time: Timestamp,
408    },
409    /// Remote tracking branch (under `refs/remotes/`).
410    Remote {
411        /// The name of the branch (without refs/heads/ or refs/remotes/ prefix).
412        name: String,
413        /// The email address of the last committer.
414        committer_email: String,
415        /// The date and time when the last commit was made.
416        committer_date_time: Timestamp,
417    },
418}
419
420impl Branch {
421    /// Returns the branch name (no "refs/" prefix).
422    pub fn name(&self) -> &str {
423        match self {
424            Self::Local { name, .. } | Self::Remote { name, .. } => name,
425        }
426    }
427
428    /// Returns the branch name with the "origin/" prefix removed if present.
429    pub fn name_no_origin(&self) -> &str {
430        self.name().trim_start_matches("origin/")
431    }
432
433    /// Returns the email address of the last committer on this branch.
434    pub fn committer_email(&self) -> &str {
435        match self {
436            Self::Local { committer_email, .. } | Self::Remote { committer_email, .. } => committer_email,
437        }
438    }
439
440    /// Returns the timestamp of the last commit on this branch.
441    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
453/// Attempts to convert a libgit2 branch and its type into our [`Branch`] enum.
454///
455/// Extracts the branch name, last committer email and date from the raw branch.
456///
457/// # Errors
458/// - Branch name is not valid UTF-8.
459/// - Resolving the branch tip commit fails.
460/// - Committer email is not valid UTF-8.
461/// - Converting the committer timestamp into a [`Timestamp`] fails.
462impl<'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
495/// Fetches branches using a pre-discovered repository, avoiding redundant filesystem walks.
496fn 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(), &timestamps);
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}