Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion gitoxide-core/src/repository/commit.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
use std::{io::Write, process::Stdio};
use std::{
borrow::Cow,
io::{Read, Write},
process::Stdio,
};

use anyhow::{anyhow, bail, Context, Result};
use gix::{
bstr::{BStr, ByteSlice},
objs::commit::SIGNATURE_FIELD_NAME,
};

/// Note that this is a quick implementation of commit signature verification that ignores a lot of what
/// git does and can do, while focussing on the gist of it.
Expand Down Expand Up @@ -39,6 +47,60 @@ pub fn verify(repo: gix::Repository, rev_spec: Option<&str>) -> Result<()> {
Ok(())
}

pub fn sign(repo: gix::Repository, rev_spec: Option<&str>) -> Result<()> {
let rev_spec = rev_spec.unwrap_or("HEAD");
let commit = repo
.rev_parse_single(format!("{rev_spec}^{{commit}}").as_str())?
.object()?
.into_commit();

let mut cmd: std::process::Command = gix::command::prepare("gpg").into();
cmd.args([
"--keyid-format=long",
"--status-fd=2",
"--detach-sign",
"--sign",
"--armor",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
gix::trace::debug!("About to execute {cmd:?}");
let mut child = cmd.spawn()?;
child
.stdin
.take()
.expect("to be present")
.write_all(commit.data.as_ref())?;

if !child.wait()?.success() {
bail!("Command {cmd:?} failed");
}

let mut signed_data = Vec::new();
child
.stdout
.take()
.expect("to be present")
.read_to_end(&mut signed_data)?;

let object = repo
.rev_parse_single(format!("{rev_spec}^{{commit}}").as_str())?
.object()?;
let mut commit_ref = object.to_commit_ref();

let extra_header: Cow<'_, BStr> = Cow::Borrowed(signed_data.as_bstr());

// TODO:
// What do we want to do when there is already a signature?
commit_ref
.extra_headers
.push((BStr::new(SIGNATURE_FIELD_NAME), extra_header));

eprintln!("{commit_ref:?}");
Copy link
Contributor Author

@cruessler cruessler Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Byron At this point, I would like to write the signed commit back to the filesystem. I searched the codebase for hints on how this is usually done, but couldn’t really find anything. Would you be able to point me to an example?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be repo.write_object(&commit_ref)?.

It's great you use the commit_ref for this by the way as it means any commit, even somewhat malformed ones, can be signed with this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that’s what I was looking for! I assume that, once the signed object has been written, rev_spec needs to be made to point to the new commit, and the reflog needs to be updated. Is that what I’d use repo.edit_reference() for?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right - you'd want to now point HEAD to the new commit, which won't change the worktree at all.
Editing the head can really use a specific API, but while it's not there, you'd indeed need to use edit_references([head_edit]) to edit only HEAD with deref: true. No value should stay the default just to be explicit.


Ok(())
}

pub fn describe(
mut repo: gix::Repository,
rev_spec: Option<&str>,
Expand Down
7 changes: 3 additions & 4 deletions gix-object/src/commit/ref_iter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ use winnow::{

use crate::{
bstr::ByteSlice,
commit::{decode, SignedData},
parse,
parse::NL,
commit::{decode, SignedData, SIGNATURE_FIELD_NAME},
parse::{self, NL},
CommitRefIter,
};

Expand Down Expand Up @@ -65,7 +64,7 @@ impl<'a> CommitRefIter<'a> {
for token in raw_tokens {
let token = token?;
if let Token::ExtraHeader((name, value)) = &token.token {
if *name == "gpgsig" {
if *name == SIGNATURE_FIELD_NAME {
// keep track of the signature range alongside the signature data,
// because all but the signature is the signed data.
signature_and_range = Some((value.clone(), token.token_range));
Expand Down
11 changes: 11 additions & 0 deletions src/plumbing/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,17 @@ pub fn main() -> Result<()> {
core::repository::commit::verify(repository(Mode::Lenient)?, rev_spec.as_deref())
},
),
commit::Subcommands::Sign { rev_spec } => prepare_and_run(
"commit-sign",
trace,
auto_verbose,
progress,
progress_keep_open,
None,
move |_progress, _out, _err| {
core::repository::commit::sign(repository(Mode::Lenient)?, rev_spec.as_deref())
},
),
commit::Subcommands::Describe {
annotated_tags,
all_refs,
Expand Down
5 changes: 5 additions & 0 deletions src/plumbing/options/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,11 @@ pub mod commit {
/// A specification of the revision to verify, or the current `HEAD` if unset.
rev_spec: Option<String>,
},
/// TODO: add description.
Sign {
/// A specification of the revision to sign, or the current `HEAD` if unset.
rev_spec: Option<String>,
},
/// Describe the current commit or the given one using the name of the closest annotated tag in its ancestry.
Describe {
/// Use annotated tag references only, not all tags.
Expand Down
Loading