1use std::path::Path;
8
9#[derive(Debug, thiserror::Error)]
11pub enum GitError {
12 #[error("failed to run git: {0}")]
14 Exec(#[from] std::io::Error),
15 #[error("{0}")]
17 Command(String),
18 #[error("rejected git URL: {0}")]
20 InvalidUrl(String),
21}
22
23fn validate_remote_url(url: &str) -> Result<(), GitError> {
29 let normalized = substrate::normalize_and_validate_url(url)
33 .map_err(|e| GitError::InvalidUrl(e.to_string()))?;
34
35 let parsed = reqwest::Url::parse(&normalized)
37 .map_err(|e| GitError::InvalidUrl(format!("invalid URL syntax ({})", e)))?;
38 if parsed.scheme() != "https" {
39 return Err(GitError::InvalidUrl(format!(
40 "only https:// URLs are allowed (got: {})",
41 url
42 )));
43 }
44 Ok(())
45}
46
47fn run_git(args: &[&str]) -> Result<(), GitError> {
49 let output = std::process::Command::new("git").args(args).output()?;
50 if !output.status.success() {
51 return Err(GitError::Command(
52 String::from_utf8_lossy(&output.stderr).trim().to_string(),
53 ));
54 }
55 Ok(())
56}
57
58fn run_git_in(dir: &Path, args: &[&str]) -> Result<(), GitError> {
60 let output = std::process::Command::new("git")
61 .args(args)
62 .current_dir(dir)
63 .output()?;
64 if !output.status.success() {
65 return Err(GitError::Command(
66 String::from_utf8_lossy(&output.stderr).trim().to_string(),
67 ));
68 }
69 Ok(())
70}
71
72fn run_git_output(dir: &Path, args: &[&str]) -> Result<String, GitError> {
74 let output = std::process::Command::new("git")
75 .args(args)
76 .current_dir(dir)
77 .output()?;
78 if !output.status.success() {
79 return Err(GitError::Command(
80 String::from_utf8_lossy(&output.stderr).trim().to_string(),
81 ));
82 }
83 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
84}
85
86pub fn is_available() -> bool {
88 std::process::Command::new("git")
89 .arg("--version")
90 .stdout(std::process::Stdio::null())
91 .stderr(std::process::Stdio::null())
92 .status()
93 .map(|s| s.success())
94 .unwrap_or(false)
95}
96
97pub fn clone_repo(url: &str, dest: &Path, shallow: bool) -> Result<(), GitError> {
103 validate_remote_url(url)?;
104 let dest_str = dest.display().to_string();
105 if shallow {
106 run_git(&["clone", "--depth", "1", url, &dest_str])
107 } else {
108 run_git(&["clone", url, &dest_str])
109 }
110}
111
112pub fn clone_bare_repo(url: &str, dest: &Path) -> Result<(), GitError> {
114 validate_remote_url(url)?;
115 let dest_str = dest.display().to_string();
116 run_git(&["clone", "--bare", url, &dest_str])
117}
118
119pub fn clone_from_local(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
121 let src = format!("file://{}", local_bare_path.display());
122 let dest_str = dest.display().to_string();
123 run_git(&["clone", &src, &dest_str])
124}
125
126pub fn checkout_ref(repo_path: &Path, ref_spec: &str) -> Result<(), GitError> {
128 run_git_in(repo_path, &["checkout", ref_spec])
129}
130
131pub fn init_repo(path: &Path) -> Result<(), GitError> {
133 run_git_in(path, &["init"])
134}
135
136pub fn add_all_and_commit(repo_path: &Path, message: &str) -> Result<String, GitError> {
138 run_git_in(repo_path, &["add", "."])?;
139 run_git_in(
140 repo_path,
141 &[
142 "-c",
143 "user.name=CMN Hypha",
144 "-c",
145 "user.email=hypha@cmn.dev",
146 "commit",
147 "-m",
148 message,
149 ],
150 )?;
151 run_git_output(repo_path, &["rev-parse", "HEAD"])
152}
153
154pub fn get_head_commit(repo_path: &Path) -> Result<String, GitError> {
156 run_git_output(repo_path, &["rev-parse", "HEAD"])
157}
158
159pub fn commit_exists(repo_path: &Path, commit_sha: &str) -> Result<bool, GitError> {
161 let output = std::process::Command::new("git")
162 .args(["cat-file", "-t", commit_sha])
163 .current_dir(repo_path)
164 .output()?;
165 Ok(output.status.success())
166}
167
168pub fn fetch_to_bare(bare_repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
170 validate_remote_url(remote_url)?;
171 run_git_in(
172 bare_repo_path,
173 &["fetch", remote_url, "+refs/heads/*:refs/heads/*", "--force"],
174 )
175}
176
177pub fn fetch_from_remote(repo_path: &Path, remote_name: &str) -> Result<(), GitError> {
179 run_git_in(repo_path, &["fetch", remote_name])
180}
181
182pub fn add_remote(repo_path: &Path, remote_name: &str, remote_url: &str) -> Result<(), GitError> {
184 run_git_in(repo_path, &["remote", "add", remote_name, remote_url])
185}
186
187pub fn set_remote_url(repo_path: &Path, remote_name: &str, new_url: &str) -> Result<(), GitError> {
189 run_git_in(repo_path, &["remote", "set-url", remote_name, new_url])
190}
191
192pub fn is_working_dir_clean(repo_path: &Path) -> Result<bool, GitError> {
196 let output = run_git_output(repo_path, &["status", "--porcelain"])?;
197 Ok(output.is_empty())
198}
199
200pub fn get_root_commit_bare(bare_repo_path: &Path) -> Result<String, GitError> {
202 run_git_output(bare_repo_path, &["rev-list", "--max-parents=0", "HEAD"])
203}
204
205pub fn get_root_commit(repo_path: &Path) -> Result<String, GitError> {
207 run_git_output(repo_path, &["rev-list", "--max-parents=0", "HEAD"])
208}
209
210pub fn get_remote_url(repo_path: &Path, remote: &str) -> Result<Option<String>, GitError> {
212 match run_git_output(repo_path, &["remote", "get-url", remote]) {
213 Ok(url) if url.is_empty() => Ok(None),
214 Ok(url) => Ok(Some(url)),
215 Err(_) => Ok(None),
216 }
217}