1use std::{
2 io::Write,
3 path::Path,
4 process::{Command, Stdio},
5};
6
7use clap::ValueEnum;
8use color_eyre::owo_colors::OwoColorize;
9use git2::{
10 BranchType, Cred, Direction, Oid, PushOptions, RemoteCallbacks, Signature, StatusOptions,
11};
12use log::log_enabled;
13use octocrate::repos::list_tags::Query;
14use tracing::instrument;
15
16use crate::client::graphql::GraphQLGetOpenPRs;
17use crate::client::graphql::GraphQLLabelPR;
18use crate::Client;
19use crate::Error;
20
21const GIT_USER_SIGNATURE: &str = "user.signingkey";
22const DEFAULT_COMMIT_MESSAGE: &str = "chore: commit staged files";
23const DEFAULT_REBASE_LOGIN: &str = "renovate";
24
25#[derive(ValueEnum, Debug, Default, Clone)]
26pub enum Sign {
27 #[default]
28 Gpg,
29 None,
30}
31
32pub trait GitOps {
33 fn branch_status(&self) -> Result<String, Error>;
34 fn branch_list(&self) -> Result<String, Error>;
35 fn repo_status(&self) -> Result<String, Error>;
36 fn repo_files_not_staged(&self) -> Result<Vec<String>, Error>;
37 fn repo_files_staged(&self) -> Result<Vec<String>, Error>;
38 fn stage_files(&self, files: Vec<String>) -> Result<(), Error>;
39 fn commit_staged(
40 &self,
41 sign: Sign,
42 commit_message: &str,
43 prefix: &str,
44 tag: Option<&str>,
45 ) -> Result<(), Error>;
46 fn push_commit(&self, prefix: &str, version: Option<&str>, no_push: bool) -> Result<(), Error>;
47 #[allow(async_fn_in_trait)]
48 async fn label_next_pr(
49 &self,
50 author: Option<&str>,
51 label: Option<&str>,
52 desc: Option<&str>,
53 colour: Option<&str>,
54 ) -> Result<Option<String>, Error>;
55 fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error>;
56 fn tag_exists(&self, tag: &str) -> bool;
57 #[allow(async_fn_in_trait)]
58 async fn get_commitish_for_tag(&self, version: &str) -> Result<String, Error>;
59}
60
61impl GitOps for Client {
62 fn create_tag(&self, tag: &str, commit_id: Oid, sig: &Signature) -> Result<(), Error> {
63 let object = self.git_repo.find_object(commit_id, None)?;
64 self.git_repo.tag(tag, &object, sig, tag, true)?;
65
66 let mut revwalk = self.git_repo.revwalk()?;
67 let reference = format!("refs/tags/{tag}");
68 revwalk.push_ref(&reference)?;
69 Ok(())
70 }
71
72 fn tag_exists(&self, tag: &str) -> bool {
73 let names = self.git_repo.tag_names(Some(tag));
74
75 if names.is_err() {
76 return false;
77 };
78
79 let names = names.unwrap();
80
81 if names.is_empty() {
82 return false;
83 }
84
85 true
86 }
87
88 async fn get_commitish_for_tag(&self, tag: &str) -> Result<String, Error> {
89 log::trace!("Get commitish for tag: {tag}");
90 log::trace!(
91 "Get tags for owner {:?} and repo: {:?}",
92 self.owner(),
93 self.repo()
94 );
95
96 let mut page_number = 1;
97 let mut more_pages = true;
98 while more_pages {
99 let query = Query {
100 per_page: Some(50),
101 page: Some(page_number),
102 };
103
104 let page = self
105 .github_rest
106 .repos
107 .list_tags(self.owner(), self.repo())
108 .query(&query)
109 .send_with_response()
110 .await?;
111
112 for t in page.data {
113 log::trace!("Tag: {}", t.name);
114 if t.name == tag {
115 return Ok(t.commit.sha);
116 }
117 }
118
119 if let Some(link) = page.headers.get("link") {
120 if let Ok(link) = link.to_str() {
121 if !link.contains("rel=\"next\"") {
122 more_pages = false
123 };
124 }
125 } else {
126 more_pages = false;
127 }
128
129 page_number += 1;
130 }
131
132 Err(Error::TagNotFound(tag.to_string()))
133 }
134
135 fn repo_status(&self) -> Result<String, Error> {
137 let statuses = self.git_repo.statuses(None)?;
138
139 log::trace!("Repo status length: {:?}", statuses.len());
140
141 Ok(print_long(&statuses))
142 }
143
144 fn repo_files_not_staged(&self) -> Result<Vec<String>, Error> {
146 let mut options = StatusOptions::new();
147 options.show(git2::StatusShow::Workdir);
148 options.include_untracked(true);
149 let statuses = self.git_repo.statuses(Some(&mut options))?;
150
151 log::trace!("Repo status length: {:?}", statuses.len());
152
153 let files: Vec<String> = statuses
154 .iter()
155 .map(|s| s.path().unwrap_or_default().to_string())
156 .collect();
157
158 Ok(files)
159 }
160
161 fn repo_files_staged(&self) -> Result<Vec<String>, Error> {
163 let mut options = StatusOptions::new();
164 options.show(git2::StatusShow::Index);
165 options.include_untracked(true);
166 let statuses = self.git_repo.statuses(Some(&mut options))?;
167
168 log::trace!("Repo status length: {:?}", statuses.len());
169
170 let files: Vec<String> = statuses
171 .iter()
172 .map(|s| s.path().unwrap_or_default().to_string())
173 .collect();
174
175 Ok(files)
176 }
177
178 fn stage_files(&self, files: Vec<String>) -> Result<(), Error> {
179 let mut index = self.git_repo.index()?;
180
181 for file in files {
182 index.add_path(Path::new(&file))?;
183 }
184
185 index.write()?;
186
187 Ok(())
188 }
189
190 fn commit_staged(
191 &self,
192 sign: Sign,
193 commit_message: &str,
194 prefix: &str,
195 tag: Option<&str>,
196 ) -> Result<(), Error> {
197 log::trace!("Commit staged with sign {sign:?}");
198
199 let commit_message = if !self.commit_message.is_empty() {
200 &self.commit_message.clone()
201 } else if !commit_message.is_empty() {
202 commit_message
203 } else {
204 DEFAULT_COMMIT_MESSAGE
205 };
206
207 log::debug!("Commit message: {commit_message}");
208
209 let mut index = self.git_repo.index()?;
210 let tree_id = index.write_tree()?;
211 let head = self.git_repo.head()?;
212 let parent = self.git_repo.find_commit(head.target().unwrap())?;
213 let sig = self.git_repo.signature()?;
214
215 let commit_id = match sign {
216 Sign::None => self.git_repo.commit(
217 Some("HEAD"),
218 &sig,
219 &sig,
220 commit_message,
221 &self.git_repo.find_tree(tree_id)?,
222 &[&parent],
223 )?,
224 Sign::Gpg => {
225 let commit_buffer = self.git_repo.commit_create_buffer(
226 &sig,
227 &sig,
228 commit_message,
229 &self.git_repo.find_tree(tree_id)?,
230 &[&parent],
231 )?;
232 let commit_str = std::str::from_utf8(&commit_buffer).unwrap();
233
234 let signature = self.git_repo.config()?.get_string(GIT_USER_SIGNATURE)?;
235
236 let short_sign = signature[12..].to_string();
237 log::trace!("Signature short: {short_sign}");
238
239 let gpg_args = vec!["--status-fd", "2", "-bsau", signature.as_str()];
240 log::trace!("gpg args: {:?}", gpg_args);
241
242 let mut cmd = Command::new("gpg");
243 cmd.args(gpg_args)
244 .stdin(Stdio::piped())
245 .stdout(Stdio::piped())
246 .stderr(Stdio::piped());
247
248 let mut child = cmd.spawn()?;
249
250 let mut stdin = child.stdin.take().ok_or(Error::Stdin)?;
251 log::trace!("Secured access to stdin");
252
253 log::trace!("Input for signing:\n-----\n{}\n-----", commit_str);
254
255 stdin.write_all(commit_str.as_bytes())?;
256 log::trace!("writing complete");
257 drop(stdin); log::trace!("stdin closed");
259
260 let output = child.wait_with_output()?;
261 log::trace!("secured output");
262
263 if !output.status.success() {
264 let stderr = String::from_utf8_lossy(&output.stderr);
265 log::trace!("stderr: {}", stderr);
266 return Err(Error::Stdout(stderr.to_string()));
267 }
268
269 let stderr = std::str::from_utf8(&output.stderr)?;
270
271 if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
272 return Err(Error::GpgError(
273 "failed to sign data, program gpg failed, SIG_CREATED not seen in stderr"
274 .to_string(),
275 ));
276 }
277 log::trace!("Error checking completed without error");
278
279 let commit_signature = std::str::from_utf8(&output.stdout)?;
280
281 log::trace!("secured signed commit:\n{}", commit_signature);
282
283 let commit_id =
284 self.git_repo
285 .commit_signed(commit_str, commit_signature, Some("gpgsig"))?;
286
287 self.git_repo
289 .head()?
290 .set_target(commit_id, commit_message)?;
291
292 log::trace!("head updated");
293
294 commit_id
295 }
296 };
297
298 if let Some(version_tag) = tag {
299 let version_tag = format!("{prefix}{version_tag}");
300 self.create_tag(&version_tag, commit_id, &sig)?;
301 }
302
303 Ok(())
304 }
305
306 fn push_commit(&self, prefix: &str, version: Option<&str>, no_push: bool) -> Result<(), Error> {
307 log::trace!("version: {version:?} and no_push: {no_push}");
308 let mut remote = self.git_repo.find_remote("origin")?;
309 log::trace!("Pushing changes to {:?}", remote.name());
310 let mut callbacks = RemoteCallbacks::new();
311 callbacks.credentials(|_url, username_from_url, _allowed_types| {
312 Cred::ssh_key_from_agent(username_from_url.unwrap())
313 });
314 let mut connection = remote.connect_auth(Direction::Push, Some(callbacks), None)?;
315 let remote = connection.remote();
316
317 let local_branch = self
318 .git_repo
319 .find_branch(self.branch_or_main(), BranchType::Local)?;
320 log::trace!("Found local branch: {}", local_branch.name()?.unwrap());
321
322 if log_enabled!(log::Level::Trace) {
323 list_tags();
324 };
325
326 let branch_ref = local_branch.into_reference();
327
328 let mut push_refs = vec![branch_ref.name().unwrap()];
329
330 #[allow(unused_assignments)]
331 let mut tag_ref = String::from("");
332
333 if let Some(version_tag) = version {
334 log::trace!("Found version tag: {prefix}{version_tag}");
335 tag_ref = format!("refs/tags/{prefix}{version_tag}");
336 log::trace!("Tag ref: {tag_ref}");
337 push_refs.push(&tag_ref);
338 };
339
340 log::trace!("Push refs: {:?}", push_refs);
341 let mut call_backs = RemoteCallbacks::new();
342 call_backs.push_transfer_progress(progress_bar);
343 let mut push_opts = PushOptions::new();
344 push_opts.remote_callbacks(call_backs);
345
346 if !no_push {
347 remote.push(&push_refs, Some(&mut push_opts))?;
348 }
349
350 Ok(())
351 }
352
353 #[instrument(skip(self))]
355 async fn label_next_pr(
356 &self,
357 author: Option<&str>,
358 label: Option<&str>,
359 desc: Option<&str>,
360 colour: Option<&str>,
361 ) -> Result<Option<String>, Error> {
362 tracing::debug!("Rebase next PR");
363
364 let prs = self.get_open_pull_requests().await?;
365
366 if prs.is_empty() {
367 return Ok(None);
368 };
369
370 tracing::trace!("Found {:?} open PRs", prs);
371
372 let login = if let Some(login) = author {
374 login
375 } else {
376 DEFAULT_REBASE_LOGIN
377 };
378
379 let mut prs: Vec<_> = prs.iter().filter(|pr| pr.login == login).collect();
380
381 if prs.is_empty() {
382 tracing::trace!("Found no open PRs for {login}");
383 return Ok(None);
384 };
385
386 tracing::trace!("Found {:?} open PRs for {login}", prs);
387
388 prs.sort_by(|a, b| a.number.cmp(&b.number));
389 let next_pr = &prs[0];
390
391 tracing::trace!("Next PR: {}", next_pr.number);
392
393 self.add_label_to_pr(next_pr.number, label, desc, colour)
394 .await?;
395
396 Ok(Some(next_pr.number.to_string()))
397 }
398
399 fn branch_list(&self) -> Result<String, Error> {
400 let branches = self.git_repo.branches(None)?;
401
402 let mut output = String::from("\nList of branches:\n");
403 for item in branches {
404 let (branch, branch_type) = item?;
405 output = format!(
406 "{}\n# Branch and type: {:?}\t{:?}",
407 output,
408 branch.name(),
409 branch_type
410 );
411 }
412 output = format!("{}\n", output);
413
414 Ok(output)
415 }
416
417 fn branch_status(&self) -> Result<String, Error> {
418 let branch_remote = self.git_repo.find_branch(
419 format!("origin/{}", self.branch_or_main()).as_str(),
420 git2::BranchType::Remote,
421 )?;
422
423 if branch_remote.get().target() == self.git_repo.head()?.target() {
424 return Ok(format!(
425 "\n\nOn branch {}\nYour branch is up to date with `{}`\n",
426 self.branch_or_main(),
427 branch_remote.name()?.unwrap()
428 ));
429 }
430
431 let local = self.git_repo.head()?.target().unwrap();
432 let remote = branch_remote.get().target().unwrap();
433
434 let (ahead, behind) = self.git_repo.graph_ahead_behind(local, remote)?;
435
436 let output = format!(
437 "Your branch is {} commits ahead and {} commits behind\n",
438 ahead, behind
439 );
440
441 Ok(output)
442 }
443}
444
445fn progress_bar(current: usize, total: usize, bytes: usize) {
446 let percent = (current as f32 / total as f32) * 100.0;
447
448 let percent = percent as u8;
449
450 log::trace!("Calculated percent: {}", percent);
451
452 match percent {
453 10 => log::trace!("{}%", percent),
454 25 => log::trace!("{}%", percent),
455 40 => log::trace!("{}%", percent),
456 55 => log::trace!("{}%", percent),
457 80 => log::trace!("{}%", percent),
458 95 => log::trace!("{}%", percent),
459 100 => log::trace!("{}%", percent),
460 _ => {}
461 }
462
463 log::trace!(
464 "{}:- current: {}, total: {}, bytes: {}",
465 "Push progress".blue().underline().bold(),
466 current.blue().bold(),
467 total.blue().bold(),
468 bytes.blue().bold()
469 );
470}
471
472fn list_tags() {
473 let output = Command::new("ls")
474 .arg("-R")
475 .arg(".git/refs")
476 .output()
477 .expect("ls of the git refs");
478 let stdout = output.stdout;
479 log::trace!("ls: {}", String::from_utf8_lossy(&stdout));
480
481 let out_string = String::from_utf8_lossy(&stdout);
482
483 let files = out_string.split_terminator("\n").collect::<Vec<&str>>();
484 log::trace!("Files: {:#?}", files);
485}
486
487fn print_long(statuses: &git2::Statuses) -> String {
490 let mut header = false;
491 let mut rm_in_workdir = false;
492 let mut changes_in_index = false;
493 let mut changed_in_workdir = false;
494
495 let mut output = String::new();
496
497 for entry in statuses
499 .iter()
500 .filter(|e| e.status() != git2::Status::CURRENT)
501 {
502 if entry.status().contains(git2::Status::WT_DELETED) {
503 rm_in_workdir = true;
504 }
505 let istatus = match entry.status() {
506 s if s.contains(git2::Status::INDEX_NEW) => "new file: ",
507 s if s.contains(git2::Status::INDEX_MODIFIED) => "modified: ",
508 s if s.contains(git2::Status::INDEX_DELETED) => "deleted: ",
509 s if s.contains(git2::Status::INDEX_RENAMED) => "renamed: ",
510 s if s.contains(git2::Status::INDEX_TYPECHANGE) => "typechange:",
511 _ => continue,
512 };
513 if !header {
514 output = format!(
515 "{}\n\
516 # Changes to be committed:
517 # (use \"git reset HEAD <file>...\" to unstage)
518 #",
519 output
520 );
521 header = true;
522 }
523
524 let old_path = entry.head_to_index().unwrap().old_file().path();
525 let new_path = entry.head_to_index().unwrap().new_file().path();
526 match (old_path, new_path) {
527 (Some(old), Some(new)) if old != new => {
528 output = format!(
529 "{}\n#\t{} {} -> {}",
530 output,
531 istatus,
532 old.display(),
533 new.display()
534 );
535 }
536 (old, new) => {
537 output = format!(
538 "{}\n#\t{} {}",
539 output,
540 istatus,
541 old.or(new).unwrap().display()
542 );
543 }
544 }
545 }
546
547 if header {
548 changes_in_index = true;
549 output = format!("{}\n", output);
550 }
551 header = false;
552
553 for entry in statuses.iter() {
555 if entry.status() == git2::Status::CURRENT || entry.index_to_workdir().is_none() {
559 continue;
560 }
561
562 let istatus = match entry.status() {
563 s if s.contains(git2::Status::WT_MODIFIED) => "modified: ",
564 s if s.contains(git2::Status::WT_DELETED) => "deleted: ",
565 s if s.contains(git2::Status::WT_RENAMED) => "renamed: ",
566 s if s.contains(git2::Status::WT_TYPECHANGE) => "typechange:",
567 _ => continue,
568 };
569
570 if !header {
571 output = format!(
572 "{}\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# ",
573 output,
574 if rm_in_workdir { "/rm" } else { "" }
575 );
576 header = true;
577 }
578
579 let old_path = entry.index_to_workdir().unwrap().old_file().path();
580 let new_path = entry.index_to_workdir().unwrap().new_file().path();
581 match (old_path, new_path) {
582 (Some(old), Some(new)) if old != new => {
583 output = format!(
584 "{}\n#\t{} {} -> {}",
585 output,
586 istatus,
587 old.display(),
588 new.display()
589 );
590 }
591 (old, new) => {
592 output = format!(
593 "{}\n#\t{} {}",
594 output,
595 istatus,
596 old.or(new).unwrap().display()
597 );
598 }
599 }
600 }
601
602 if header {
603 changed_in_workdir = true;
604 output = format!("{}\n#\n", output);
605 }
606 header = false;
607
608 for entry in statuses
610 .iter()
611 .filter(|e| e.status() == git2::Status::WT_NEW)
612 {
613 if !header {
614 output = format!(
615 "{}# Untracked files\n# (use \"git add <file>...\" to include in what will be committed)\n#",
616 output
617 );
618 header = true;
619 }
620 let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
621 output = format!("{}\n#\t{}", output, file.display());
622 }
623 header = false;
624
625 for entry in statuses
627 .iter()
628 .filter(|e| e.status() == git2::Status::IGNORED)
629 {
630 if !header {
631 output = format!(
632 "{}\n# Ignored files\n# (use \"git add -f <file>...\" to include in what will be committed)\n#",
633 output
634 );
635 header = true;
636 }
637 let file = entry.index_to_workdir().unwrap().old_file().path().unwrap();
638 output = format!("{}\n#\t{}", output, file.display());
639 }
640
641 if !changes_in_index && changed_in_workdir {
642 output = format!(
643 "{}\n
644 no changes added to commit (use \"git add\" and/or \
645 \"git commit -a\")",
646 output
647 );
648 }
649
650 output
651}