Skip to main content

git2_ext/
ops.rs

1//! Higher-level git operations
2//!
3//! These are closer to what you expect to see for porcelain commands, rather than just plumbing.
4//! They serve as both examples on how to use `git2` but also should be usable in some limited
5//! subset of cases.
6
7use bstr::ByteSlice;
8use itertools::Itertools;
9
10/// Lookup the commit ID for `HEAD`
11pub fn head_id(repo: &git2::Repository) -> Option<git2::Oid> {
12    repo.head().ok()?.resolve().ok()?.target()
13}
14
15/// Lookup the branch that HEAD points to
16pub fn head_branch(repo: &git2::Repository) -> Option<String> {
17    repo.head()
18        .ok()?
19        .resolve()
20        .ok()?
21        .shorthand()
22        .map(String::from)
23        .ok()
24}
25
26/// Report if the working directory is dirty
27pub fn is_dirty(repo: &git2::Repository) -> bool {
28    if repo.state() != git2::RepositoryState::Clean {
29        log::trace!("Repository status is unclean: {:?}", repo.state());
30        return true;
31    }
32
33    let status = repo
34        .statuses(Some(git2::StatusOptions::new().include_ignored(false)))
35        .unwrap();
36    if status.is_empty() {
37        false
38    } else {
39        log::trace!(
40            "Repository is dirty: {}",
41            status
42                .iter()
43                .filter_map(|s| s.path().map(|s| s.to_owned()).ok())
44                .join(", ")
45        );
46        true
47    }
48}
49
50/// Cherry pick a commit onto another without touching the working directory
51pub fn cherry_pick(
52    repo: &git2::Repository,
53    head_id: git2::Oid,
54    cherry_id: git2::Oid,
55    sign: Option<&dyn Sign>,
56) -> Result<git2::Oid, git2::Error> {
57    let cherry_commit = repo.find_commit(cherry_id)?;
58    let base_id = match cherry_commit.parent_count() {
59        0 => cherry_id,
60        1 => cherry_commit.parent_id(0)?,
61        _ => cherry_commit
62            .parent_ids()
63            .find(|id| *id == head_id)
64            .map(Ok)
65            .unwrap_or_else(|| cherry_commit.parent_id(0))?,
66    };
67    if base_id == head_id {
68        // Already on top of the intended base
69        return Ok(cherry_id);
70    }
71
72    let base_ann_commit = repo.find_annotated_commit(base_id)?;
73    let head_ann_commit = repo.find_annotated_commit(head_id)?;
74    let cherry_ann_commit = repo.find_annotated_commit(cherry_id)?;
75    let mut rebase = repo.rebase(
76        Some(&cherry_ann_commit),
77        Some(&base_ann_commit),
78        Some(&head_ann_commit),
79        Some(git2::RebaseOptions::new().inmemory(true)),
80    )?;
81
82    let mut tip_id = head_id;
83    while let Some(op) = rebase.next() {
84        op.inspect_err(|_err| {
85            let _ = rebase.abort();
86        })?;
87        let inmemory_index = rebase.inmemory_index().unwrap();
88        if inmemory_index.has_conflicts() {
89            let conflicts = inmemory_index
90                .conflicts()?
91                .map(|conflict| {
92                    let conflict = conflict.unwrap();
93                    let our_path = conflict
94                        .our
95                        .as_ref()
96                        .map(|c| crate::bytes::bytes2path(&c.path))
97                        .or_else(|| {
98                            conflict
99                                .their
100                                .as_ref()
101                                .map(|c| crate::bytes::bytes2path(&c.path))
102                        })
103                        .or_else(|| {
104                            conflict
105                                .ancestor
106                                .as_ref()
107                                .map(|c| crate::bytes::bytes2path(&c.path))
108                        })
109                        .unwrap_or_else(|| std::path::Path::new("<unknown>"));
110                    format!("{}", our_path.display())
111                })
112                .join("\n  ");
113            return Err(git2::Error::new(
114                git2::ErrorCode::Unmerged,
115                git2::ErrorClass::Index,
116                format!("cherry-pick conflicts:\n  {conflicts}\n"),
117            ));
118        }
119
120        let mut sig = commit_signature(repo)?;
121        if let (Ok(name), Ok(email)) = (sig.name(), sig.email()) {
122            // For simple rebases, preserve the original commit time
123            sig = git2::Signature::new(name, email, &cherry_commit.time())?.to_owned();
124        }
125        let commit_id = rebase.commit(None, &sig, None).inspect_err(|_err| {
126            let _ = rebase.abort();
127        });
128        let commit_id = match commit_id {
129            Ok(commit_id) => Ok(commit_id),
130            Err(err) => {
131                if err.class() == git2::ErrorClass::Rebase && err.code() == git2::ErrorCode::Applied
132                {
133                    log::trace!("Skipping {cherry_id}, already applied to {head_id}");
134                    return Ok(tip_id);
135                }
136                Err(err)
137            }
138        }?;
139
140        let rebased_commit = repo.find_commit(commit_id).expect("commit succeeded");
141        let tree = rebased_commit.tree()?;
142        let parent_commit = repo.find_commit(head_id).expect("it worked earlier");
143        let signed_id = commit(
144            repo,
145            &rebased_commit.author(),
146            &rebased_commit.committer(),
147            rebased_commit.message().unwrap(),
148            &tree,
149            &[&parent_commit],
150            sign,
151        )?;
152
153        tip_id = signed_id;
154    }
155    rebase.finish(None)?;
156    Ok(tip_id)
157}
158
159/// Squash `head_id` into `into_id` without touching the working directory
160///
161/// `into_id`'s author, committer, and message are preserved.
162pub fn squash(
163    repo: &git2::Repository,
164    head_id: git2::Oid,
165    into_id: git2::Oid,
166    sign: Option<&dyn Sign>,
167) -> Result<git2::Oid, git2::Error> {
168    // Based on https://www.pygit2.org/recipes/git-cherry-pick.html
169    let head_commit = repo.find_commit(head_id)?;
170    let head_tree = repo.find_tree(head_commit.tree_id())?;
171
172    let base_commit = if 0 < head_commit.parent_count() {
173        head_commit.parent(0)?
174    } else {
175        head_commit.clone()
176    };
177    let base_tree = repo.find_tree(base_commit.tree_id())?;
178
179    let into_commit = repo.find_commit(into_id)?;
180    let into_tree = repo.find_tree(into_commit.tree_id())?;
181
182    let onto_commit;
183    let onto_commits;
184    let onto_commits: &[&git2::Commit<'_>] = if 0 < into_commit.parent_count() {
185        onto_commit = into_commit.parent(0)?;
186        onto_commits = [&onto_commit];
187        &onto_commits
188    } else {
189        &[]
190    };
191
192    let mut result_index = repo.merge_trees(&base_tree, &into_tree, &head_tree, None)?;
193    if result_index.has_conflicts() {
194        let conflicts = result_index
195            .conflicts()?
196            .map(|conflict| {
197                let conflict = conflict.unwrap();
198                let our_path = conflict
199                    .our
200                    .as_ref()
201                    .map(|c| crate::bytes::bytes2path(&c.path))
202                    .or_else(|| {
203                        conflict
204                            .their
205                            .as_ref()
206                            .map(|c| crate::bytes::bytes2path(&c.path))
207                    })
208                    .or_else(|| {
209                        conflict
210                            .ancestor
211                            .as_ref()
212                            .map(|c| crate::bytes::bytes2path(&c.path))
213                    })
214                    .unwrap_or_else(|| std::path::Path::new("<unknown>"));
215                format!("{}", our_path.display())
216            })
217            .join("\n  ");
218        return Err(git2::Error::new(
219            git2::ErrorCode::Unmerged,
220            git2::ErrorClass::Index,
221            format!("squash conflicts:\n  {conflicts}\n"),
222        ));
223    }
224    let result_id = result_index.write_tree_to(repo)?;
225    let result_tree = repo.find_tree(result_id)?;
226    let new_id = commit(
227        repo,
228        &into_commit.author(),
229        &into_commit.committer(),
230        into_commit.message().unwrap(),
231        &result_tree,
232        onto_commits,
233        sign,
234    )?;
235    Ok(new_id)
236}
237
238/// Reword `head_id`s commit
239pub fn reword(
240    repo: &git2::Repository,
241    head_id: git2::Oid,
242    msg: &str,
243    sign: Option<&dyn Sign>,
244) -> Result<git2::Oid, git2::Error> {
245    let old_commit = repo.find_commit(head_id)?;
246    let parents = old_commit.parents().collect::<Vec<_>>();
247    let parents = parents.iter().collect::<Vec<_>>();
248    let tree = repo.find_tree(old_commit.tree_id())?;
249    let new_id = commit(
250        repo,
251        &old_commit.author(),
252        &old_commit.committer(),
253        msg,
254        &tree,
255        &parents,
256        sign,
257    )?;
258    Ok(new_id)
259}
260
261/// Commit with signing support
262pub fn commit(
263    repo: &git2::Repository,
264    author: &git2::Signature<'_>,
265    committer: &git2::Signature<'_>,
266    message: &str,
267    tree: &git2::Tree<'_>,
268    parents: &[&git2::Commit<'_>],
269    sign: Option<&dyn Sign>,
270) -> Result<git2::Oid, git2::Error> {
271    if let Some(sign) = sign {
272        let content = repo.commit_create_buffer(author, committer, message, tree, parents)?;
273        let content = std::str::from_utf8(&content).unwrap();
274        let signed = sign.sign(content)?;
275        repo.commit_signed(content, &signed, None)
276    } else {
277        repo.commit(None, author, committer, message, tree, parents)
278    }
279}
280
281/// For signing [commit]s
282///
283/// See <https://blog.hackeriet.no/signing-git-commits-in-rust/> for an example of what to do.
284pub trait Sign {
285    fn sign(&self, buffer: &str) -> Result<String, git2::Error>;
286}
287
288pub struct UserSign(UserSignInner);
289
290enum UserSignInner {
291    Gpg(GpgSign),
292    Ssh(SshSign),
293}
294
295impl UserSign {
296    pub fn from_config(
297        repo: &git2::Repository,
298        config: &git2::Config,
299    ) -> Result<Self, git2::Error> {
300        let format = config
301            .get_string("gpg.format")
302            .unwrap_or_else(|_| "openpgp".to_owned());
303        match format.as_str() {
304            "openpgp" => {
305                let program = config
306                    .get_string("gpg.openpgp.program")
307                    .or_else(|_| config.get_string("gpg.program"))
308                    .unwrap_or_else(|_| "gpg".to_owned());
309
310                let signing_key = config.get_string("user.signingkey").or_else(
311                    |_| -> Result<_, git2::Error> {
312                        let sig = commit_signature(repo)?;
313                        Ok(sig.to_string())
314                    },
315                )?;
316
317                Ok(UserSign(UserSignInner::Gpg(GpgSign::new(
318                    program,
319                    signing_key,
320                ))))
321            }
322            "x509" => {
323                let program = config
324                    .get_string("gpg.x509.program")
325                    .unwrap_or_else(|_| "gpgsm".to_owned());
326
327                let signing_key = config.get_string("user.signingkey").or_else(
328                    |_| -> Result<_, git2::Error> {
329                        let sig = commit_signature(repo)?;
330                        Ok(sig.to_string())
331                    },
332                )?;
333
334                Ok(UserSign(UserSignInner::Gpg(GpgSign::new(
335                    program,
336                    signing_key,
337                ))))
338            }
339            "ssh" => {
340                let program = config
341                    .get_string("gpg.ssh.program")
342                    .unwrap_or_else(|_| "ssh-keygen".to_owned());
343
344                let signing_key = config
345                    .get_string("user.signingkey")
346                    .map(Ok)
347                    .unwrap_or_else(|_| -> Result<_, git2::Error> {
348                        get_default_ssh_signing_key(config)?.map(Ok).unwrap_or_else(
349                            || -> Result<_, git2::Error> {
350                                let sig = commit_signature(repo)?;
351                                Ok(sig.to_string())
352                            },
353                        )
354                    })?;
355
356                Ok(UserSign(UserSignInner::Ssh(SshSign::new(
357                    program,
358                    signing_key,
359                ))))
360            }
361            _ => Err(git2::Error::new(
362                git2::ErrorCode::Invalid,
363                git2::ErrorClass::Config,
364                format!("invalid valid for gpg.format: {format}"),
365            )),
366        }
367    }
368}
369
370impl Sign for UserSign {
371    fn sign(&self, buffer: &str) -> Result<String, git2::Error> {
372        match &self.0 {
373            UserSignInner::Gpg(s) => s.sign(buffer),
374            UserSignInner::Ssh(s) => s.sign(buffer),
375        }
376    }
377}
378
379pub struct GpgSign {
380    program: String,
381    signing_key: String,
382}
383
384impl GpgSign {
385    pub fn new(program: String, signing_key: String) -> Self {
386        Self {
387            program,
388            signing_key,
389        }
390    }
391}
392
393impl Sign for GpgSign {
394    fn sign(&self, buffer: &str) -> Result<String, git2::Error> {
395        let output = pipe_command(
396            std::process::Command::new(&self.program)
397                .arg("--status-fd=2")
398                .arg("-bsau")
399                .arg(&self.signing_key),
400            Some(buffer),
401        )
402        .map_err(|e| {
403            git2::Error::new(
404                git2::ErrorCode::GenericError,
405                git2::ErrorClass::Os,
406                format!("{} failed to sign the data: {}", self.program, e),
407            )
408        })?;
409        if !output.status.success() {
410            return Err(git2::Error::new(
411                git2::ErrorCode::GenericError,
412                git2::ErrorClass::Os,
413                format!("{} failed to sign the data", self.program),
414            ));
415        }
416        if output.stderr.find(b"\n[GNUPG:] SIG_CREATED ").is_none() {
417            return Err(git2::Error::new(
418                git2::ErrorCode::GenericError,
419                git2::ErrorClass::Os,
420                format!("{} failed to sign the data", self.program),
421            ));
422        }
423
424        let sig = std::str::from_utf8(&output.stdout).map_err(|e| {
425            git2::Error::new(
426                git2::ErrorCode::GenericError,
427                git2::ErrorClass::Os,
428                format!("{} failed to sign the data: {}", self.program, e),
429            )
430        })?;
431
432        // Strip CR from the line endings, in case we are on Windows.
433        let normalized = remove_cr_after(sig);
434
435        Ok(normalized)
436    }
437}
438
439pub struct SshSign {
440    program: String,
441    signing_key: String,
442}
443
444impl SshSign {
445    pub fn new(program: String, signing_key: String) -> Self {
446        Self {
447            program,
448            signing_key,
449        }
450    }
451}
452
453impl Sign for SshSign {
454    fn sign(&self, buffer: &str) -> Result<String, git2::Error> {
455        let mut literal_key_file = None;
456        let ssh_signing_key_file = if let Some(literal_key) = literal_key(&self.signing_key) {
457            let temp = tempfile::NamedTempFile::new().map_err(|e| {
458                git2::Error::new(
459                    git2::ErrorCode::GenericError,
460                    git2::ErrorClass::Os,
461                    format!("failed writing ssh signing key: {e}"),
462                )
463            })?;
464
465            std::fs::write(temp.path(), literal_key).map_err(|e| {
466                git2::Error::new(
467                    git2::ErrorCode::GenericError,
468                    git2::ErrorClass::Os,
469                    format!("failed writing ssh signing key: {e}"),
470                )
471            })?;
472            let path = temp.path().to_owned();
473            literal_key_file = Some(temp);
474            path
475        } else {
476            fn expanduser(path: &str) -> std::path::PathBuf {
477                // HACK: Need a cross-platform solution
478                std::path::PathBuf::from(path)
479            }
480
481            // We assume a file
482            expanduser(&self.signing_key)
483        };
484
485        let buffer_file = tempfile::NamedTempFile::new().map_err(|e| {
486            git2::Error::new(
487                git2::ErrorCode::GenericError,
488                git2::ErrorClass::Os,
489                format!("failed writing buffer: {e}"),
490            )
491        })?;
492        std::fs::write(buffer_file.path(), buffer).map_err(|e| {
493            git2::Error::new(
494                git2::ErrorCode::GenericError,
495                git2::ErrorClass::Os,
496                format!("failed writing buffer: {e}"),
497            )
498        })?;
499
500        let output = pipe_command(
501            std::process::Command::new(&self.program)
502                .arg("-Y")
503                .arg("sign")
504                .arg("-n")
505                .arg("git")
506                .arg("-f")
507                .arg(&ssh_signing_key_file)
508                .arg(buffer_file.path()),
509            Some(buffer),
510        )
511        .map_err(|e| {
512            git2::Error::new(
513                git2::ErrorCode::GenericError,
514                git2::ErrorClass::Os,
515                format!("{} failed to sign the data: {}", self.program, e),
516            )
517        })?;
518        if !output.status.success() {
519            if output.stderr.find("usage:").is_some() {
520                return Err(git2::Error::new(
521                    git2::ErrorCode::GenericError,
522                    git2::ErrorClass::Os,
523                    "ssh-keygen -Y sign is needed for ssh signing (available in openssh version 8.2p1+)",
524                ));
525            } else {
526                return Err(git2::Error::new(
527                    git2::ErrorCode::GenericError,
528                    git2::ErrorClass::Os,
529                    format!(
530                        "{} failed to sign the data: {}",
531                        self.program,
532                        String::from_utf8_lossy(&output.stderr)
533                    ),
534                ));
535            }
536        }
537
538        let mut ssh_signature_filename = buffer_file.path().as_os_str().to_owned();
539        ssh_signature_filename.push(".sig");
540        let ssh_signature_filename = std::path::PathBuf::from(ssh_signature_filename);
541        let sig = std::fs::read_to_string(&ssh_signature_filename).map_err(|e| {
542            git2::Error::new(
543                git2::ErrorCode::GenericError,
544                git2::ErrorClass::Os,
545                format!(
546                    "failed reading ssh signing data buffer from {}: {}",
547                    ssh_signature_filename.display(),
548                    e
549                ),
550            )
551        })?;
552        // Strip CR from the line endings, in case we are on Windows.
553        let normalized = remove_cr_after(&sig);
554
555        buffer_file.close().map_err(|e| {
556            git2::Error::new(
557                git2::ErrorCode::GenericError,
558                git2::ErrorClass::Os,
559                format!("failed writing buffer: {e}"),
560            )
561        })?;
562        if let Some(literal_key_file) = literal_key_file {
563            literal_key_file.close().map_err(|e| {
564                git2::Error::new(
565                    git2::ErrorCode::GenericError,
566                    git2::ErrorClass::Os,
567                    format!("failed writing ssh signing key: {e}"),
568                )
569            })?;
570        }
571
572        Ok(normalized)
573    }
574}
575
576fn pipe_command(
577    cmd: &mut std::process::Command,
578    stdin: Option<&str>,
579) -> Result<std::process::Output, std::io::Error> {
580    use std::io::Write;
581
582    let mut child = cmd
583        .stdin(if stdin.is_some() {
584            std::process::Stdio::piped()
585        } else {
586            std::process::Stdio::null()
587        })
588        .stdout(std::process::Stdio::piped())
589        .stderr(std::process::Stdio::piped())
590        .spawn()?;
591    if let Some(stdin) = stdin {
592        let mut stdin_sync = child.stdin.take().expect("stdin is piped");
593        write!(stdin_sync, "{stdin}")?;
594    }
595    child.wait_with_output()
596}
597
598fn remove_cr_after(sig: &str) -> String {
599    let mut normalized = String::new();
600    for line in sig.lines() {
601        normalized.push_str(line);
602        normalized.push('\n');
603    }
604    normalized
605}
606
607fn literal_key(signing_key: &str) -> Option<&str> {
608    if let Some(literal) = signing_key.strip_prefix("key::") {
609        Some(literal)
610    } else if signing_key.starts_with("ssh-") {
611        Some(signing_key)
612    } else {
613        None
614    }
615}
616
617// Returns the first public key from an ssh-agent to use for signing
618fn get_default_ssh_signing_key(config: &git2::Config) -> Result<Option<String>, git2::Error> {
619    let ssh_default_key_command = config
620        .get_string("gpg.ssh.defaultKeyCommand")
621        .map_err(|_| {
622            git2::Error::new(
623                git2::ErrorCode::Invalid,
624                git2::ErrorClass::Config,
625                "either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured",
626            )
627        })?;
628    let ssh_default_key_args = shlex::split(&ssh_default_key_command).ok_or_else(|| {
629        git2::Error::new(
630            git2::ErrorCode::Invalid,
631            git2::ErrorClass::Config,
632            format!("malformed gpg.ssh.defaultKeyCommand: {ssh_default_key_command}"),
633        )
634    })?;
635    if ssh_default_key_args.is_empty() {
636        return Err(git2::Error::new(
637            git2::ErrorCode::Invalid,
638            git2::ErrorClass::Config,
639            format!("malformed gpg.ssh.defaultKeyCommand: {ssh_default_key_command}"),
640        ));
641    }
642
643    let Ok(output) = pipe_command(
644        std::process::Command::new(&ssh_default_key_args[0]).args(&ssh_default_key_args[1..]),
645        None,
646    ) else {
647        return Ok(None);
648    };
649
650    let Ok(keys) = std::str::from_utf8(&output.stdout) else {
651        return Ok(None);
652    };
653    let Some((default_key, _)) = keys.split_once('\n') else {
654        return Ok(None);
655    };
656    // We only use `is_literal_ssh_key` here to check validity
657    // The prefix will be stripped when the key is used
658    if literal_key(default_key).is_none() {
659        return Ok(None);
660    }
661
662    Ok(Some(default_key.to_owned()))
663}
664
665#[doc(hidden)]
666#[deprecated(
667    since = "0.4.3",
668    note = "Replaced with `commit_signature`, `author_signature`"
669)]
670pub fn signature(repo: &git2::Repository) -> Result<git2::Signature<'_>, git2::Error> {
671    commit_signature(repo)
672}
673
674/// Lookup the configured committer's signature
675pub fn commit_signature(repo: &git2::Repository) -> Result<git2::Signature<'_>, git2::Error> {
676    let config = repo.config()?;
677    let name = read_signature_field(&config, "GIT_COMMITTER_NAME", "committer.name", "user.name")?;
678    let email = read_signature_field(
679        &config,
680        "GIT_COMMITTER_EMAIL",
681        "committer.email",
682        "user.email",
683    )?;
684
685    git2::Signature::now(&name, &email)
686}
687
688/// Lookup the configured author's signature
689pub fn author_signature(repo: &git2::Repository) -> Result<git2::Signature<'_>, git2::Error> {
690    let config = repo.config()?;
691    let name = read_signature_field(&config, "GIT_AUTHOR_NAME", "author.name", "user.name")?;
692    let email = read_signature_field(&config, "GIT_AUTHOR_EMAIL", "author.email", "user.email")?;
693
694    git2::Signature::now(&name, &email)
695}
696
697fn read_signature_field(
698    config: &git2::Config,
699    env_var: &str,
700    specialized_key: &str,
701    general_key: &str,
702) -> Result<String, git2::Error> {
703    std::env::var_os(env_var)
704        .map(|os| {
705            os.into_string().map_err(|os| {
706                git2::Error::new(
707                    git2::ErrorCode::Unmerged,
708                    git2::ErrorClass::Invalid,
709                    format!("`{}` is not valid UTF-8: {}", env_var, os.to_string_lossy()),
710                )
711            })
712        })
713        .or_else(|| config.get_string(specialized_key).ok().map(Ok))
714        .unwrap_or_else(|| config.get_string(general_key))
715}