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