Skip to main content

ward/github/
commits.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use super::Client;
5
6#[derive(Debug, Serialize)]
7struct CreateBlobRequest {
8    content: String,
9    encoding: String,
10}
11
12#[derive(Debug, Deserialize)]
13struct CreateBlobResponse {
14    sha: String,
15}
16
17#[derive(Debug, Serialize)]
18struct TreeEntry {
19    path: String,
20    mode: String,
21    #[serde(rename = "type")]
22    entry_type: String,
23    sha: String,
24}
25
26#[derive(Debug, Serialize)]
27struct CreateTreeRequest {
28    base_tree: String,
29    tree: Vec<TreeEntry>,
30}
31
32#[derive(Debug, Deserialize)]
33struct CreateTreeResponse {
34    sha: String,
35}
36
37#[derive(Debug, Serialize)]
38struct CreateCommitRequest {
39    message: String,
40    tree: String,
41    parents: Vec<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct CreateCommitResponse {
46    sha: String,
47}
48
49#[derive(Debug, Serialize)]
50struct UpdateRefRequest {
51    sha: String,
52    force: bool,
53}
54
55#[derive(Debug, Deserialize)]
56struct RefResponse {
57    object: RefObject,
58}
59
60#[derive(Debug, Deserialize)]
61struct RefObject {
62    sha: String,
63}
64
65/// A file to include in an atomic commit.
66#[derive(Debug, Clone)]
67pub struct CommitFile {
68    pub path: String,
69    pub content: String,
70}
71
72impl Client {
73    /// Create an atomic multi-file commit using the Git Trees API.
74    /// This avoids cloning the repo - everything happens via the API.
75    pub async fn create_commit(
76        &self,
77        repo: &str,
78        branch: &str,
79        message: &str,
80        files: &[CommitFile],
81    ) -> Result<String> {
82        let ref_path = format!("/repos/{}/{repo}/git/ref/heads/{branch}", self.org);
83        let resp = self.get(&ref_path).await?;
84        let status = resp.status();
85        if !status.is_success() {
86            let body = resp.text().await.unwrap_or_default();
87            anyhow::bail!("Failed to get ref heads/{branch} for {repo} (HTTP {status}): {body}");
88        }
89        let ref_info: RefResponse = resp.json().await.context("Failed to parse ref response")?;
90        let base_commit_sha = ref_info.object.sha;
91
92        // Get the tree SHA for the base commit
93        let commit_resp = self
94            .get(&format!(
95                "/repos/{}/{repo}/git/commits/{base_commit_sha}",
96                self.org
97            ))
98            .await?;
99        let commit_data: serde_json::Value = commit_resp.json().await?;
100        let base_tree_sha = commit_data["tree"]["sha"]
101            .as_str()
102            .context("Missing tree SHA in commit")?
103            .to_owned();
104
105        // Create blobs for each file
106        let mut tree_entries = Vec::new();
107        for file in files {
108            let blob_req = CreateBlobRequest {
109                content: file.content.clone(),
110                encoding: "utf-8".to_owned(),
111            };
112
113            let resp = self
114                .post_json(&format!("/repos/{}/{repo}/git/blobs", self.org), &blob_req)
115                .await?;
116
117            let blob: CreateBlobResponse = resp.json().await.context("Failed to create blob")?;
118
119            tree_entries.push(TreeEntry {
120                path: file.path.clone(),
121                mode: "100644".to_owned(),
122                entry_type: "blob".to_owned(),
123                sha: blob.sha,
124            });
125        }
126
127        // Create tree
128        let tree_req = CreateTreeRequest {
129            base_tree: base_tree_sha,
130            tree: tree_entries,
131        };
132
133        let resp = self
134            .post_json(&format!("/repos/{}/{repo}/git/trees", self.org), &tree_req)
135            .await?;
136        let tree: CreateTreeResponse = resp.json().await.context("Failed to create tree")?;
137
138        // Create commit
139        let commit_req = CreateCommitRequest {
140            message: message.to_owned(),
141            tree: tree.sha,
142            parents: vec![base_commit_sha],
143        };
144
145        let resp = self
146            .post_json(
147                &format!("/repos/{}/{repo}/git/commits", self.org),
148                &commit_req,
149            )
150            .await?;
151        let commit: CreateCommitResponse = resp.json().await.context("Failed to create commit")?;
152
153        // Update branch ref
154        let update_ref = UpdateRefRequest {
155            sha: commit.sha.clone(),
156            force: false,
157        };
158
159        let resp = self
160            .patch_json(
161                &format!("/repos/{}/{repo}/git/refs/heads/{branch}", self.org),
162                &update_ref,
163            )
164            .await?;
165
166        if !resp.status().is_success() {
167            let status = resp.status();
168            let body = resp.text().await.unwrap_or_default();
169            anyhow::bail!("Failed to update ref for {repo} (HTTP {status}): {body}");
170        }
171
172        Ok(commit.sha)
173    }
174
175    /// Create a new branch from the default branch.
176    pub async fn create_branch(
177        &self,
178        repo: &str,
179        branch_name: &str,
180        from_branch: &str,
181    ) -> Result<()> {
182        // Get the SHA of the source branch
183        let resp = self
184            .get(&format!(
185                "/repos/{}/{repo}/git/ref/heads/{from_branch}",
186                self.org
187            ))
188            .await?;
189
190        if !resp.status().is_success() {
191            let body = resp.text().await.unwrap_or_default();
192            anyhow::bail!("Failed to get ref for {from_branch} in {repo}: {body}");
193        }
194
195        let ref_info: RefResponse = resp.json().await?;
196
197        let body = serde_json::json!({
198            "ref": format!("refs/heads/{branch_name}"),
199            "sha": ref_info.object.sha
200        });
201
202        let resp = self
203            .post_json(&format!("/repos/{}/{repo}/git/refs", self.org), &body)
204            .await?;
205
206        let status = resp.status();
207        if status.is_success() || status.as_u16() == 422 {
208            // 422 = branch already exists, which is fine (idempotent)
209            Ok(())
210        } else {
211            let body = resp.text().await.unwrap_or_default();
212            anyhow::bail!(
213                "Failed to create branch {branch_name} in {repo} (HTTP {status}): {body}"
214            );
215        }
216    }
217}