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 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 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 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 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 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 #[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 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 #[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 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 Ok(())
195 })
196 }
197
198 #[inline]
200 pub fn push_with_oauth(&self, secret: &str) -> Result<(), git2::Error> {
201 self.push("oauth2", secret)
202 }
203
204 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 #[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 }
268
269 fn walk(&self, commit: git2::Commit, mut f: impl FnMut(git2::Oid) -> bool) {
270 const MAX_DEPTH: u16 = 500;
271 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 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 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 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" {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{ 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 }
368
369 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 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 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 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 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 Ok(commit.into_commit())
432 })
433 }
434
435 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 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 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 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 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