pcu/ops/
git_ops.rs

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;
13// use octocrate::repos::list_tags::Query;
14use 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    // fn tag_exists(&self, tag: &str) -> bool {
89    //     let names = self.git_repo.tag_names(Some(tag));
90
91    //     if names.is_err() {
92    //         return false;
93    //     };
94
95    //     let names = names.unwrap();
96
97    //     if names.is_empty() {
98    //         return false;
99    //     }
100
101    //     true
102    // }
103
104    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        // let mut page_number = 1;
126        // let mut more_pages = true;
127        // while more_pages {
128        //     let query = Query {
129        //         per_page: Some(50),
130        //         page: Some(page_number),
131        //     };
132
133        //     let page = self
134        //         .github_rest
135        //         .repos
136        //         .list_tags(self.owner(), self.repo())
137        //         .query(&query)
138        //         .send_with_response()
139        //         .await?;
140
141        //     for t in page.data {
142        //         log::trace!("Tag: {}", t.name);
143        //         if t.name == tag {
144        //             return Ok(t.commit.sha);
145        //         }
146        //     }
147
148        //     if let Some(link) = page.headers.get("link") {
149        //         if let Ok(link) = link.to_str() {
150        //             if !link.contains("rel=\"next\"") {
151        //                 more_pages = false
152        //             };
153        //         }
154        //     } else {
155        //         more_pages = false;
156        //     }
157
158        //     page_number += 1;
159        // }
160
161        // Err(Error::TagNotFound(tag.to_string()))
162    }
163
164    /// Report the status of the git repo in a human readable format
165    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    /// Report a list of the files that have not been staged
174    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    /// Report a list of the files that have not been staged
204    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); // close stdin to not block indefinitely
365                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                // manually advance to the new commit id
395                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        // callbacks.credentials(move |_url, username_from_url, _allowed_types| {
430        //     if let Some(username_from_url) = username_from_url {
431        //         log::trace!("Using username from url: {}", username_from_url);
432        //         Cred::ssh_key_from_agent(username_from_url)
433        //     } else {
434        //         log::trace!("Using ssh key from bot user name: {}", bot_user_name);
435        //         let cred = Cred::ssh_key_from_agent(bot_user_name);
436        //         log::trace!("cred: {}", if cred.is_ok() { "is ok" } else { "is error" });
437        //         cred
438        //     }
439        // });
440        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    /// Rebase the next pr of dependency updates if any
479    #[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        // filter to PRs created by a specific login
498        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
612// This function print out an output similar to git's status command in long
613// form, including the command-line hints.
614fn 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    // Print index changes
623    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    // Print workdir changes to tracked files
679    for entry in statuses.iter() {
680        // With `Status::OPT_INCLUDE_UNMODIFIED` (not used in this example)
681        // `index_to_workdir` may not be `None` even if there are no differences,
682        // in which case it will be a `Delta::Unmodified`.
683        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    // Print untracked files
734    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    // Print ignored files
751    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}