gitops_operator/git/
git.rs

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    // now create the merge commit
42    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    // Do our merge commit and set current branch head to that commit.
47    let _merge_commit = repo.commit(
48        Some("HEAD"),
49        &sig,
50        &sig,
51        &msg,
52        &result_tree,
53        &[&local_commit, &remote_commit],
54    )?;
55    // Set working tree to match head.
56    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    // Prepare fetch options
72    let mut fetch_options = FetchOptions::new();
73    fetch_options.remote_callbacks(callbacks);
74    fetch_options.download_tags(git2::AutotagOption::All);
75
76    // Check if repository already exists
77    if repo_path.exists() {
78        info!(
79            "Repository already exists ({}), pulling...",
80            &repo_path.display()
81        );
82
83        // Open existing repository
84        let repo = Repository::open(&repo_path)?;
85
86        // Fetch changes
87        fetch_existing_repo(&repo, &mut fetch_options, branch)?;
88        pull_repo(&repo, branch)?;
89
90        // Pull changes (merge)
91        Ok(repo)
92    } else {
93        info!(
94            "Repository does not exist, cloning: {}",
95            &repo_path.display()
96        );
97
98        // Clone new repository
99        clone_new_repo(url, &repo_path, fetch_options)
100    }
101}
102
103/// Fetch changes for an existing repository
104fn 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    // Find the origin remote
115    let mut remote = repo.find_remote("origin")?;
116
117    // Fetch all branches
118    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
128/// Clone a new repository
129fn 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    // Prepare repository builder
136    let mut repo_builder = RepoBuilder::new();
137    repo_builder.fetch_options(fetch_options);
138
139    // Clone the repository
140    repo_builder.clone(url, local_path)
141}
142
143/// Pull (merge) changes into the current branch
144fn 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    // Find remote branch
152    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    // Annotated commit for merge
161    let fetch_head = repo.find_reference("FETCH_HEAD")?;
162    let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
163
164    // Perform merge analysis
165    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    // Stage all changes (equivalent to git add .)
206    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    // Create a tree from the index
217    let tree_id = index.write_tree()?;
218    let tree = repo.find_tree(tree_id)?;
219
220    // Get the current head commit
221    let parent_commit = repo.head()?.peel_to_commit()?;
222
223    info!("Parent commit: {}", parent_commit.id());
224
225    // Prepare signature (author and committer)
226    let signature = create_signature()?;
227
228    info!("Author: {}", signature.name().unwrap());
229
230    // Create the commit
231    let commit_oid = repo.commit(
232        Some("HEAD"),      // Update HEAD reference
233        &signature,        // Author
234        &signature,        // Committer
235        commit_message,    // Commit message
236        &tree,             // Tree to commit
237        &[&parent_commit], // Parent commit
238    )?;
239
240    info!("New commit: {}", commit_oid);
241
242    // Prepare push credentials
243    let mut callbacks = RemoteCallbacks::new();
244    callbacks.prepare_callbacks(ssh_key.to_string());
245
246    // Prepare push options
247    let mut push_options = git2::PushOptions::new();
248    push_options.remote_callbacks(callbacks);
249
250    // Find the origin remote
251    let mut remote = repo.find_remote("origin")?;
252
253    info!("Pushing to remote: {}", remote.url().unwrap());
254
255    // We are only watching the master branch at the moment
256    let refspec = "refs/heads/master";
257
258    info!("Pushing to remote branch: {}", &refspec);
259
260    // Push changes
261    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    // Create fetch options with verbose progress
307    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    // Get the remote, with explicit error handling
315    let mut remote = repo.find_remote("origin").map_err(|e| {
316        error!("Error finding remote 'origin': {}", e);
317        e
318    })?;
319
320    // Fetch the latest changes, including all branches
321    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    // Try different branch name variations
334    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                // Convert the commit ID to the appropriate format
345                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    // If we get here, we couldn't find the branch
359    Err(git2::Error::from_str(
360        format!("Could not find {} branch in any expected location", branch).as_str(),
361    ))
362}