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 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
22/// Retrieves the default branch name from the Git repository.
23///
24/// Iterates over all configured remotes and returns the branch name pointed to by the first valid
25/// `refs/remotes/{remote}/HEAD` reference.
26///
27/// # Errors
28/// - If the repository cannot be opened.
29/// - If no remote has a valid `HEAD` reference.
30/// - If the branch name cannot be extracted from the reference target.
31pub 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
54/// Get current branch name (fails if HEAD detached).
55///
56/// # Errors
57/// - Repository discovery fails or HEAD is detached.
58pub 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
83/// Returns the branch checked out at or before `timestamp`, inferred from `HEAD` reflog.
84///
85/// Returns [`None`] when `path` is not in a repository, `HEAD` has no usable
86/// reflog, or no checkout/switch entry exists before the timestamp.
87pub 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            // git2 decodes reflog messages lazily; unreadable or missing messages cannot
95            // prove branch history, so this lookup skips only that entry.
96            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
104/// Create a new local branch at current HEAD (no checkout).
105///
106/// # Errors
107/// - Repository discovery, HEAD resolution, or branch creation fails.
108pub 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
133/// Rename the current local branch.
134///
135/// Mirrors `git branch -m <branch_name>` semantics: the rename is not forced,
136/// so it fails when the target branch already exists.
137///
138/// # Errors
139/// - Repository discovery fails.
140/// - HEAD is detached or the current branch cannot be resolved.
141/// - The current local branch cannot be found.
142/// - The branch rename fails.
143pub 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
178/// Pushes a branch to the default remote.
179///
180/// Uses the default remote (determined by the first valid `refs/remotes/{remote}/HEAD` reference)
181/// to push the specified branch.
182///
183/// # Errors
184/// - Repository discovery fails.
185/// - No default remote can be determined.
186/// - The default remote cannot be found.
187/// - Pushing the branch fails.
188pub 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
225/// Returns the name of the previously checked-out branch (`@{-1}`), if any.
226///
227/// Walks the HEAD reflog looking for the most recent checkout/switch entry and
228/// extracts the source branch name from the message.
229///
230/// Returns [`None`] when there is no recorded previous branch (e.g. fresh clone)
231/// or the reflog cannot be read.
232pub 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
243/// Returns the configured Git user email for the repository, if present.
244///
245/// Looks up `user.email` using the repository's config resolution order.
246///
247/// # Errors
248/// - Reading repository configuration fails.
249pub 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
258/// Checkout a branch or detach HEAD.
259///
260/// # Errors
261/// - `git switch` command fails.
262pub 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
270/// Fetches all branches from the 'origin' remote and returns all local and remote [`Branch`]es
271/// sorted by last committer date (most recent first).
272///
273/// # Errors
274/// - The 'origin' remote cannot be found.
275/// - Performing `git fetch` for all branches fails.
276/// - Enumerating branches fails.
277/// - A branch name is not valid UTF-8.
278/// - Resolving the branch tip commit fails.
279/// - Converting the committer timestamp into a [`DateTime`] fails.
280pub 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
294/// Retrieves all branches without redundant remote duplicates.
295///
296/// # Errors
297/// - The 'origin' remote cannot be found.
298/// - Performing `git fetch` for all branches fails.
299/// - Enumerating branches fails.
300/// - A branch name is not valid UTF-8.
301/// - Resolving the branch tip commit fails.
302/// - Converting the committer timestamp into a [`DateTime`] fails.
303pub 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
309/// Fetches the specified branch names from the `origin` remote.
310///
311/// Used before switching to a branch that may only exist remotely
312/// (e.g. derived from a GitHub PR URL).
313///
314/// # Errors
315/// - The repository cannot be discovered.
316/// - The `origin` remote cannot be found.
317/// - Performing `git fetch` for the requested branches fails.
318pub 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
326/// Removes remote branches that have a corresponding local branch of the same
327/// shortened name.
328///
329/// A remote branch is considered redundant if its name after the first `/`
330/// (e.g. `origin/feature-x` -> `feature-x`) matches a local branch name.
331///
332/// After this function returns, each remaining [`Branch::Remote`] has no local
333/// counterpart with the same short name.
334pub fn remove_redundant_remotes(branches: &mut Vec<Branch>) {
335    // Collect local branch names as owned `String`s. An owned `HashSet` is required because
336    // `retain` takes `&mut self`, which conflicts with any `&str` borrows into the same vec.
337    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/// Local or remote branch with metadata about the last commit.
358#[derive(Clone, Debug)]
359#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
360pub enum Branch {
361    /// Local branch (under `refs/heads/`).
362    Local {
363        /// The name of the branch (without refs/heads/ or refs/remotes/ prefix).
364        name: String,
365        /// The email address of the last committer.
366        committer_email: String,
367        /// The date and time when the last commit was made.
368        committer_date_time: DateTime<Utc>,
369    },
370    /// Remote tracking branch (under `refs/remotes/`).
371    Remote {
372        /// The name of the branch (without refs/heads/ or refs/remotes/ prefix).
373        name: String,
374        /// The email address of the last committer.
375        committer_email: String,
376        /// The date and time when the last commit was made.
377        committer_date_time: DateTime<Utc>,
378    },
379}
380
381impl Branch {
382    /// Returns the branch name (no "refs/" prefix).
383    pub fn name(&self) -> &str {
384        match self {
385            Self::Local { name, .. } | Self::Remote { name, .. } => name,
386        }
387    }
388
389    /// Returns the branch name with the "origin/" prefix removed if present.
390    pub fn name_no_origin(&self) -> &str {
391        self.name().trim_start_matches("origin/")
392    }
393
394    /// Returns the email address of the last committer on this branch.
395    pub fn committer_email(&self) -> &str {
396        match self {
397            Self::Local { committer_email, .. } | Self::Remote { committer_email, .. } => committer_email,
398        }
399    }
400
401    /// Returns the timestamp of the last commit on this branch.
402    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
414/// Attempts to convert a libgit2 branch and its type into our [`Branch`] enum.
415///
416/// Extracts the branch name, last committer email and date from the raw branch.
417///
418/// # Errors
419/// - Branch name is not valid UTF-8.
420/// - Resolving the branch tip commit fails.
421/// - Committer email is not valid UTF-8.
422/// - Converting the committer timestamp into a [`DateTime`] fails.
423impl<'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
456/// Fetches branches using a pre-discovered repository, avoiding redundant filesystem walks.
457fn 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}