flams_git/
repos.rs

1use git2::{build::CheckoutBuilder, RepositoryOpenFlags};
2use std::path::Path;
3
4use crate::GitUrlExt;
5
6macro_rules! in_git {
7	(($($tt:tt)*); $b:block) => {{
8		let span = ::tracing::debug_span!(parent:&*$crate::REMOTE_SPAN,$($tt)*);
9		span.in_scope(|| $b).map_err(|e| {
10			span.in_scope(|| ::tracing::error!("Error: {e}"));
11			e
12		})
13	}};
14}
15
16pub struct GitRepo(git2::Repository);
17
18impl From<git2::Repository> for GitRepo {
19    #[inline]
20    fn from(r: git2::Repository) -> Self {
21        Self(r)
22    }
23}
24
25pub trait CommitExt {
26    fn into_commit(self) -> flams_backend_types::git::Commit;
27}
28impl CommitExt for git2::Commit<'_> {
29    fn into_commit(self) -> flams_backend_types::git::Commit {
30        let commit = self;
31        let time = commit.time();
32        let author_name = commit
33            .author()
34            .name()
35            .map(ToString::to_string)
36            .unwrap_or_default();
37        flams_backend_types::git::Commit {
38            id: commit.id().to_string(),
39            created_at: chrono::DateTime::from_timestamp(
40                time.seconds() + (i64::from(time.offset_minutes()) * 60),
41                0,
42            )
43            .unwrap_or_else(|| unreachable!()),
44            title: commit
45                .summary()
46                .map(ToString::to_string)
47                .unwrap_or_default(),
48            parent_ids: commit.parent_ids().map(|p| p.to_string()).collect(),
49            message: commit
50                .message()
51                .map(ToString::to_string)
52                .unwrap_or_default(),
53            author_name,
54        }
55    }
56}
57
58const NOTES_NS: &str = "refs/notes/flams";
59
60impl GitRepo {
61    /// #### Errors
62    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
63        let repo = git2::Repository::open_ext(
64            path,
65            RepositoryOpenFlags::NO_SEARCH.intersection(RepositoryOpenFlags::NO_DOTGIT),
66            std::iter::empty::<&std::ffi::OsStr>(),
67        )?;
68        Ok(Self(repo))
69    }
70
71    #[must_use]
72    pub fn is_managed(&self, gl_host: &str) -> Option<git_url_parse::GitUrl> {
73        self.get_origin_url().ok().and_then(|url| {
74            if url.host.as_ref().is_some_and(|h| h == gl_host) {
75                Some(url)
76            } else {
77                None
78            }
79        })
80    }
81
82    /// #### Errors
83    pub fn get_origin_url(&self) -> Result<git_url_parse::GitUrl, git2::Error> {
84        let remote = self.0.find_remote("origin")?;
85        let Some(url) = remote.url() else {
86            return Err(git2::Error::from_str("No origin"));
87        };
88        let mut url =
89            git_url_parse::GitUrl::parse(url).map_err(|e| git2::Error::from_str(&e.to_string()))?;
90        // enforce HTTPS (because oauth; for now)
91        if matches!(
92            url.scheme,
93            git_url_parse::Scheme::Ssh | git_url_parse::Scheme::GitSsh
94        ) {
95            url = url.into_https();
96            self.0.remote_set_url("origin", &url.to_string())?;
97        }
98
99        Ok(url)
100    }
101
102    /// #### Errors
103    pub fn add_note(&self, note: &str) -> Result<(), git2::Error> {
104        let head = self.0.head()?.peel_to_commit()?.id();
105        let sig = self.0.signature()?;
106        self.0.note(&sig, &sig, Some(NOTES_NS), head, note, true)?;
107        Ok(())
108    }
109
110    /// #### Errors
111    pub fn with_latest_note<R>(&self, f: impl FnOnce(&str) -> R) -> Result<Option<R>, git2::Error> {
112        let head = self.0.head()?.peel_to_commit()?.id();
113        self.0
114            .find_note(Some(NOTES_NS), head)
115            .map(|n| n.message().map(f))
116    }
117
118    /// #### Errors
119    #[inline]
120    pub fn clone_from_oauth(
121        token: &str,
122        url: &str,
123        branch: &str,
124        to: &Path,
125        shallow: bool,
126    ) -> Result<Self, git2::Error> {
127        Self::clone("oauth2", token, url, branch, to, shallow)
128    }
129
130    /// #### Errors
131    pub fn clone(
132        user: &str,
133        password: &str,
134        url: &str,
135        branch: &str,
136        to: &Path,
137        shallow: bool,
138    ) -> Result<Self, git2::Error> {
139        use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
140        in_git!(("git clone",url=url,branch=branch); {
141          let _ = std::fs::create_dir_all(to);
142          let mut cbs = RemoteCallbacks::new();
143          cbs.credentials(|_,_,_| Cred::userpass_plaintext(user, password));
144
145          let mut fetch = FetchOptions::new();
146          fetch.remote_callbacks(cbs);
147          if shallow { fetch.depth(1); }
148
149          let repo = RepoBuilder::new()
150            .fetch_options(fetch)
151            .bare(false)
152            .branch(branch)
153            .clone(url,to)?;
154          Ok(repo.into())
155        })
156    }
157
158    /// #### Errors
159    #[inline]
160    pub fn fetch_branch_from_oauth(
161        &self,
162        token: &str,
163        branch: &str,
164        shallow: bool,
165    ) -> Result<(), git2::Error> {
166        self.fetch_branch("oauth2", token, branch, shallow)
167    }
168
169    /// #### Errors
170    pub fn fetch_branch(
171        &self,
172        user: &str,
173        password: &str,
174        branch: &str,
175        shallow: bool,
176    ) -> Result<(), git2::Error> {
177        in_git!(("git fetch",path=%self.0.path().display(),branch=branch); {
178          let mut cbs = git2::RemoteCallbacks::new();
179          cbs.credentials(|_,_,_| git2::Cred::userpass_plaintext(user, password));
180          let mut fetch = git2::FetchOptions::new();
181          fetch.remote_callbacks(cbs);
182          if shallow { fetch.depth(1); }
183          self.0.find_remote("origin")?
184            .fetch(&[branch,&format!("+{NOTES_NS}:{NOTES_NS}")], Some(&mut fetch), None)?;
185          /*
186          let remote = self.0.find_branch(&format!("origin/{branch}"), git2::BranchType::Remote)?;
187          let commit = remote.get().peel_to_commit()?;
188          if let Ok(mut local) = self.0.find_branch(branch, git2::BranchType::Local) {
189            local.get_mut().set_target(commit.id(), "fast forward")?;
190            Ok(())
191          } else {
192            self.0.branch(branch, &commit, false)?.set_upstream(Some(&format!("origin/{branch}")))
193          }*/
194          Ok(())
195        })
196    }
197
198    /// #### Errors
199    #[inline]
200    pub fn push_with_oauth(&self, secret: &str) -> Result<(), git2::Error> {
201        self.push("oauth2", secret)
202    }
203
204    /// #### Errors
205    pub fn push(&self, user: &str, password: &str) -> Result<(), git2::Error> {
206        in_git!(("git push",path=%self.0.path().display()); {
207          let head = self.0.head()?;
208          if !head.is_branch() { return Err(git2::Error::from_str("no branch checked out")); }
209          let Some(branch_name) = head.shorthand() else {
210            return Err(git2::Error::from_str("no branch checked out"));
211          };
212          let mut remote = self.0.find_remote("origin")?;
213          let mut cbs = git2::RemoteCallbacks::new();
214          cbs.credentials(|_,_,_| git2::Cred::userpass_plaintext(user, password));
215          let mut opts = git2::PushOptions::new();
216          opts.remote_callbacks(cbs);
217          remote.push(&[
218            format!("+refs/heads/{branch_name}:refs/heads/{branch_name}").as_str(),
219            NOTES_NS
220          ],Some(&mut opts))?;
221          Ok(())
222        })
223    }
224
225    /// #### Errors
226    #[inline]
227    pub fn get_new_commits_with_oauth(
228        &self,
229        token: &str,
230    ) -> Result<Vec<(String, flams_backend_types::git::Commit)>, git2::Error> {
231        self.get_new_commits("oauth2", token)
232    }
233
234    #[allow(dead_code)]
235    fn print_history(&self, commit: &git2::Commit) {
236        println!("commit {:.8}", commit.id());
237        self.walk(commit.clone(), |id| {
238            println!(" - {id:.8}");
239            true
240        });
241        /*
242        let Ok(mut revwalk) = self.0.revwalk() else {return};
243        /*let Ok(_) = revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) else {
244          println!("ERROR SORTING");
245          return
246        };*/
247        revwalk.push(commit.id());
248        println!("commit {}",commit.id());
249        for oid in revwalk {
250          let Ok(id) = oid else {continue};
251          println!(" - {}",id);
252          let Ok(commit) = self.0.find_commit(id) else {
253            println!("NOT FOUND!"); continue
254          };
255          if commit.parent_count() > 1 {
256            print!("Merge:");
257            for i in 0..commit.parent_count() {
258              let Ok(p)  = commit.parent_id(i) else {
259                print!("(MISSING)"); continue
260              };
261              print!(" {:.8}",p);
262            }
263            println!();
264          }
265        }
266         */
267    }
268
269    fn walk(&self, commit: git2::Commit, mut f: impl FnMut(git2::Oid) -> bool) {
270        const MAX_DEPTH: u16 = 500;
271        // safe walking over commit history; compatible with missing commits,
272        // unlike self.0.revwalk()
273        let mut todos = smallvec::SmallVec::<_, 4>::new();
274        let mut checked = 0;
275        todos.push(commit);
276        while let Some(next) = todos.pop() {
277            checked += 1;
278            if checked > MAX_DEPTH {
279                return;
280            }
281            //tracing::info!("Walking {} {}",next.id(),todos.len());
282            //let num = next.parent_count();
283            for i in 0..next.parent_count() {
284                let Ok(id) = next.parent_id(i) else { continue };
285                if !f(id) {
286                    return;
287                }
288                if let Ok(commit) = self.0.find_commit(id) {
289                    todos.push(commit);
290                }
291            }
292        }
293    }
294
295    /// #### Errors
296    pub fn get_new_commits(
297        &self,
298        user: &str,
299        password: &str,
300    ) -> Result<Vec<(String, flams_backend_types::git::Commit)>, git2::Error> {
301        in_git!(("get new commits",path=%self.0.path().display()); {
302          let mut remote = self.0.find_remote("origin")?;
303          let mut cbs = git2::RemoteCallbacks::new();
304          cbs.credentials(|_,_,_| git2::Cred::userpass_plaintext(user,password));
305          tracing::debug!("Fetching new commits");
306          remote.fetch(&[
307              "+refs/heads/*:refs/remotes/origin/*",
308              &format!("{NOTES_NS}:{NOTES_NS}")
309            ],Some(
310              git2::FetchOptions::new().remote_callbacks(cbs)
311            ),None)?;
312          tracing::debug!("Fetching done.");
313          let head = self.0.head()?.peel_to_commit()?;
314          /*let Some(s) = self.get_managed()? else {
315            return Ok(Vec::new())
316          };
317          let Some((_,managed)) = s.split_once(';') else {
318            return Err(git2::Error::from_str("unexpected git note on release branch"))
319          };
320          let managed_id = git2::Oid::from_str(managed)?;
321          let managed = self.0.find_commit(managed_id)?;*/
322            let head_id = head.id();
323          let mut new_commits = Vec::new();
324          for branch in self.0.branches(Some(git2::BranchType::Remote))? {
325            let (branch,_) = branch?;
326            let Some(branch_name) = branch.name()? else {continue};
327            if branch_name == "origin/HEAD" /*|| branch_name == "origin/release"*/ {continue}
328            let branch_name = branch_name.strip_prefix("origin/").unwrap_or(branch_name);
329            let tip_commit = branch.get().peel_to_commit()?;
330            if tip_commit.id() == head_id/*managed_id*/ { continue }
331            let mut found = false;
332            self.walk(tip_commit.clone(),|id|
333              if id == head_id {found = true;false} else {true}
334            );
335            if found {
336              new_commits.push((branch_name.to_string(),tip_commit.into_commit()));
337            }
338          }
339          Ok(new_commits)
340        })
341
342        /*
343
344        let mut history = HSet::default();
345        history.insert(head.id());
346        self.walk(head.clone(),|id| {history.insert(id);true});
347
348        let mut new_commits = Vec::new();
349        for branch in self.0.branches(Some(git2::BranchType::Remote))? {
350          let (branch,_) = branch?;
351          let Some(branch_name) = branch.name()? else {continue};
352          if branch_name == "origin/HEAD" {continue}
353          let branch_name = branch_name.strip_prefix("origin/").unwrap_or(branch_name);
354          let tip_commit = branch.get().peel_to_commit()?;
355          if history.contains(&tip_commit.id()) { continue }
356          let mut found = false;
357          self.walk(tip_commit.clone(),|id|
358            if history.contains(&id) {found = true;false} else {true}
359          );
360          if found {
361            new_commits.push((branch_name.to_string(),tip_commit.into()));
362          }
363        }
364
365        Ok(new_commits)
366         */
367    }
368
369    /*
370    /// #### Errors
371    pub fn release_commit_id(&self) -> Result<String,git2::Error> {
372      let head = self.0.head()?.peel_to_commit()?;
373      let release = self.0.find_branch("release", git2::BranchType::Local)?.get().peel_to_commit()?;
374      if head.id() == release.id() { Ok(head.id().to_string()) }
375      else { Err(git2::Error::from_str("not on release branch")) }
376    }
377     */
378
379    /// #### Errors
380    pub fn current_commit(&self) -> Result<flams_backend_types::git::Commit, git2::Error> {
381        let commit = self.0.head()?.peel_to_commit()?;
382        Ok(commit.into_commit())
383    }
384
385    /// #### Errors
386    pub fn current_commit_on(
387        &self,
388        branch: &str,
389    ) -> Result<flams_backend_types::git::Commit, git2::Error> {
390        let commit = self
391            .0
392            .find_branch(branch, git2::BranchType::Local)?
393            .get()
394            .peel_to_commit()?;
395        Ok(commit.into_commit())
396    }
397    /// #### Errors
398    pub fn current_remote_commit_on(
399        &self,
400        branch: &str,
401    ) -> Result<flams_backend_types::git::Commit, git2::Error> {
402        let commit = self
403            .0
404            .find_branch(&format!("origin/{branch}"), git2::BranchType::Remote)?
405            .get()
406            .peel_to_commit()?;
407        Ok(commit.into_commit())
408    }
409
410    /// #### Errors
411    pub fn commit_all(
412        &self,
413        message: &str,
414    ) -> Result<flams_backend_types::git::Commit, git2::Error> {
415        in_git!(("commit all",path=%self.0.path().display(),commit_message=message); {
416          let mut index = self.0.index()?;
417          //let managed = self.get_managed()?;
418          let id = index.write_tree()?;
419          let tree = self.0.find_tree(id)?;
420          let parent = self.0.head()?.peel_to_commit()?;
421          let sig = self.0.signature()?;
422          let commit = self.0.commit(
423            Some("HEAD"),
424            &sig, &sig,
425            message, &tree, &[&parent]
426          )?;
427          let commit = self.0.find_commit(commit)?;
428          /*if let Some(mg) = managed {
429            self.add_note(&mg)?
430          }*/
431          Ok(commit.into_commit())
432        })
433    }
434
435    /// #### Errors
436    pub fn new_branch(&self, name: &str) -> Result<(), git2::Error> {
437        in_git!(("new branch",path=%self.0.path().display(),branch=name); {
438          let head = self.0.head()?.peel_to_commit()?;
439          let mut branch = self.0.branch(name,&head,false)?;
440          let _ = self.0.find_remote("origin")?;
441          let _ = self.0.reference(
442            &format!("refs/remotes/origin/{name}"),
443            head.id(),
444            false,
445            "create remote branch"
446          )?;
447          branch.set_upstream(Some(&format!("origin/{name}")))?;
448          let Some(name) = branch.get().name() else {
449            return Err(git2::Error::from_str("failed to create branch"));
450          };
451          self.0.set_head(name)?;
452          self.0.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
453        })
454    }
455
456    /// #### Errors
457    pub fn force_checkout(&self, commit: &str) -> Result<(), git2::Error> {
458        in_git!(("checkout",path=%self.0.path().display(),commit=commit); {
459          let id = git2::Oid::from_str(commit)?;
460          let a_commit = self.0.find_annotated_commit(id)?;
461          let commit = self.0.find_commit(id)?;
462          let head = self.0.head()?.peel_to_commit()?;
463          if /*head.id() == commit.id() ||*/ self.0.graph_descendant_of(head.id(), commit.id())? {
464              tracing::debug!("HEAD is descendant of commit!");
465              return Ok(())
466          }
467          let (analysis,_) = self.0.merge_analysis(&[&a_commit])?;
468          if analysis.is_up_to_date() { return Ok(())}
469          if analysis.is_fast_forward() {
470              let head = self.0.head()?;
471              let name = head.name().ok_or_else(|| git2::Error::from_str("No name for HEAD reference"))?;
472              let _ = self.0.reference(name,id,true,"Fast-forward");
473              return self.0.checkout_head(Some(CheckoutBuilder::new().force()));
474          }
475
476          let mut merge_options = git2::MergeOptions::new();
477          merge_options
478            .file_favor(git2::FileFavor::Theirs)
479            .fail_on_conflict(false);
480          tracing::debug!("Merging");
481          self.0.merge(
482            &[&a_commit],
483            Some(&mut merge_options),
484            Some(&mut git2::build::CheckoutBuilder::new()),
485          )?;
486          tracing::debug!("Checking out HEAD");
487          self.0.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
488          let mut index = self.0.index()?;
489          if index.has_conflicts() {
490            tracing::debug!("Index has conflicts now");
491            let mut entries = Vec::new();
492            for conflict in index.conflicts()? {
493              let conflict = conflict?;
494              if let Some(entry) = conflict.their {
495                entries.push(entry);
496              }
497            }
498            for e in entries {
499              index.add(&e)?;
500            }
501            index.write()?;
502          } else {
503              tracing::debug!("No conflicts");
504          }
505
506          if index.has_conflicts() || self.0.state() == git2::RepositoryState::Merge {
507            tracing::debug!("In merge state; commit necessary");
508            let sig = self.0.signature()?;
509            let tree_id = index.write_tree()?;
510            let tree = self.0.find_tree(tree_id)?;
511            self.0.commit(
512              Some("HEAD"),
513              &sig, &sig,
514              &format!("Merge commit {}",commit.id()),
515              &tree,
516              &[&head,&commit]
517            )?;
518          }
519          self.0.cleanup_state()?;
520          Ok(())
521        })
522    }
523
524    /// #### Errors
525    pub fn merge(&self, commit: &str) -> Result<(), git2::Error> {
526        in_git!(("merge",path=%self.0.path().display(),commit=commit); {
527          let id = git2::Oid::from_str(commit)?;
528          let a_commit = self.0.find_annotated_commit(id)?;
529          let commit = self.0.find_commit(id)?;
530          let mut merge_options = git2::MergeOptions::new();
531          merge_options.file_favor(git2::FileFavor::Theirs);
532          let mut checkout_options = git2::build::CheckoutBuilder::new();
533          let parent = self.0.head()?.peel_to_commit()?;
534          self.0.merge(
535            &[&a_commit],
536            Some(&mut merge_options),
537            Some(&mut checkout_options),
538          )?;
539          let sig = self.0.signature()?;
540          let tree_id = self.0.index()?.write_tree()?;
541          let tree = self.0.find_tree(tree_id)?;
542          self.0.commit(
543            Some("HEAD"),
544            &sig, &sig,
545            &format!("Merge commit {}",commit.id()),
546            &tree,
547            &[&parent,&commit]
548          ).map(|_| ())
549        })
550    }
551
552    /// #### Errors
553    pub fn add_dir(&self, path: &Path, force: bool) -> Result<(), git2::Error> {
554        in_git!(("git add",path=%self.0.path().display(),dir=%path.display()); {
555          let mut index = self.0.index()?;
556          for entry in walkdir::WalkDir::new(path)
557            .min_depth(1)
558            .into_iter()
559            .filter_map(Result::ok)
560            .filter(|e| e.file_type().is_file()) {
561              let relative_path = entry.path().strip_prefix(self.0.path().parent().unwrap_or_else(|| unreachable!()))
562                .map_err(|e| git2::Error::from_str(&e.to_string()))?;
563              if force || !self.0.is_path_ignored(relative_path)? {
564                index.add_path(relative_path)?;
565              }
566            }
567          index.write()?;
568          Ok(())
569        })
570    }
571}
572
573/*
574#[test]
575fn test_new_commits() {
576  tracing_subscriber::fmt().init();
577  let repo = GitRepo::open(Path::new("/home/jazzpirate/work/coursetest")).unwrap();
578  let commit = repo.0.find_branch("main", git2::BranchType::Local).unwrap().get().peel_to_commit().unwrap();
579  repo.walk(commit,|_| true);
580}
581   */