core_lib/git/
repo.rs

1use git2::{Cred, Error, FetchOptions, RemoteCallbacks, Repository};
2use serde::{Deserialize, Serialize};
3
4use crate::{core::watcher::WatchContext, git::remote::find_ssh_key};
5
6#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
7pub struct Repo {
8    pub branches: Branches,
9    pub name: String,
10    pub remote: String,
11}
12
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
14pub struct Branch {
15    pub branch: String,      // the name of this branch
16    pub last_commit: String, // last commit of this branch
17    pub remote: String,      // remote url of the repo
18    pub name: String,        // name of the repo
19                             // (do not use if you want to retrieve the branch name, use .branch instead)
20}
21
22#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
23pub struct Branches {
24    pub branches: Vec<Branch>,
25    pub last_commit: String, // the last commit who triggered a pipeline (used for log)
26    pub last_name: String,   // the last name of the branch who triggered a pipeline
27    pub name: String, // the name of the last branch in branches: Vec<Branch> (used for ps command)
28}
29
30impl Branches {
31    pub fn last_mut(&mut self) -> anyhow::Result<&mut Branch> {
32        if let Some(last) = self.branches.last_mut() {
33            Ok(last)
34        } else {
35            anyhow::bail!("failed to recover branch");
36        }
37    }
38
39    pub fn last(&self) -> anyhow::Result<&Branch> {
40        if let Some(last) = self.branches.last() {
41            Ok(last)
42        } else {
43            anyhow::bail!("failed to recover branch");
44        }
45    }
46
47    pub fn default_last_commit(&self) -> anyhow::Result<String> {
48        let last = self.last()?;
49        Ok(last.last_commit.clone())
50    }
51
52    pub fn try_for_each<F, T>(&mut self, mut f: F) -> anyhow::Result<Vec<T>>
53    where
54        F: FnMut(&mut Branch) -> anyhow::Result<T>,
55    {
56        let mut results = Vec::new();
57
58        for branch in &mut self.branches {
59            results.push(f(branch)?);
60        }
61
62        Ok(results)
63    }
64}
65
66impl From<Vec<Branch>> for Branches {
67    fn from(branches: Vec<Branch>) -> Self {
68        Branches {
69            branches,
70            last_commit: String::default(),
71            name: String::default(),
72            last_name: String::default(),
73        }
74    }
75}
76
77impl From<Branch> for Branches {
78    fn from(branch: Branch) -> Self {
79        Branches {
80            branches: vec![branch],
81            last_commit: String::default(),
82            name: String::default(),
83            last_name: String::default(),
84        }
85    }
86}
87
88impl Repo {
89    pub fn build(branch_names: Vec<String>) -> anyhow::Result<Self> {
90        let repo = Repository::open(".")?;
91        let mut remote = String::new();
92        let mut repo_name = String::new();
93
94        let branches: Vec<Branch> = branch_names
95            .iter()
96            .map(|name| {
97                let (branch, commit) = {
98                    let branch_ref = repo.find_branch(name, git2::BranchType::Remote)?;
99                    let target = branch_ref.get().peel_to_commit()?;
100
101                    let branch_name = branch_ref
102                        .name()?
103                        .ok_or_else(|| Error::from_str("Failed to read branch name"))?
104                        .to_string();
105
106                    (branch_name, target.id().to_string())
107                };
108                remote = repo
109                    .find_remote("origin")?
110                    .url()
111                    .ok_or_else(|| Error::from_str("Remote URL 'origin' not found"))?
112                    .to_string();
113                repo_name = remote
114                    .rsplit('/')
115                    .next()
116                    .and_then(|s| s.strip_suffix(".git").or(Some(s)))
117                    .ok_or_else(|| Error::from_str("Failed to parse repo name from remote URL"))?
118                    .to_string();
119                Ok(Branch {
120                    branch,
121                    last_commit: commit,
122                    remote: remote.clone(),
123                    name: repo_name.clone(),
124                })
125            })
126            .collect::<anyhow::Result<Vec<_>>>()?;
127
128        Ok(Self {
129            branches: branches.into(),
130            remote,
131            name: repo_name,
132        })
133    }
134
135    pub fn default_build() -> anyhow::Result<Self> {
136        let repo = Repository::open(".")?;
137
138        let branch = {
139            let head = repo.head()?;
140            let branch = head
141                .shorthand()
142                .ok_or_else(|| Error::from_str("Failed to read branch name"))?
143                .to_string();
144            let commit_id = head.peel_to_commit()?.id().to_string();
145
146            let remote = repo
147                .find_remote("origin")?
148                .url()
149                .ok_or_else(|| Error::from_str("Remote URL 'origin' not found"))?
150                .to_string();
151
152            let repo_name = remote
153                .rsplit('/')
154                .next()
155                .and_then(|s| s.strip_suffix(".git").or(Some(s)))
156                .ok_or_else(|| Error::from_str("Failed to parse repo name from remote URL"))?
157                .to_string();
158
159            Branch {
160                branch,
161                last_commit: commit_id,
162                remote: remote.clone(),
163                name: repo_name.clone(),
164            }
165        };
166
167        //trash code (only for prototyping)
168
169        let remote = repo
170            .find_remote("origin")?
171            .url()
172            .ok_or_else(|| Error::from_str("Remote URL 'origin' not found"))?
173            .to_string();
174
175        let repo_name = remote
176            .rsplit('/')
177            .next()
178            .and_then(|s| s.strip_suffix(".git").or(Some(s)))
179            .ok_or_else(|| Error::from_str("Failed to parse repo name from remote URL"))?
180            .to_string();
181
182        Ok(Self {
183            branches: branch.into(),
184            remote,
185            name: repo_name,
186        })
187    }
188
189    pub fn pull(repo: &Repository, branch_name: &str) -> anyhow::Result<()> {
190        let ssh_key_path = find_ssh_key()?;
191
192        let mut cb = RemoteCallbacks::new();
193
194        cb.credentials(|_, username_from_url, _| {
195            Cred::ssh_key(username_from_url.unwrap(), None, &ssh_key_path, None)
196        });
197
198        let mut fo = FetchOptions::new();
199        fo.remote_callbacks(cb);
200
201        let mut remote = repo.find_remote("origin")?;
202        remote.fetch(&[branch_name], Some(&mut fo), None)?;
203        Ok(())
204    }
205
206    pub fn switch_branch(ctx: &WatchContext, remote_branch: &str) -> anyhow::Result<()> {
207        Repo::switch_branch_inner(ctx, remote_branch, 0)
208    }
209
210    /// This function behaves like `git checkout`.
211    /// - First, it tries to switch to the branch locally.
212    /// - If the branch is not found locally, it looks for the corresponding remote branch (`origin/<branch>`).
213    /// - If the remote branch exists, it creates a new local branch tracking it and retries.
214    /// - If the remote branch does not exist, it fetches from `origin` and retries once more.
215    /// - If the branch still cannot be found after the allowed number of attempts, it returns an error.
216    fn switch_branch_inner(
217        ctx: &WatchContext,
218        remote_branch: &str,
219        attempt: u8,
220    ) -> anyhow::Result<()> {
221        const MAX_ATTEMPTS: u8 = 2;
222
223        if attempt > MAX_ATTEMPTS {
224            anyhow::bail!(
225                "Unable to find branch `{}` locally or remotely",
226                remote_branch
227            );
228        }
229
230        let repo = Repository::open(&ctx.project_dir)?;
231
232        let branch_name = remote_branch
233            .strip_prefix("origin/")
234            .unwrap_or(remote_branch);
235
236        if repo.head()?.shorthand().unwrap_or_default() == branch_name {
237            // already on the right branch
238            eprintln!("already on the good branch");
239            return Ok(());
240        }
241
242        let branch = repo.find_branch(branch_name, git2::BranchType::Local);
243        match branch {
244            Ok(b) => {
245                let branch_ref = b.get();
246                let commit = branch_ref.peel_to_commit()?;
247                repo.set_head(branch_ref.name().unwrap())?;
248                repo.checkout_tree(commit.as_object(), None)?;
249                Ok(())
250            }
251            Err(_) => {
252                match repo.find_branch(remote_branch, git2::BranchType::Remote) {
253                    Ok(remote_ref) => {
254                        // if remote exists
255                        let target_commit = remote_ref.get().peel_to_commit()?;
256                        repo.branch(branch_name, &target_commit, false)?;
257                        Repo::switch_branch_inner(ctx, remote_branch, attempt + 1)
258                    }
259                    Err(_) => {
260                        // if we dont find remote -> fetch and retry
261                        Repo::pull(&repo, branch_name)?;
262                        Repo::switch_branch_inner(ctx, remote_branch, attempt + 1)
263                    }
264                }
265            }
266        }
267    }
268}