use smol_str::SmolStr;
use std::fmt::Write as _;
use zenops_safe_relative_path::srpath;
use crate::{
config::user::StoredUserConfig,
config_files::{ConfigFilePath, ConfigFileSource, ConfigFiles},
error::Error,
};
const ALLOWED_SIGNERS_CONFIG_VALUE: &str = "~/.ssh/allowed_signers";
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct StoredGitConfig {
pub signing: Option<StoredGitSigning>,
}
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum StoredGitSigning {
Ssh {
key: SmolStr,
},
Gpg {
key: SmolStr,
},
}
impl StoredGitSigning {
fn key(&self) -> &SmolStr {
match self {
Self::Ssh { key } | Self::Gpg { key } => key,
}
}
fn format(&self) -> &'static str {
match self {
Self::Ssh { .. } => "ssh",
Self::Gpg { .. } => "openpgp",
}
}
}
impl StoredGitConfig {
fn build_body(&self, user: &StoredUserConfig, has_allowed_signers: bool) -> Option<String> {
let has_user = user.name.is_some() || user.email.is_some();
let signing = self.signing.as_ref();
if !has_user && signing.is_none() {
return None;
}
let mut body = String::from("# Generated by zenops — do not edit.\n");
if has_user || signing.is_some() {
body.push_str("[user]\n");
if let Some(name) = &user.name {
_ = writeln!(body, "\tname = {name}");
}
if let Some(email) = &user.email {
_ = writeln!(body, "\temail = {email}");
}
if let Some(s) = signing {
_ = writeln!(body, "\tsigningkey = {}", s.key());
}
}
if let Some(s) = signing {
_ = writeln!(body, "[gpg]\n\tformat = {}", s.format());
if matches!(s, StoredGitSigning::Ssh { .. }) && has_allowed_signers {
_ = writeln!(
body,
"[gpg \"ssh\"]\n\tallowedSignersFile = {ALLOWED_SIGNERS_CONFIG_VALUE}"
);
}
body.push_str("[commit]\n\tgpgsign = true\n");
}
Some(body)
}
pub(super) fn update_config_files(
&self,
user: &StoredUserConfig,
has_allowed_signers: bool,
config_files: &mut ConfigFiles,
) -> Result<(), Error> {
let Some(body) = self.build_body(user, has_allowed_signers) else {
return Ok(());
};
config_files.add(
ConfigFilePath::in_home(srpath!(".gitconfig")),
ConfigFileSource::Generated(body),
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(name: Option<&str>, email: Option<&str>) -> StoredUserConfig {
StoredUserConfig {
name: name.map(SmolStr::new),
email: email.map(SmolStr::new),
}
}
#[test]
fn empty_everything_emits_no_body() {
let cfg = StoredGitConfig::default();
assert_eq!(cfg.build_body(&user(None, None), false), None);
assert_eq!(cfg.build_body(&user(None, None), true), None);
}
#[test]
fn user_alone_emits_only_user_block() {
let cfg = StoredGitConfig::default();
assert_eq!(
cfg.build_body(&user(Some("Alice"), Some("a@example.com")), false),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tname = Alice\n\
\temail = a@example.com\n"
.to_string()
),
);
}
#[test]
fn only_email_set_emits_email_only() {
let cfg = StoredGitConfig::default();
assert_eq!(
cfg.build_body(&user(None, Some("a@example.com")), false),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\temail = a@example.com\n"
.to_string()
),
);
}
#[test]
fn ssh_signing_alone_emits_signing_blocks_without_allowed_signers_line() {
let cfg = StoredGitConfig {
signing: Some(StoredGitSigning::Ssh {
key: SmolStr::new("~/.ssh/id_ed25519.pub"),
}),
};
assert_eq!(
cfg.build_body(&user(None, None), false),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tsigningkey = ~/.ssh/id_ed25519.pub\n\
[gpg]\n\
\tformat = ssh\n\
[commit]\n\
\tgpgsign = true\n"
.to_string()
),
);
}
#[test]
fn ssh_signing_plus_user_plus_allowed_signers_emits_full_output() {
let cfg = StoredGitConfig {
signing: Some(StoredGitSigning::Ssh {
key: SmolStr::new("~/.ssh/id_ed25519-github.pub"),
}),
};
assert_eq!(
cfg.build_body(&user(Some("Bjorn"), Some("bjrnove@gmail.com")), true),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tname = Bjorn\n\
\temail = bjrnove@gmail.com\n\
\tsigningkey = ~/.ssh/id_ed25519-github.pub\n\
[gpg]\n\
\tformat = ssh\n\
[gpg \"ssh\"]\n\
\tallowedSignersFile = ~/.ssh/allowed_signers\n\
[commit]\n\
\tgpgsign = true\n"
.to_string()
),
);
}
#[test]
fn ssh_signing_with_allowed_signers_but_no_user() {
let cfg = StoredGitConfig {
signing: Some(StoredGitSigning::Ssh {
key: SmolStr::new("~/.ssh/id.pub"),
}),
};
assert_eq!(
cfg.build_body(&user(None, None), true),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tsigningkey = ~/.ssh/id.pub\n\
[gpg]\n\
\tformat = ssh\n\
[gpg \"ssh\"]\n\
\tallowedSignersFile = ~/.ssh/allowed_signers\n\
[commit]\n\
\tgpgsign = true\n"
.to_string()
),
);
}
#[test]
fn gpg_signing_alone_uses_openpgp_format_and_skips_allowed_signers_block() {
let cfg = StoredGitConfig {
signing: Some(StoredGitSigning::Gpg {
key: SmolStr::new("ABCD1234DEADBEEF"),
}),
};
assert_eq!(
cfg.build_body(&user(None, None), false),
Some(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tsigningkey = ABCD1234DEADBEEF\n\
[gpg]\n\
\tformat = openpgp\n\
[commit]\n\
\tgpgsign = true\n"
.to_string()
),
);
}
#[test]
fn gpg_signing_with_allowed_signers_still_skips_ssh_block() {
let cfg = StoredGitConfig {
signing: Some(StoredGitSigning::Gpg {
key: SmolStr::new("ABCD1234"),
}),
};
let body = cfg.build_body(&user(None, None), true).unwrap();
assert!(!body.contains("allowedSignersFile"), "body was:\n{body}");
assert!(body.contains("format = openpgp"), "body was:\n{body}");
}
}