1use std::{
2 io::Write,
3 path::Path,
4 process::{Command, Stdio},
5};
6
7use clap::ValueEnum;
8use git2::{
9 BranchType, Direction, Oid, PushOptions, RemoteCallbacks, Signature, Status, StatusOptions,
10};
11use git2_credentials::CredentialHandler;
12use log::log_enabled;
13use owo_colors::{OwoColorize, Style};
15use tracing::instrument;
16
17use crate::client::graphql::GraphQLLabelPR;
18use crate::client::graphql::{GraphQLGetOpenPRs, GraphQLGetTag};
19use crate::Client;
20use crate::Error;
21
22const GIT_USER_SIGNATURE: &str = "user.signingkey";
23const DEFAULT_COMMIT_MESSAGE: &str = "chore: commit staged files";
24const DEFAULT_REBASE_LOGIN: &str = "renovate";
25
26#[derive(ValueEnum, Debug, Default, Clone)]
27pub enum Sign {
28 #[default]
29 Gpg,
30 None,
31}
32
33pub trait GitOps {
34 fn branch_status(&self) -> Result<String, Error>;
35 fn branch_list(&self) -> Result<String, Error>;
36 fn repo_status(&self) -> Result<String, Error>;
37 fn repo_files_not_staged(&self) -> Result<Vec<(String, Status)>, Error>;
38 fn repo_files_staged(&self) -> Result<Vec<(String, Status)>, Error>;
39 fn stage_files(&self, files: Vec<(String, Status)>) -> Result<(), Error>;
40 #[allow(async_fn_in_trait)]
41 async fn commit_changed_files(
42 &self,
43 sign: Sign,
44 commit_message: &str,
45 prefix: &str,
46 tag_opt: Option<&str>,
47 ) -> Result<(), Error>;
48 fn commit_staged(
49 &self,
50 sign: Sign,
51 commit_message: &str,
52 prefix: &str,
53 tag: Option<&str>,
54 ) -> Result<(), Error>;
55 fn push_commit(
56 &self,
57 prefix: &str,
58 version: Option<&str>,
59 no_push: bool,
60 bot_user_name: &str,
61 ) -> Result<(), Error>;
62 #[allow(async_fn_in_trait)]
63 async fn label_next_pr(
64 &self,
65 author: Option<&str>,
66 label: Option<&str>,
67 desc: Option<&str>,
68 colour: Option<&str>,
69 ) -> Result<Option<String>, Error>;
70 fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error>;
71 #[allow(async_fn_in_trait)]
72 async fn tag_exists(&self, tag: &str) -> bool;
73 #[allow(async_fn_in_trait)]
74 async fn get_commitish_for_tag(&self, version: &str) -> Result<String, Error>;
75}
76
77impl GitOps for Client {
78 fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error> {
79 let object = self.git_repo.find_object(commit_id, None)?;
80 self.git_repo.tag(tag, &object, sig, tag, true)?;
81
82 let mut revwalk = self.git_repo.revwalk()?;
83 let reference = format!("refs/tags/{tag}");
84 revwalk.push_ref(&reference)?;
85 Ok(())
86 }
87
88 async fn tag_exists(&self, tag: &str) -> bool {
105 self.get_tag(tag).await.is_ok()
106 }
107
108 async fn get_commitish_for_tag(&self, tag: &str) -> Result<String, Error> {
109 log::trace!("Get commitish for tag: {tag}");
110 log::trace!(
111 "Get tags for owner {:?} and repo: {:?}",
112 self.owner(),
113 self.repo()
114 );
115
116 let tags = self.get_tag(tag).await?;
117
118 if let Some(commit_id) = tags.commit_sha() {
119 log::trace!("Commit id: {commit_id}");
120 Ok(commit_id)
121 } else {
122 Err(Error::TagNotFound(tag.to_string()))
123 }
124
125 }
163
164 fn repo_status(&self) -> Result<String, Error> {
166 let statuses = self.git_repo.statuses(None)?;
167
168 log::trace!("Repo status length: {:?}", statuses.len());
169
170 Ok(print_long(&statuses))
171 }
172
173 fn repo_files_not_staged(&self) -> Result<Vec<(String, Status)>, Error> {
175 let mut options = StatusOptions::new();
176 options
177 .show(git2::StatusShow::Workdir)
178 .include_untracked(true)
179 .recurse_untracked_dirs(true);
180 log::trace!("Options: Show Workdir, include untracked, recurse ignored dirs");
181
182 let statuses = self.git_repo.statuses(Some(&mut options))?;
183
184 log::trace!("Repo status length: {:?}", statuses.len());
185 if log::log_enabled!(log::Level::Trace) {
186 for status in statuses.iter() {
187 for status in status.status() {
188 log::trace!("Status: {:?}", status);
189 }
190 }
191 }
192
193 let files: Vec<(String, Status)> = statuses
194 .iter()
195 .map(|s| (s.path().unwrap_or_default().to_string(), s.status()))
196 .collect();
197
198 log::trace!("Files: {:#?}", files);
199
200 Ok(files)
201 }
202
203 fn repo_files_staged(&self) -> Result<Vec<(String, Status)>, Error> {
205 let mut options = StatusOptions::new();
206 options
207 .show(git2::StatusShow::Index)
208 .include_untracked(true)
209 .recurse_untracked_dirs(true);
210 log::trace!("Options: Show Index, include untracked, recurse ignored dirs");
211
212 let statuses = self.git_repo.statuses(Some(&mut options))?;
213
214 log::trace!("Repo status length: {:?}", statuses.len());
215
216 let files: Vec<(String, Status)> = statuses
217 .iter()
218 .map(|s| (s.path().unwrap_or_default().to_string(), s.status()))
219 .collect();
220
221 Ok(files)
222 }
223
224 fn stage_files(&self, files: Vec<(String, Status)>) -> Result<(), Error> {
225 let mut index = self.git_repo.index()?;
226
227 for file in files {
228 match file.1 {
229 Status::INDEX_NEW
230 | Status::INDEX_MODIFIED
231 | Status::WT_NEW
232 | Status::WT_MODIFIED => {
233 index.add_path(Path::new(&file.0))?;
234 }
235 Status::INDEX_DELETED | Status::WT_DELETED => {
236 index.remove_path(Path::new(&file.0))?;
237 }
238 _ => {}
239 }
240 }
241
242 index.write()?;
243
244 Ok(())
245 }
246
247 async fn commit_changed_files(
248 &self,
249 sign: Sign,
250 commit_message: &str,
251 prefix: &str,
252 tag_opt: Option<&str>,
253 ) -> Result<(), Error> {
254 let hdr_style = Style::new().bold().underline();
255 log::debug!("{}", "Check WorkDir".style(hdr_style));
256
257 let files_in_workdir = self.repo_files_not_staged()?;
258
259 log::debug!("WorkDir files:\n\t{:?}", files_in_workdir);
260 log::debug!("Staged files:\n\t{:?}", self.repo_files_staged()?);
261 log::debug!("Branch status: {}", self.branch_status()?);
262
263 log::info!("Stage the changes for commit");
264
265 self.stage_files(files_in_workdir)?;
266
267 log::debug!("{}", "Check Staged".style(hdr_style));
268 log::debug!("WorkDir files:\n\t{:?}", self.repo_files_not_staged()?);
269
270 let files_staged_for_commit = self.repo_files_staged()?;
271
272 log::debug!("Staged files:\n\t{:?}", files_staged_for_commit);
273 log::debug!("Branch status: {}", self.branch_status()?);
274
275 log::info!(
276 "Commit the staged changes with commit message: {}",
277 commit_message
278 );
279
280 self.commit_staged(sign, commit_message, prefix, tag_opt)?;
281
282 log::debug!("{}", "Check Committed".style(hdr_style));
283 log::debug!("WorkDir files:\n\t{:?}", self.repo_files_not_staged()?);
284
285 let files_staged_for_commit = self.repo_files_staged()?;
286
287 log::debug!("Staged files:\n\t{:?}", files_staged_for_commit);
288 log::debug!("Branch status: {}", self.branch_status()?);
289
290 Ok(())
291 }
292
293 fn commit_staged(
294 &self,
295 sign: Sign,
296 commit_message: &str,
297 prefix: &str,
298 tag: Option<&str>,
299 ) -> Result<(), Error> {
300 log::trace!("Commit staged with sign {sign:?}");
301
302 log::trace!("Checking commit message: `{commit_message}`");
303 let commit_message = if !commit_message.is_empty() {
304 log::trace!("Using commit message passed to the function");
305 commit_message
306 } else if !self.commit_message.is_empty() {
307 log::trace!("Using commit message set in the calling struct");
308 &self.commit_message.clone()
309 } else {
310 log::trace!("Using default commit message");
311 DEFAULT_COMMIT_MESSAGE
312 };
313
314 log::debug!("Commit message: {commit_message}");
315
316 let mut index = self.git_repo.index()?;
317 let tree_id = index.write_tree()?;
318 let head = self.git_repo.head()?;
319 let parent = self.git_repo.find_commit(head.target().unwrap())?;
320 let sig = self.git_repo.signature()?;
321
322 let commit_id = match sign {
323 Sign::None => self.git_repo.commit(
324 Some("HEAD"),
325 &sig,
326 &sig,
327 commit_message,
328 &self.git_repo.find_tree(tree_id)?,
329 &[&parent],
330 )?,
331 Sign::Gpg => {
332 let commit_buffer = self.git_repo.commit_create_buffer(
333 &sig,
334 &sig,
335 commit_message,
336 &self.git_repo.find_tree(tree_id)?,
337 &[&parent],
338 )?;
339 let commit_str = std::str::from_utf8(&commit_buffer).unwrap();
340
341 let signature = self.git_repo.config()?.get_string(GIT_USER_SIGNATURE)?;
342
343 let short_sign = signature[12..].to_string();
344 log::trace!("Signature short: {short_sign}");
345
346 let gpg_args = vec!["--status-fd", "2", "-bsau", signature.as_str()];
347 log::trace!("gpg args: {:?}", gpg_args);
348
349 let mut cmd = Command::new("gpg");
350 cmd.args(gpg_args)
351 .stdin(Stdio::piped())
352 .stdout(Stdio::piped())
353 .stderr(Stdio::piped());
354
355 let mut child = cmd.spawn()?;
356
357 let mut stdin = child.stdin.take().ok_or(Error::Stdin)?;
358 log::trace!("Secured access to stdin");
359
360 log::trace!("Input for signing:\n-----\n{}\n-----", commit_str);
361
362 stdin.write_all(commit_str.as_bytes())?;
363 log::trace!("writing complete");
364 drop(stdin); log::trace!("stdin closed");
366
367 let output = child.wait_with_output()?;
368 log::trace!("secured output");
369
370 if !output.status.success() {
371 let stderr = String::from_utf8_lossy(&output.stderr);
372 log::trace!("stderr: {}", stderr);
373 return Err(Error::Stdout(stderr.to_string()));
374 }
375
376 let stderr = std::str::from_utf8(&output.stderr)?;
377
378 if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
379 return Err(Error::GpgError(
380 "failed to sign data, program gpg failed, SIG_CREATED not seen in stderr"
381 .to_string(),
382 ));
383 }
384 log::trace!("Error checking completed without error");
385
386 let commit_signature = std::str::from_utf8(&output.stdout)?;
387
388 log::trace!("secured signed commit:\n{}", commit_signature);
389
390 let commit_id =
391 self.git_repo
392 .commit_signed(commit_str, commit_signature, Some("gpgsig"))?;
393
394 self.git_repo
396 .head()?
397 .set_target(commit_id, commit_message)?;
398
399 log::trace!("head updated");
400
401 commit_id
402 }
403 };
404
405 if let Some(version_tag) = tag {
406 let version_tag = format!("{prefix}{version_tag}");
407 self.create_tag(&version_tag, commit_id, &sig)?;
408 }
409
410 Ok(())
411 }
412
413 fn push_commit(
414 &self,
415 prefix: &str,
416 version: Option<&str>,
417 no_push: bool,
418 _bot_user_name: &str,
419 ) -> Result<(), Error> {
420 log::trace!("version: {version:?} and no_push: {no_push}");
421 let mut remote = self.git_repo.find_remote("origin")?;
422 log::trace!("Pushing changes to {:?}", remote.name());
423 let mut callbacks = RemoteCallbacks::new();
424 let git_config = git2::Config::open_default().unwrap();
425 let mut ch = CredentialHandler::new(git_config);
426 callbacks.credentials(move |url, username, allowed| {
427 ch.try_next_credential(url, username, allowed)
428 });
429 let mut connection = remote.connect_auth(Direction::Push, Some(callbacks), None)?;
441 let remote = connection.remote();
442
443 let local_branch = self
444 .git_repo
445 .find_branch(self.branch_or_main(), BranchType::Local)?;
446 log::trace!("Found local branch: {}", local_branch.name()?.unwrap());
447
448 if log_enabled!(log::Level::Trace) {
449 list_tags();
450 };
451
452 let branch_ref = local_branch.into_reference();
453
454 let mut push_refs = vec![branch_ref.name().unwrap()];
455
456 #[allow(unused_assignments)]
457 let mut tag_ref = String::from("");
458
459 if let Some(version_tag) = version {
460 log::trace!("Found version tag: {prefix}{version_tag}");
461 tag_ref = format!("refs/tags/{prefix}{version_tag}");
462 log::trace!("Tag ref: {tag_ref}");
463 push_refs.push(&tag_ref);
464 };
465
466 log::trace!("Push refs: {:?}", push_refs);
467 let mut call_backs = RemoteCallbacks::new();
468 call_backs.push_transfer_progress(progress_bar);
469 let mut push_opts = PushOptions::new();
470 push_opts.remote_callbacks(call_backs);
471
472 if !no_push {
473 remote.push(&push_refs, Some(&mut push_opts))?;
474 }
475
476 Ok(())
477 }
478 #[instrument(skip(self))]
480 async fn label_next_pr(
481 &self,
482 author: Option<&str>,
483 label: Option<&str>,
484 desc: Option<&str>,
485 colour: Option<&str>,
486 ) -> Result<Option<String>, Error> {
487 log::debug!("Rebase next PR");
488
489 let prs = self.get_open_pull_requests().await?;
490
491 if prs.is_empty() {
492 return Ok(None);
493 };
494
495 log::trace!("Found {:?} open PRs", prs);
496
497 let login = if let Some(login) = author {
499 login
500 } else {
501 DEFAULT_REBASE_LOGIN
502 };
503
504 let mut prs: Vec<_> = prs.iter().filter(|pr| pr.login == login).collect();
505
506 if prs.is_empty() {
507 log::trace!("Found no open PRs for {login}");
508 return Ok(None);
509 };
510
511 log::trace!("Found {:?} open PRs for {login}", prs);
512
513 prs.sort_by(|a, b| a.number.cmp(&b.number));
514 let next_pr = &prs[0];
515
516 log::trace!("Next PR: {}", next_pr.number);
517
518 self.add_label_to_pr(next_pr.number, label, desc, colour)
519 .await?;
520
521 Ok(Some(next_pr.number.to_string()))
522 }
523
524 fn branch_list(&self) -> Result<String, Error> {
525 let branches = self.git_repo.branches(None)?;
526
527 let mut output = String::from("\nList of branches:\n");
528 for item in branches {
529 let (branch, branch_type) = item?;
530 output = format!(
531 "{}\n# Branch and type: {:?}\t{:?}",
532 output,
533 branch.name(),
534 branch_type
535 );
536 }
537 output = format!("{}\n", output);
538
539 Ok(output)
540 }
541
542 fn branch_status(&self) -> Result<String, Error> {
543 let branch_remote = self.git_repo.find_branch(
544 format!("origin/{}", self.branch_or_main()).as_str(),
545 git2::BranchType::Remote,
546 )?;
547
548 if branch_remote.get().target() == self.git_repo.head()?.target() {
549 return Ok(format!(
550 "\n\nOn branch {}\nYour branch is up to date with `{}`\n",
551 self.branch_or_main(),
552 branch_remote.name()?.unwrap()
553 ));
554 }
555
556 let local = self.git_repo.head()?.target().unwrap();
557 let remote = branch_remote.get().target().unwrap();
558
559 let (ahead, behind) = self.git_repo.graph_ahead_behind(local, remote)?;
560
561 let output = format!(
562 "Your branch is {} commits ahead and {} commits behind\n",
563 ahead, behind
564 );
565
566 Ok(output)
567 }
568}
569
570fn progress_bar(current: usize, total: usize, bytes: usize) {
571 let percent = (current as f32 / total as f32) * 100.0;
572
573 let percent = percent as u8;
574
575 log::trace!("Calculated percent: {}", percent);
576
577 match percent {
578 10 => log::trace!("{}%", percent),
579 25 => log::trace!("{}%", percent),
580 40 => log::trace!("{}%", percent),
581 55 => log::trace!("{}%", percent),
582 80 => log::trace!("{}%", percent),
583 95 => log::trace!("{}%", percent),
584 100 => log::trace!("{}%", percent),
585 _ => {}
586 }
587
588 log::trace!(
589 "{}:- current: {}, total: {}, bytes: {}",
590 "Push progress".blue().underline().bold(),
591 current.blue().bold(),
592 total.blue().bold(),
593 bytes.blue().bold()
594 );
595}
596
597fn list_tags() {
598 let output = Command::new("ls")
599 .arg("-R")
600 .arg(".git/refs")
601 .output()
602 .expect("ls of the git refs");
603 let stdout = output.stdout;
604 log::trace!("ls: {}", String::from_utf8_lossy(&stdout));
605
606 let out_string = String::from_utf8_lossy(&stdout);
607
608 let files = out_string.split_terminator("\n").collect::<Vec<&str>>();
609 log::trace!("Files: {:#?}", files);
610}
611
612fn print_long(statuses: &git2::Statuses) -> String {
615 let mut header = false;
616 let mut rm_in_workdir = false;
617 let mut changes_in_index = false;
618 let mut changed_in_workdir = false;
619
620 let mut output = String::new();
621
622 for entry in statuses
624 .iter()
625 .filter(|e| e.status() != git2::Status::CURRENT)
626 {
627 if entry.status().contains(git2::Status::WT_DELETED) {
628 rm_in_workdir = true;
629 }
630 let is_status = match entry.status() {
631 s if s.contains(git2::Status::INDEX_NEW) => "new file: ",
632 s if s.contains(git2::Status::INDEX_MODIFIED) => "modified: ",
633 s if s.contains(git2::Status::INDEX_DELETED) => "deleted: ",
634 s if s.contains(git2::Status::INDEX_RENAMED) => "renamed: ",
635 s if s.contains(git2::Status::INDEX_TYPECHANGE) => "typechange:",
636 _ => continue,
637 };
638 if !header {
639 output = format!(
640 "{}\n\
641 # Changes to be committed:
642 # (use \"git reset HEAD <file>...\" to unstage)
643 #",
644 output
645 );
646 header = true;
647 }
648
649 let old_path = entry.head_to_index().unwrap().old_file().path();
650 let new_path = entry.head_to_index().unwrap().new_file().path();
651 match (old_path, new_path) {
652 (Some(old), Some(new)) if old != new => {
653 output = format!(
654 "{}\n#\t{} {} -> {}",
655 output,
656 is_status,
657 old.display(),
658 new.display()
659 );
660 }
661 (old, new) => {
662 output = format!(
663 "{}\n#\t{} {}",
664 output,
665 is_status,
666 old.or(new).unwrap().display()
667 );
668 }
669 }
670 }
671
672 if header {
673 changes_in_index = true;
674 output = format!("{}\n", output);
675 }
676 header = false;
677
678 for entry in statuses.iter() {
680 if entry.status() == git2::Status::CURRENT || entry.index_to_workdir().is_none() {
684 continue;
685 }
686
687 let is_status = match entry.status() {
688 s if s.contains(git2::Status::WT_MODIFIED) => "modified: ",
689 s if s.contains(git2::Status::WT_DELETED) => "deleted: ",
690 s if s.contains(git2::Status::WT_RENAMED) => "renamed: ",
691 s if s.contains(git2::Status::WT_TYPECHANGE) => "typechange:",
692 _ => continue,
693 };
694
695 if !header {
696 output = format!(
697 "{}\n# Changes not staged for commit:\n# (use \"git add{} <file>...\" to update what will be committed)\n# (use \"git checkout -- <file>...\" to discard changes in working directory)\n# ",
698 output,
699 if rm_in_workdir { "/rm" } else { "" }
700 );
701 header = true;
702 }
703
704 let old_path = entry.index_to_workdir().unwrap().old_file().path();
705 let new_path = entry.index_to_workdir().unwrap().new_file().path();
706 match (old_path, new_path) {
707 (Some(old), Some(new)) if old != new => {
708 output = format!(
709 "{}\n#\t{} {} -> {}",
710 output,
711 is_status,
712 old.display(),
713 new.display()
714 );
715 }
716 (old, new) => {
717 output = format!(
718 "{}\n#\t{} {}",
719 output,
720 is_status,
721 old.or(new).unwrap().display()
722 );
723 }
724 }
725 }
726
727 if header {
728 changed_in_workdir = true;
729 output = format!("{}\n#\n", output);
730 }
731 header = false;
732
733 for entry in statuses
735 .iter()
736 .filter(|e| e.status() == git2::Status::WT_NEW)
737 {
738 if !header {
739 output = format!(
740 "{}# Untracked files\n# (use \"git add <file>...\" to include in what will be committed)\n#",
741 output
742 );
743 header = true;
744 }
745 let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
746 output = format!("{}\n#\t{}", output, file.display());
747 }
748 header = false;
749
750 for entry in statuses
752 .iter()
753 .filter(|e| e.status() == git2::Status::IGNORED)
754 {
755 if !header {
756 output = format!(
757 "{}\n# Ignored files\n# (use \"git add -f <file>...\" to include in what will be committed)\n#",
758 output
759 );
760 header = true;
761 }
762 let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
763 output = format!("{}\n#\t{}", output, file.display());
764 }
765
766 if !changes_in_index && changed_in_workdir {
767 output = format!(
768 "{}\n
769 no changes added to commit (use \"git add\" and/or \
770 \"git commit -a\")",
771 output
772 );
773 }
774
775 output
776}