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 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 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 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 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 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 #[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 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 #[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 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 Ok(())
191 })
192 }
193
194 #[inline]
196 pub fn push_with_oauth(&self, secret: &str) -> Result<(), git2::Error> {
197 self.push("oauth2", secret)
198 }
199
200 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 #[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 }
264
265 fn walk(&self, commit: git2::Commit, mut f: impl FnMut(git2::Oid) -> bool) {
266 const MAX_DEPTH: u16 = 500;
267 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 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 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 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" {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{ 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 }
364
365 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 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 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 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 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 Ok(commit.into())
419 })
420 }
421
422 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 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 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 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 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