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