codetether_agent/provenance/
git.rs1use 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}