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