Skip to main content

codetether_agent/provenance/
git.rs

1use crate::provenance::ExecutionProvenance;
2use anyhow::{Context, Result};
3use std::path::Path;
4
5use super::identity::{git_author_email, git_author_name};
6use super::{enrich_from_repo, sign_provenance};
7
8pub fn commit_message_with_provenance(
9    commit_msg: &str,
10    provenance: Option<&ExecutionProvenance>,
11) -> String {
12    let Some(provenance) = provenance else {
13        return commit_msg.to_string();
14    };
15    let trailers = build_trailers(commit_msg, provenance);
16    if trailers.is_empty() {
17        return commit_msg.to_string();
18    }
19    format!("{}\n\n{}", commit_msg.trim_end(), trailers.join("\n"))
20}
21
22pub async fn git_commit_with_provenance(
23    repo_path: &Path,
24    commit_msg: &str,
25    provenance: Option<&ExecutionProvenance>,
26) -> Result<std::process::Output> {
27    let enriched = enrich_repo_provenance(provenance, repo_path);
28    let message = commit_message_with_provenance(commit_msg, enriched.as_ref());
29    let mut command = tokio::process::Command::new("git");
30    command
31        .args(["commit", "-m", &message])
32        .current_dir(repo_path);
33    apply_git_identity_env_tokio(&mut command, enriched.as_ref());
34    command
35        .output()
36        .await
37        .context("Failed to execute git commit")
38}
39
40pub fn git_commit_with_provenance_blocking(
41    repo_path: &Path,
42    commit_msg: &str,
43    provenance: Option<&ExecutionProvenance>,
44) -> Result<std::process::Output> {
45    let enriched = enrich_repo_provenance(provenance, repo_path);
46    let message = commit_message_with_provenance(commit_msg, enriched.as_ref());
47    let mut command = std::process::Command::new("git");
48    command
49        .args(["commit", "-m", &message])
50        .current_dir(repo_path);
51    apply_git_identity_env_blocking(&mut command, enriched.as_ref());
52    command.output().context("Failed to execute git commit")
53}
54
55fn enrich_repo_provenance(
56    provenance: Option<&ExecutionProvenance>,
57    repo_path: &Path,
58) -> Option<ExecutionProvenance> {
59    provenance.map(|value| enrich_from_repo(value, repo_path))
60}
61
62fn build_trailers(commit_msg: &str, provenance: &ExecutionProvenance) -> Vec<String> {
63    trailer_pairs(provenance)
64        .into_iter()
65        .filter(|(label, _)| !commit_msg.contains(label))
66        .map(|(label, value)| format!("{label}: {value}"))
67        .collect()
68}
69
70fn trailer_pairs(provenance: &ExecutionProvenance) -> Vec<(&'static str, String)> {
71    let mut trailers = vec![
72        ("CodeTether-Provenance-ID", provenance.provenance_id.clone()),
73        (
74            "CodeTether-Origin",
75            provenance.identity.origin.as_str().to_string(),
76        ),
77        (
78            "CodeTether-Agent-Name",
79            provenance.identity.agent_name.clone(),
80        ),
81    ];
82    push_opt(
83        &mut trailers,
84        "CodeTether-Agent-Identity",
85        provenance.identity.agent_identity_id.clone(),
86    );
87    push_opt(
88        &mut trailers,
89        "CodeTether-Tenant-ID",
90        provenance.identity.tenant_id.clone(),
91    );
92    push_opt(
93        &mut trailers,
94        "CodeTether-Worker-ID",
95        provenance.identity.worker_id.clone(),
96    );
97    push_opt(
98        &mut trailers,
99        "CodeTether-Session-ID",
100        provenance.session_id.clone(),
101    );
102    push_opt(
103        &mut trailers,
104        "CodeTether-Task-ID",
105        provenance.task_id.clone(),
106    );
107    push_opt(
108        &mut trailers,
109        "CodeTether-Run-ID",
110        provenance.run_id.clone(),
111    );
112    push_opt(
113        &mut trailers,
114        "CodeTether-Attempt-ID",
115        provenance.attempt_id.clone(),
116    );
117    push_opt(
118        &mut trailers,
119        "CodeTether-Key-ID",
120        provenance.identity.key_id.clone(),
121    );
122    push_opt(
123        &mut trailers,
124        "CodeTether-GitHub-Installation-ID",
125        provenance.identity.github_installation_id.clone(),
126    );
127    push_opt(
128        &mut trailers,
129        "CodeTether-GitHub-App-ID",
130        provenance.identity.github_app_id.clone(),
131    );
132    push_opt(
133        &mut trailers,
134        "CodeTether-Signature",
135        sign_provenance(provenance),
136    );
137    trailers
138}
139
140fn push_opt(
141    trailers: &mut Vec<(&'static str, String)>,
142    label: &'static str,
143    value: Option<String>,
144) {
145    if let Some(value) = value {
146        trailers.push((label, value));
147    }
148}
149
150fn git_identity_env_vars(provenance: Option<&ExecutionProvenance>) -> Vec<(&'static str, String)> {
151    let Some(provenance) = provenance else {
152        return Vec::new();
153    };
154    let name = git_author_name(provenance.identity.agent_identity_id.as_deref());
155    let email = git_author_email(
156        provenance.identity.agent_identity_id.as_deref(),
157        provenance.identity.worker_id.as_deref(),
158        Some(provenance.provenance_id.as_str()),
159    );
160    vec![
161        ("GIT_AUTHOR_NAME", name.clone()),
162        ("GIT_COMMITTER_NAME", name),
163        ("GIT_AUTHOR_EMAIL", email.clone()),
164        ("GIT_COMMITTER_EMAIL", email),
165    ]
166}
167
168fn apply_git_identity_env_tokio(
169    command: &mut tokio::process::Command,
170    provenance: Option<&ExecutionProvenance>,
171) {
172    for (key, value) in git_identity_env_vars(provenance) {
173        command.env(key, value);
174    }
175}
176
177fn apply_git_identity_env_blocking(
178    command: &mut std::process::Command,
179    provenance: Option<&ExecutionProvenance>,
180) {
181    for (key, value) in git_identity_env_vars(provenance) {
182        command.env(key, value);
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{commit_message_with_provenance, git_identity_env_vars};
189    use crate::provenance::{ExecutionOrigin, ExecutionProvenance};
190
191    #[test]
192    fn appends_codetether_trailers() {
193        let mut provenance = ExecutionProvenance::for_operation("ralph", ExecutionOrigin::Ralph);
194        provenance.session_id = Some("session-1".to_string());
195        provenance.apply_worker_task("worker-1", "task-1");
196        provenance.set_run_id("run-1");
197        provenance.attempt_id = Some("run-1:attempt:2".to_string());
198        let message = commit_message_with_provenance("feat: test", Some(&provenance));
199        assert!(message.contains("CodeTether-Provenance-ID:"));
200        assert!(message.contains("CodeTether-Session-ID: session-1"));
201        assert!(message.contains("CodeTether-Task-ID: task-1"));
202        assert!(message.contains("CodeTether-Run-ID: run-1"));
203        assert!(message.contains("CodeTether-Attempt-ID: run-1:attempt:2"));
204    }
205
206    #[test]
207    fn derives_real_git_identity_env() {
208        let mut provenance = ExecutionProvenance::for_operation("worker", ExecutionOrigin::Worker);
209        provenance.identity.agent_identity_id = Some("user:abc-123".to_string());
210        provenance.identity.worker_id = Some("wrk-1".to_string());
211        let env = git_identity_env_vars(Some(&provenance));
212        assert!(
213            env.iter()
214                .any(|(k, v)| *k == "GIT_AUTHOR_NAME" && v.contains("user-abc-123"))
215        );
216        assert!(
217            env.iter()
218                .any(|(k, v)| *k == "GIT_AUTHOR_EMAIL" && v == "agent+user-abc-123@codetether.run")
219        );
220    }
221}