Skip to content

Commit 8052e22

Browse files
committed
refactor: replaced git2-based implemenation of 'git checkout' with gix-based implementation
Signed-off-by: Patrick Casey <patrick.casey1@outlook.com>
1 parent 531e6b7 commit 8052e22

File tree

1 file changed

+93
-193
lines changed

1 file changed

+93
-193
lines changed

hipcheck/src/source/git.rs

Lines changed: 93 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,13 @@
33
//! Git related types and implementations for pulling/cloning source repos.
44
55
use crate::{
6-
error::{Context, Error as HcError, Result as HcResult},
6+
error::{Error as HcError, Result as HcResult},
77
hc_error,
8-
shell::{progress_phase::ProgressPhase, verbosity::Verbosity, Shell},
98
};
10-
use console::Term;
11-
use git2::{
12-
build::{CheckoutBuilder, RepoBuilder},
13-
AnnotatedCommit, Branch, FetchOptions, Progress, Reference, RemoteCallbacks, Repository,
14-
};
15-
use std::{cell::OnceCell, io::Write, path::Path};
169
use gix::{bstr::ByteSlice, refs::FullName, remote, ObjectId};
1710
use std::path::Path;
1811
use url::Url;
1912

20-
/// Construct the remote callbacks object uesd when making callinging into [git2].
21-
fn make_remote_callbacks() -> RemoteCallbacks<'static> {
22-
// Create progress phases for recieving the objects and resolving deltas.
23-
let transfer_phase: OnceCell<ProgressPhase> = OnceCell::new();
24-
let resolution_phase: OnceCell<ProgressPhase> = OnceCell::new();
25-
26-
// Create a struct to hold the callbacks.
27-
let mut callbacks = RemoteCallbacks::new();
28-
29-
// Messages from the remote ("Counting objects" etc) are sent over the sideband.
30-
// This involves clearing and replacing the line -- use console to do this effectively.
31-
32-
match Shell::get_verbosity() {
33-
Verbosity::Normal => {
34-
callbacks.sideband_progress(move |msg: &[u8]| {
35-
Shell::in_suspend(|| {
36-
// use the standard output.
37-
let mut term = Term::stdout();
38-
39-
// Crash on errors here, since they should be relatively uncommon.
40-
term.clear_line().expect("clear line on standard output");
41-
42-
write!(&mut term, "remote: {}", String::from_utf8_lossy(msg))
43-
.expect("wrote to standard output");
44-
45-
term.flush().expect("flushed standard output");
46-
});
47-
48-
true
49-
});
50-
}
51-
Verbosity::Quiet | Verbosity::Silent => {}
52-
}
53-
54-
callbacks.transfer_progress(move |prog: Progress| {
55-
if prog.received_objects() > 0 {
56-
let phase = transfer_phase.get_or_init(|| {
57-
ProgressPhase::start(prog.total_objects() as u64, "(git) receiving objects")
58-
});
59-
60-
phase.set_position(prog.received_objects() as u64);
61-
62-
if prog.received_objects() == prog.total_objects() && !phase.is_finished() {
63-
phase.finish_successful(false);
64-
}
65-
}
66-
67-
if prog.indexed_deltas() > 0 {
68-
let phase = resolution_phase.get_or_init(|| {
69-
ProgressPhase::start(prog.total_deltas() as u64, "(git) resolving deltas")
70-
});
71-
72-
phase.set_position(prog.indexed_deltas() as u64);
73-
74-
if prog.indexed_deltas() == prog.total_deltas() && !phase.is_finished() {
75-
phase.finish_successful(false);
76-
}
77-
}
78-
79-
true
80-
});
81-
82-
callbacks
8313
/// default options to use when fetching a repo with `gix`
8414
fn fetch_options(url: &Url, dest: &Path) -> gix::clone::PrepareFetch {
8515
gix::clone::PrepareFetch::new(
@@ -92,35 +22,53 @@ fn fetch_options(url: &Url, dest: &Path) -> gix::clone::PrepareFetch {
9222
.expect("fetch options must be valid to perform a clone")
9323
}
9424

95-
96-
fn make_checkout_builder() -> CheckoutBuilder<'static> {
97-
// Create a struct to hold callbacks while doing git checkout.
98-
let mut checkout_opts = CheckoutBuilder::new();
99-
100-
// Make a phase to track the checkout progress.
101-
let checkout_phase: OnceCell<ProgressPhase> = OnceCell::new();
102-
103-
// We don't care about the path being resolved, only the total and current numbers.
104-
checkout_opts.progress(move |path, current, total| {
105-
// Initialize the phase if we haven't already.
106-
let phase =
107-
checkout_phase.get_or_init(|| ProgressPhase::start(total as u64, "(git) checkout"));
108-
109-
// Set the bar to have the amount of progress in resolving.
110-
phase.set_position(current as u64);
111-
// Set the progress bar's status to the path being resolved.
112-
phase.update_status(
113-
path.map(Path::to_string_lossy)
114-
.unwrap_or("resolving...".into()),
115-
);
116-
117-
// If we have resolved everything, finish the phase.
118-
if current == total {
119-
phase.finish_successful(false);
120-
}
121-
});
122-
123-
checkout_opts
25+
/// fast-forward HEAD of current repo to a new_commit
26+
///
27+
/// returns new ObjectId (hash) of updated HEAD upon success
28+
fn fast_forward_to_hash(
29+
repo: &gix::Repository,
30+
current_head: gix::Head,
31+
new_object_id: gix::ObjectId,
32+
) -> HcResult<ObjectId> {
33+
let current_id = current_head
34+
.id()
35+
.ok_or_else(|| hc_error!("Could not determine hash of current HEAD"))?;
36+
37+
if current_id == new_object_id {
38+
log::debug!("skipping fast-forward, IDs match");
39+
return Ok(current_id.into());
40+
}
41+
let edit = gix::refs::transaction::RefEdit {
42+
change: gix::refs::transaction::Change::Update {
43+
log: gix::refs::transaction::LogChange {
44+
mode: gix::refs::transaction::RefLog::AndReference,
45+
force_create_reflog: false,
46+
message: format!("fast-forward HEAD from {} to {}", current_id, new_object_id)
47+
.into(),
48+
},
49+
expected: gix::refs::transaction::PreviousValue::Any,
50+
new: gix::refs::Target::Object(new_object_id),
51+
},
52+
name: FullName::try_from("HEAD").unwrap(),
53+
deref: true,
54+
};
55+
log::trace!(
56+
"attempting fast-forward from {} to {}",
57+
current_id,
58+
new_object_id
59+
);
60+
61+
// commit change to the repo and the reflog
62+
repo.refs
63+
.transaction()
64+
.prepare(
65+
[edit],
66+
gix::lock::acquire::Fail::Immediately,
67+
gix::lock::acquire::Fail::Immediately,
68+
)?
69+
.commit(Some(Default::default()))?;
70+
log::trace!("fast-forward successful");
71+
Ok(new_object_id)
12472
}
12573

12674
/// Clone a repo from the given url to a destination path in the filesystem.
@@ -135,113 +83,65 @@ pub fn clone(url: &Url, dest: &Path) -> HcResult<()> {
13583
Ok(())
13684
}
13785

138-
/// For a given repo, checkout a particular ref in a detached HEAD state. If no
139-
/// ref is provided, instead try to resolve the most correct ref to target. If
140-
/// the repo has one branch, try fast-forwarding to match upstream, then set HEAD
141-
/// to top of branch. Else, if the repo has one remote, try to find a local branch
142-
/// tracking the default branch of remote and set HEAD to that. Otherwise, error.
143-
pub fn checkout(repo_path: &Path, refspec: Option<String>) -> HcResult<String> {
144-
// Open the repo with git2.
145-
let repo: Repository = Repository::open(repo_path)?;
146-
// Get the repo's head.
147-
let head: Reference = repo.head()?;
148-
// Get the shortname for later debugging.
149-
let init_short_name = head
150-
.shorthand()
151-
.ok_or(HcError::msg("HEAD shorthand should be UTF-8"))?;
152-
let ret_str: String;
153-
if let Some(refspec_str) = refspec {
154-
// Parse refspec as an annotated commit, and set HEAD based on that
155-
156-
// Try refspec as given
157-
let tgt_ref: AnnotatedCommit = match repo.revparse_single(&refspec_str) {
158-
Ok(object) => repo.find_annotated_commit(object.peel_to_commit()?.id())?,
159-
// If that refspec is not found, try it again with a leading "v"
86+
/// For a given repo, checkout a particular ref in a detached HEAD state.
87+
///
88+
/// 1. If a refspec is passed, then attempt to fast-forward to the specified revision
89+
/// 2. If no ref is provided, then attempt to fast-forward repo to HEAD of the default remote.
90+
/// 3. If there is no default remote, then attempt to set HEAD to match upstream of local branch
91+
///
92+
/// If none of these are possible, then error due to inability to infer target
93+
pub fn checkout(repo_path: &Path, refspec: Option<String>) -> HcResult<gix::ObjectId> {
94+
let repo = gix::open(repo_path)?;
95+
let head = repo.head()?;
96+
97+
// if a refspec was given attempt to resolve it, error if unable to resolve
98+
if let Some(refspec) = refspec {
99+
log::trace!("attempting to find refspec [{}]", refspec);
100+
// try refspec as given
101+
let target = match repo.rev_parse(refspec.as_str()) {
102+
Ok(rev) => {
103+
let oid = rev
104+
.single()
105+
.ok_or_else(|| hc_error!("ref '{}' was not a unique identifier", refspec))?;
106+
repo.find_object(oid)?.id
107+
}
160108
Err(e) => {
161109
return Err(hc_error!(
162110
"Could not find repo with provided refspec: {}",
163111
e
164112
));
165113
}
166114
};
115+
log::trace!("found refspec: {:?}", target);
116+
return fast_forward_to_hash(&repo, head, target);
117+
}
167118

168-
repo.set_head_detached_from_annotated(tgt_ref)?;
169-
ret_str = refspec_str;
170-
} else {
171-
// Get names of remotes
172-
let raw_remotes = repo.remotes()?;
173-
let remotes = raw_remotes.into_iter().flatten().collect::<Vec<&str>>();
174-
let mut local_branches = repo
175-
.branches(Some(git2::BranchType::Local))?
176-
.filter_map(|x| match x {
177-
Ok((b, _)) => Some(b),
178-
_ => None,
179-
})
180-
.collect::<Vec<Branch>>();
181-
if local_branches.len() == 1 {
182-
let mut local_branch = local_branches.remove(0);
183-
// if applicable, update local_branch reference to match newest remote commit
184-
if let Ok(upstr) = local_branch.upstream() {
185-
let remote_ref = upstr.into_reference();
186-
let target_commit = repo
187-
.reference_to_annotated_commit(&remote_ref)
188-
.context("Error creating annotated commit")?;
189-
let reflog_msg = format!(
190-
"Fast-forward {init_short_name} to id: {}",
191-
target_commit.id()
192-
);
193-
// Set the local branch to the given commit
194-
local_branch
195-
.get_mut()
196-
.set_target(target_commit.id(), &reflog_msg)?;
197-
}
198-
// Get branch name in form "refs/heads/<NAME>"
199-
let tgt_ref = local_branch.get();
200-
let local_name = tgt_ref.name().unwrap();
201-
repo.set_head(local_name)?;
202-
ret_str = tgt_ref.shorthand().unwrap_or(local_name).to_owned();
203-
} else if remotes.len() == 1 {
204-
// Get name of default branch for remote
205-
let mut remote = repo.find_remote(remotes.first().unwrap())?;
206-
remote.connect(git2::Direction::Fetch)?;
207-
let default = remote.default_branch()?;
208-
// Get the <NAME> in "refs/heads/<NAME>" for remote
209-
let default_name = default.as_str().unwrap();
210-
let (_, remote_branch_name) = default_name.rsplit_once('/').unwrap();
211-
// Check if any local branches are tracking it
212-
let mut opt_tgt_head: Option<&str> = None;
213-
for branch in local_branches.iter() {
214-
let Ok(upstr) = branch.upstream() else {
215-
continue;
216-
};
217-
// Get the <NAME> in "refs/remote/<REMOTE>/<NAME>"
218-
let upstream_name = upstr.get().name().unwrap();
219-
let (_, upstream_branch_name) = upstream_name.rsplit_once('/').unwrap();
220-
// If the branch names match, we have found our branch
221-
if upstream_branch_name == remote_branch_name {
222-
opt_tgt_head = Some(branch.get().name().unwrap());
223-
break;
224-
}
119+
// Determine if there is a default remote, if there is determine what it thinks HEAD is and
120+
// fast-forward to the remote HEAD
121+
if let Some(Ok(default_remote)) = repo.find_default_remote(remote::Direction::Fetch) {
122+
if let Some(remote_name) = default_remote.name() {
123+
if let Ok(mut remote_head) =
124+
repo.find_reference(format!("refs/remotes/{}/HEAD", remote_name.as_bstr()).as_str())
125+
{
126+
let target = remote_head.peel_to_id_in_place()?;
127+
return fast_forward_to_hash(&repo, head, target.into());
225128
}
226-
let Some(local_name) = opt_tgt_head else {
227-
return Err(HcError::msg(
228-
"could not find local branch tracking remote default",
229-
));
230-
};
231-
repo.set_head(local_name)?;
232-
let head_ref = repo.head()?;
233-
ret_str = head_ref.shorthand().unwrap_or(local_name).to_owned();
234-
} else {
235-
return Err(HcError::msg(
236-
"repo has multiple local branches and remotes, target is ambiguous",
237-
));
238129
}
239130
}
240-
repo.checkout_head(Some(make_checkout_builder().force()))?;
241131

242-
Ok(ret_str)
132+
let mut local_branches = repo.branch_names();
133+
if local_branches.len() == 1 {
134+
let mut local_branch = repo.find_reference(
135+
format!("refs/heads/{}", local_branches.pop_first().unwrap()).as_str(),
136+
)?;
137+
let tip_of_local_branch = local_branch.peel_to_id_in_place()?;
138+
return fast_forward_to_hash(&repo, head, tip_of_local_branch.into());
139+
}
140+
Err(HcError::msg("target is ambiguous"))
243141
}
244142

143+
/// TODO: redo commit history to add support for fetch/clone/checkout separately
144+
/// TODO: add support for visual progress indicators
245145
/// Perform a `git fetch` for all remotes in the repo.
246146
pub fn fetch(repo_path: &Path) -> HcResult<()> {
247147
log::debug!("Fetching: {:?}", repo_path);

0 commit comments

Comments
 (0)