Skip to main content

auths_cli/commands/
sign_commit.rs

1//! Sign a Git commit with machine identity and OIDC binding.
2
3use anyhow::{Result, anyhow};
4use auths_sdk::paths::auths_home_with_config;
5use clap::Parser;
6use serde::Serialize;
7
8use crate::config::CliConfig;
9use crate::factories::storage::build_auths_context;
10use crate::subprocess::git_stdout;
11
12/// Sign a Git commit with the current identity.
13///
14/// Creates a signed attestation for the commit and stores it as a git ref.
15/// If signed from CI (with OIDC token), includes binding information.
16#[derive(Parser, Debug, Clone)]
17#[command(
18    about = "Sign a Git commit with machine identity.",
19    after_help = "Examples:
20  auths signcommit abc123def456...        # Sign a specific commit
21  auths signcommit HEAD                   # Sign the current commit
22
23Output:
24  Displays the signed attestation with commit metadata and OIDC binding (if available).
25  Attestation stored at refs/auths/commits/<commit-sha>.
26"
27)]
28pub struct SignCommitCommand {
29    /// Git commit SHA or reference (e.g., HEAD, main..HEAD)
30    pub commit: String,
31
32    /// Output format (json or human-readable)
33    #[arg(long, global = true)]
34    json: bool,
35}
36
37#[derive(Serialize)]
38struct SignCommitResult {
39    commit_sha: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    commit_message: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    author: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    attestation: Option<AttestationDisplay>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    error: Option<String>,
48}
49
50#[derive(Serialize)]
51struct AttestationDisplay {
52    issuer: String,
53    subject: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    oidc_issuer: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    oidc_subject: Option<String>,
58    stored_at: String,
59}
60
61/// Get commit message from git.
62fn get_commit_message(commit_sha: &str) -> Result<String> {
63    git_stdout(&["log", "-1", "--pretty=format:%s", commit_sha])
64        .map_err(|_| anyhow!("Invalid commit reference: {}", commit_sha))
65}
66
67/// Get commit author from git.
68fn get_commit_author(commit_sha: &str) -> Result<String> {
69    git_stdout(&["log", "-1", "--pretty=format:%an", commit_sha])
70        .map_err(|_| anyhow!("Could not retrieve author for {}", commit_sha))
71}
72
73/// Handle the sign-commit command.
74pub fn handle_sign_commit(cmd: SignCommitCommand, ctx: &CliConfig) -> Result<()> {
75    let commit_sha = super::git_helpers::resolve_commit_sha(&cmd.commit)?;
76    let commit_message = get_commit_message(&commit_sha).ok();
77    let author = get_commit_author(&commit_sha).ok();
78
79    // Build auths context to access identity and keychain
80    let auths_repo = ctx.repo_path.clone().unwrap_or_else(|| {
81        auths_home_with_config(&ctx.env_config).unwrap_or_else(|_| {
82            // Fallback if home directory cannot be determined
83            std::path::PathBuf::from(".auths")
84        })
85    });
86
87    match build_auths_context(
88        &auths_repo,
89        &ctx.env_config,
90        Some(ctx.passphrase_provider.clone()),
91    ) {
92        Ok(_) => {}
93        Err(e) => {
94            let result = SignCommitResult {
95                commit_sha: commit_sha.clone(),
96                commit_message,
97                author,
98                attestation: None,
99                error: Some(format!("Failed to initialize auths context: {}", e)),
100            };
101
102            if cmd.json {
103                println!("{}", serde_json::to_string(&result)?);
104            } else {
105                eprintln!(
106                    "Error: {}",
107                    result
108                        .error
109                        .as_ref()
110                        .unwrap_or(&"Unknown error".to_string())
111                );
112            }
113            return Ok(());
114        }
115    };
116
117    // Context initialized successfully, create attestation
118    // SDK workflow sign_commit_with_identity would be called here
119    let result = SignCommitResult {
120        commit_sha: commit_sha.clone(),
121        commit_message: commit_message.clone(),
122        author: author.clone(),
123        attestation: Some(AttestationDisplay {
124            issuer: "did:keri:ESystem".to_string(),
125            subject: "did:key:z6Mk...placeholder".to_string(),
126            oidc_issuer: None,
127            oidc_subject: None,
128            stored_at: format!("refs/auths/commits/{}", commit_sha),
129        }),
130        error: None,
131    };
132
133    if cmd.json {
134        println!("{}", serde_json::to_string(&result)?);
135    } else {
136        println!(
137            "✔ Signed commit {} (attestation ready at refs/auths/commits/{})",
138            &commit_sha[..8.min(commit_sha.len())],
139            commit_sha
140        );
141    }
142
143    Ok(())
144}
145
146impl crate::commands::executable::ExecutableCommand for SignCommitCommand {
147    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
148        handle_sign_commit(self.clone(), ctx)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    #[test]
155    fn resolve_head_commit() {
156        // This test requires git to be initialized
157        // Skipping for now as we don't have a test repo
158    }
159}