tracevault_cli/commands/
commit_push.rs1use crate::api_client::{resolve_credentials, ApiClient};
2use crate::config::TracevaultConfig;
3use serde_json::json;
4use std::path::Path;
5use std::process::Command;
6use tracevault_core::streaming::CommitPushRequest;
7
8pub async fn run_commit_push(project_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
9 let config = TracevaultConfig::load(project_root).ok_or("config not found")?;
10 let org_slug = config.org_slug.ok_or("org_slug not configured")?;
11 let repo_id = config.repo_id.ok_or("repo_id not configured")?;
12
13 let (server_url, token) = resolve_credentials(project_root);
14 let server_url = server_url.ok_or("server_url not configured")?;
15 let client = ApiClient::new(&server_url, token.as_deref());
16
17 let run_git = |args: &[&str]| -> Option<String> {
19 Command::new("git")
20 .args(args)
21 .current_dir(project_root)
22 .output()
23 .ok()
24 .filter(|o| o.status.success())
25 .and_then(|o| String::from_utf8(o.stdout).ok())
26 .map(|s| s.trim().to_string())
27 };
28
29 let commit_sha = run_git(&["rev-parse", "HEAD"]).ok_or("failed to get HEAD sha")?;
30 let branch = run_git(&["rev-parse", "--abbrev-ref", "HEAD"]);
31 let author = run_git(&["log", "-1", "--format=%ae"]).unwrap_or_default();
32 let message = run_git(&["log", "-1", "--format=%B"]);
33
34 let diff_output = run_git(&["diff", "HEAD~1..HEAD", "--unified=3"]);
36
37 let diff_data = diff_output.map(|diff| parse_diff_to_json(&diff));
38
39 let req = CommitPushRequest {
40 commit_sha,
41 branch,
42 author,
43 message,
44 diff_data,
45 committed_at: Some(chrono::Utc::now()),
46 };
47
48 match client.push_commit(&org_slug, &repo_id, &req).await {
49 Ok(resp) => {
50 println!(
51 "Commit pushed: {} ({} attributions)",
52 resp.commit_db_id, resp.attributions_count
53 );
54 }
55 Err(e) => {
56 eprintln!("Warning: commit push failed: {e}");
57 }
59 }
60
61 Ok(())
62}
63
64fn parse_diff_to_json(diff: &str) -> serde_json::Value {
65 let mut files: Vec<serde_json::Value> = Vec::new();
66 let mut current_file: Option<String> = None;
67 let mut current_hunks: Vec<serde_json::Value> = Vec::new();
68 let mut current_hunk_lines: Vec<String> = Vec::new();
69 let mut current_new_start: i64 = 0;
70 let mut current_new_count: i64 = 0;
71
72 let flush_hunk =
73 |hunks: &mut Vec<serde_json::Value>, lines: &mut Vec<String>, start: i64, count: i64| {
74 if !lines.is_empty() {
75 hunks.push(json!({
76 "new_start": start,
77 "new_count": count,
78 "added_lines": lines.clone(),
79 }));
80 lines.clear();
81 }
82 };
83
84 let flush_file = |files: &mut Vec<serde_json::Value>,
85 file: &Option<String>,
86 hunks: &mut Vec<serde_json::Value>| {
87 if let Some(path) = file {
88 if !hunks.is_empty() {
89 files.push(json!({
90 "path": path,
91 "hunks": hunks.clone(),
92 }));
93 hunks.clear();
94 }
95 }
96 };
97
98 for line in diff.lines() {
99 if let Some(path) = line.strip_prefix("+++ b/") {
100 flush_hunk(
102 &mut current_hunks,
103 &mut current_hunk_lines,
104 current_new_start,
105 current_new_count,
106 );
107 flush_file(&mut files, ¤t_file, &mut current_hunks);
108 current_file = Some(path.to_string());
109 } else if line.starts_with("@@ ") {
110 flush_hunk(
112 &mut current_hunks,
113 &mut current_hunk_lines,
114 current_new_start,
115 current_new_count,
116 );
117 if let Some(plus_part) = line.split('+').nth(1) {
119 let nums: Vec<&str> = plus_part
120 .split(' ')
121 .next()
122 .unwrap_or("")
123 .split(',')
124 .collect();
125 current_new_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
126 current_new_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
127 }
128 } else if let Some(added) = line.strip_prefix('+') {
129 current_hunk_lines.push(added.to_string());
130 }
131 }
132
133 flush_hunk(
135 &mut current_hunks,
136 &mut current_hunk_lines,
137 current_new_start,
138 current_new_count,
139 );
140 flush_file(&mut files, ¤t_file, &mut current_hunks);
141
142 json!({ "files": files })
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn parse_diff_empty() {
151 let result = parse_diff_to_json("");
152 assert!(result["files"].as_array().unwrap().is_empty());
153 }
154
155 #[test]
156 fn parse_diff_single_file() {
157 let diff = "\
158diff --git a/src/main.rs b/src/main.rs
159--- a/src/main.rs
160+++ b/src/main.rs
161@@ -1,3 +1,4 @@
162 fn main() {
163+ println!(\"hello\");
164 other();
165 }
166";
167 let result = parse_diff_to_json(diff);
168 let files = result["files"].as_array().unwrap();
169 assert_eq!(files.len(), 1);
170 }
171
172 #[test]
173 fn parse_diff_extracts_added_lines() {
174 let diff = "\
175diff --git a/a.rs b/a.rs
176--- a/a.rs
177+++ b/a.rs
178@@ -1,2 +1,3 @@
179 existing
180+new_line
181-removed
182";
183 let result = parse_diff_to_json(diff);
184 let files = result["files"].as_array().unwrap();
185 let hunks = files[0]["hunks"].as_array().unwrap();
186 let added = hunks[0]["added_lines"].as_array().unwrap();
187 assert!(added.iter().any(|l| l.as_str().unwrap() == "new_line"));
188 }
189
190 #[test]
191 fn parse_diff_multiple_files() {
192 let diff = "\
193diff --git a/a.rs b/a.rs
194--- a/a.rs
195+++ b/a.rs
196@@ -1 +1,2 @@
197 a
198+b
199diff --git a/c.rs b/c.rs
200--- a/c.rs
201+++ b/c.rs
202@@ -1 +1,2 @@
203 c
204+d
205";
206 let result = parse_diff_to_json(diff);
207 let files = result["files"].as_array().unwrap();
208 assert_eq!(files.len(), 2);
209 }
210}