pcu_lib/ops/
git_ops.rs

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    /// Report the status of the git repo in a human readable format
136    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    /// Report a list of the files that have not been staged
145    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    /// Report a list of the files that have not been staged
162    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); // close stdin to not block indefinitely
258                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                // manually advance to the new commit id
288                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    /// Rebase the next pr of dependency updates if any
354    #[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        // filter to PRs created by a specfic login
373        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
487// This function print out an output similar to git's status command in long
488// form, including the command-line hints.
489fn 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    // Print index changes
498    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    // Print workdir changes to tracked files
554    for entry in statuses.iter() {
555        // With `Status::OPT_INCLUDE_UNMODIFIED` (not used in this example)
556        // `index_to_workdir` may not be `None` even if there are no differences,
557        // in which case it will be a `Delta::Unmodified`.
558        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    // Print untracked files
609    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    // Print ignored files
626    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}