1use crate::git::utils::create_signature;
2use git2::{
3 build::RepoBuilder, Cred, Error as GitError, FetchOptions, RemoteCallbacks, Repository,
4};
5
6use std::path::{Path, PathBuf};
7
8use tracing::{debug, error, info, warn};
9
10pub trait DefaultCallbacks<'a> {
11 fn prepare_callbacks(&mut self, ssh_key: String) -> &Self;
12}
13
14impl<'a> DefaultCallbacks<'a> for RemoteCallbacks<'a> {
15 fn prepare_callbacks(&mut self, ssh_key: String) -> &Self {
16 self.credentials(move |_url, username_from_url, _allowed_types| {
17 Cred::ssh_key_from_memory(username_from_url.unwrap_or("git"), None, &ssh_key, None)
18 });
19 self
20 }
21}
22
23fn normal_merge(
24 repo: &Repository,
25 local: &git2::AnnotatedCommit,
26 remote: &git2::AnnotatedCommit,
27) -> Result<(), git2::Error> {
28 let local_tree = repo.find_commit(local.id())?.tree()?;
29 let remote_tree = repo.find_commit(remote.id())?.tree()?;
30 let ancestor = repo
31 .find_commit(repo.merge_base(local.id(), remote.id())?)?
32 .tree()?;
33 let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
34
35 if idx.has_conflicts() {
36 warn!("Merge conflicts detected...");
37 repo.checkout_index(Some(&mut idx), None)?;
38 return Ok(());
39 }
40 let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
41 let msg = format!("Merge: {} into {}", remote.id(), local.id());
43 let sig = repo.signature()?;
44 let local_commit = repo.find_commit(local.id())?;
45 let remote_commit = repo.find_commit(remote.id())?;
46 let _merge_commit = repo.commit(
48 Some("HEAD"),
49 &sig,
50 &sig,
51 &msg,
52 &result_tree,
53 &[&local_commit, &remote_commit],
54 )?;
55 repo.checkout_head(None)?;
57 Ok(())
58}
59
60pub fn clone_or_update_repo(
61 url: &str,
62 repo_path: PathBuf,
63 branch: &str,
64 ssh_key: &str,
65) -> Result<Repository, GitError> {
66 info!("Cloning or updating repository from: {}", &url);
67
68 let mut callbacks = RemoteCallbacks::new();
69 callbacks.prepare_callbacks(ssh_key.to_string());
70
71 let mut fetch_options = FetchOptions::new();
73 fetch_options.remote_callbacks(callbacks);
74 fetch_options.download_tags(git2::AutotagOption::All);
75
76 if repo_path.exists() {
78 info!(
79 "Repository already exists ({}), pulling...",
80 &repo_path.display()
81 );
82
83 let repo = Repository::open(&repo_path)?;
85
86 fetch_existing_repo(&repo, &mut fetch_options, branch)?;
88 pull_repo(&repo, branch)?;
89
90 Ok(repo)
92 } else {
93 info!(
94 "Repository does not exist, cloning: {}",
95 &repo_path.display()
96 );
97
98 clone_new_repo(url, &repo_path, fetch_options)
100 }
101}
102
103fn fetch_existing_repo(
105 repo: &Repository,
106 fetch_options: &mut FetchOptions,
107 branch: &str,
108) -> Result<(), GitError> {
109 info!(
110 "Fetching changes for existing repository: {}",
111 &repo.path().display()
112 );
113
114 let mut remote = repo.find_remote("origin")?;
116
117 let refs = &[format!(
119 "refs/heads/{}:refs/remotes/origin/{}",
120 branch, branch
121 )];
122
123 remote.fetch(refs, Some(fetch_options), None)?;
124
125 Ok(())
126}
127
128fn clone_new_repo(
130 url: &str,
131 local_path: &Path,
132 fetch_options: FetchOptions,
133) -> Result<Repository, GitError> {
134 info!("Cloning repository from: {}", &url);
135 let mut repo_builder = RepoBuilder::new();
137 repo_builder.fetch_options(fetch_options);
138
139 repo_builder.clone(url, local_path)
141}
142
143fn pull_repo(repo: &Repository, branch: &str) -> Result<(), GitError> {
145 info!(
146 "Pulling changes into the current branch: {}/{}",
147 &repo.path().display(),
148 &branch
149 );
150
151 let remote_branch_name = format!("remotes/origin/{}", branch);
153
154 info!(
155 "Merging changes from remote branch: {}/{}",
156 &repo.path().display(),
157 &remote_branch_name
158 );
159
160 let fetch_head = repo.find_reference("FETCH_HEAD")?;
162 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
163
164 let (merge_analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
166
167 info!(
168 "Merge analysis for {}, result: {:?}",
169 &repo.path().display(),
170 merge_analysis
171 );
172
173 if merge_analysis.is_fast_forward() {
174 let refname = "refs/remotes/origin/master";
175 let mut reference = repo.find_reference(refname)?;
176 reference.set_target(fetch_commit.id(), "Fast-Forward")?;
177 repo.set_head(refname)?;
178 repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
179
180 Ok(())
181 } else if merge_analysis.is_normal() {
182 let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
183 normal_merge(repo, &head_commit, &fetch_commit)?;
184
185 Ok(())
186 } else if merge_analysis.is_up_to_date() {
187 info!("Repository is up to date: {}", &repo.path().display());
188 Ok(())
189 } else {
190 Err(GitError::from_str("Unsupported merge analysis case"))
191 }
192}
193
194#[tracing::instrument(name = "stage_and_push_changes", skip(repo, ssh_key), fields())]
195pub fn stage_and_push_changes(
196 repo: &Repository,
197 commit_message: &str,
198 ssh_key: &str,
199) -> Result<(), GitError> {
200 info!(
201 "Staging and pushing changes for: {}",
202 &repo.path().display()
203 );
204
205 let mut index = repo.index()?;
207 if index.has_conflicts() {
208 warn!("Merge conflicts detected for {}", &repo.path().display());
209 repo.checkout_index(Some(&mut index), None)?;
210 return Ok(());
211 }
212
213 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
214 index.write()?;
215
216 let tree_id = index.write_tree()?;
218 let tree = repo.find_tree(tree_id)?;
219
220 let parent_commit = repo.head()?.peel_to_commit()?;
222
223 info!("Parent commit: {}", parent_commit.id());
224
225 let signature = create_signature()?;
227
228 info!("Author: {}", signature.name().unwrap());
229
230 let commit_oid = repo.commit(
232 Some("HEAD"), &signature, &signature, commit_message, &tree, &[&parent_commit], )?;
239
240 info!("New commit: {}", commit_oid);
241
242 let mut callbacks = RemoteCallbacks::new();
244 callbacks.prepare_callbacks(ssh_key.to_string());
245
246 let mut push_options = git2::PushOptions::new();
248 push_options.remote_callbacks(callbacks);
249
250 let mut remote = repo.find_remote("origin")?;
252
253 info!("Pushing to remote: {}", remote.url().unwrap());
254
255 let refspec = "refs/heads/master";
257
258 info!("Pushing to remote branch: {}", &refspec);
259
260 remote.push(&[&refspec], Some(&mut push_options))
262}
263
264#[tracing::instrument(name = "clone_repo", skip(ssh_key), fields())]
265pub fn clone_repo(url: &str, local_path: &str, branch: &str, ssh_key: &str) {
266 let repo_path = PathBuf::from(local_path);
267
268 match clone_or_update_repo(url, repo_path, branch, ssh_key) {
269 Ok(_) => info!("Repository successfully updated: {}", &local_path),
270 Err(e) => error!("Error updating repository: {}", e),
271 }
272}
273
274#[tracing::instrument(name = "commit_changes", skip(ssh_key), fields())]
275pub fn commit_changes(manifest_repo_path: &str, ssh_key: &str) -> Result<(), GitError> {
276 let commit_message = "chore(refs): gitops-operator updating image tags";
277 let manifest_repo = Repository::open(manifest_repo_path)?;
278
279 stage_and_push_changes(&manifest_repo, commit_message, ssh_key)
280}
281
282#[tracing::instrument(name = "get_latest_commit", skip(ssh_key), fields())]
283pub fn get_latest_commit(
284 repo_path: &Path,
285 branch: &str,
286 tag_type: &str,
287 ssh_key: &str,
288) -> Result<String, git2::Error> {
289 let repo = Repository::open(repo_path)?;
290
291 debug!("Available branches:");
292 for branch in repo.branches(None)? {
293 let (branch, branch_type) = branch?;
294 debug!(
295 "{} ({:?})",
296 branch.name()?.unwrap_or("invalid utf-8"),
297 branch_type
298 );
299 }
300
301 debug!("Available remotes:");
302 for remote_name in repo.remotes()?.iter() {
303 debug!("{}", remote_name.unwrap_or("invalid utf-8"));
304 }
305
306 let mut fetch_opts = FetchOptions::new();
308
309 let mut callbacks = RemoteCallbacks::new();
310 callbacks.prepare_callbacks(ssh_key.to_string());
311
312 fetch_opts.remote_callbacks(callbacks);
313
314 let mut remote = repo.find_remote("origin").map_err(|e| {
316 error!("Error finding remote 'origin': {}", e);
317 e
318 })?;
319
320 info!("Fetching updates for: {}", &repo_path.display());
322 remote
323 .fetch(
324 &[format!("refs/remotes/origin/{}", &branch)],
325 Some(&mut fetch_opts),
326 None,
327 )
328 .map_err(|e| {
329 error!("Error during fetch: {}", e);
330 e
331 })?;
332
333 let branch_names = [format!("refs/remotes/origin/{}", &branch)];
335
336 for branch_name in &branch_names {
337 info!("Trying to find branch: {}", branch_name);
338
339 match repo.find_reference(branch_name) {
340 Ok(reference) => {
341 let commit = reference.peel_to_commit()?;
342 let commit_id = commit.id();
343
344 info!("Found commit: {} in branch {}", commit_id, branch_name);
346 match tag_type {
347 "short" => return Ok(commit_id.to_string()[..7].to_string()),
348 "long" => return Ok(commit_id.to_string()),
349 _ => Err(git2::Error::from_str(
350 "Invalid tag_type. Must be 'short' or 'long'",
351 )),
352 }?;
353 }
354 Err(e) => error!("Could not find reference {}: {}", branch_name, e),
355 }
356 }
357
358 Err(git2::Error::from_str(
360 format!("Could not find {} branch in any expected location", branch).as_str(),
361 ))
362}