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
33    // Get diff (ok if fails, e.g. initial commit)
34    let diff_output = run_git(&["diff", "HEAD~1..HEAD", "--unified=3"]);
35
36    let diff_data = diff_output.map(|diff| parse_diff_to_json(&diff));
37
38    let req = CommitPushRequest {
39        commit_sha,
40        branch,
41        author,
42        diff_data,
43        committed_at: Some(chrono::Utc::now()),
44    };
45
46    match client.push_commit(&org_slug, &repo_id, &req).await {
47        Ok(resp) => {
48            println!(
49                "Commit pushed: {} ({} attributions)",
50                resp.commit_db_id, resp.attributions_count
51            );
52        }
53        Err(e) => {
54            eprintln!("Warning: commit push failed: {e}");
55            // Don't fail — post-commit hook should not block
56        }
57    }
58
59    Ok(())
60}
61
62fn parse_diff_to_json(diff: &str) -> serde_json::Value {
63    let mut files: Vec<serde_json::Value> = Vec::new();
64    let mut current_file: Option<String> = None;
65    let mut current_hunks: Vec<serde_json::Value> = Vec::new();
66    let mut current_hunk_lines: Vec<String> = Vec::new();
67    let mut current_new_start: i64 = 0;
68    let mut current_new_count: i64 = 0;
69
70    let flush_hunk =
71        |hunks: &mut Vec<serde_json::Value>, lines: &mut Vec<String>, start: i64, count: i64| {
72            if !lines.is_empty() {
73                hunks.push(json!({
74                    "new_start": start,
75                    "new_count": count,
76                    "added_lines": lines.clone(),
77                }));
78                lines.clear();
79            }
80        };
81
82    let flush_file = |files: &mut Vec<serde_json::Value>,
83                      file: &Option<String>,
84                      hunks: &mut Vec<serde_json::Value>| {
85        if let Some(path) = file {
86            if !hunks.is_empty() {
87                files.push(json!({
88                    "path": path,
89                    "hunks": hunks.clone(),
90                }));
91                hunks.clear();
92            }
93        }
94    };
95
96    for line in diff.lines() {
97        if let Some(path) = line.strip_prefix("+++ b/") {
98            // Flush previous hunk and file
99            flush_hunk(
100                &mut current_hunks,
101                &mut current_hunk_lines,
102                current_new_start,
103                current_new_count,
104            );
105            flush_file(&mut files, &current_file, &mut current_hunks);
106            current_file = Some(path.to_string());
107        } else if line.starts_with("@@ ") {
108            // Flush previous hunk
109            flush_hunk(
110                &mut current_hunks,
111                &mut current_hunk_lines,
112                current_new_start,
113                current_new_count,
114            );
115            // Parse @@ -old_start,old_count +new_start,new_count @@
116            if let Some(plus_part) = line.split('+').nth(1) {
117                let nums: Vec<&str> = plus_part
118                    .split(' ')
119                    .next()
120                    .unwrap_or("")
121                    .split(',')
122                    .collect();
123                current_new_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
124                current_new_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
125            }
126        } else if let Some(added) = line.strip_prefix('+') {
127            current_hunk_lines.push(added.to_string());
128        }
129    }
130
131    // Flush remaining
132    flush_hunk(
133        &mut current_hunks,
134        &mut current_hunk_lines,
135        current_new_start,
136        current_new_count,
137    );
138    flush_file(&mut files, &current_file, &mut current_hunks);
139
140    json!({ "files": files })
141}