Skip to main content

tracevault_cli/commands/
commit_push.rs

1use 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    // Gather git info
18    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    // Get diff (ok if fails, e.g. initial commit)
35    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            // Don't fail — post-commit hook should not block
58        }
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 previous hunk and file
101            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, &current_file, &mut current_hunks);
108            current_file = Some(path.to_string());
109        } else if line.starts_with("@@ ") {
110            // Flush previous hunk
111            flush_hunk(
112                &mut current_hunks,
113                &mut current_hunk_lines,
114                current_new_start,
115                current_new_count,
116            );
117            // Parse @@ -old_start,old_count +new_start,new_count @@
118            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 remaining
134    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, &current_file, &mut current_hunks);
141
142    json!({ "files": files })
143}