1use std::time::{Duration, UNIX_EPOCH};
4
5use rskit_errors::{AppError, AppResult};
6use rskit_process::ProcessResult;
7
8use crate::error::GitError;
9use crate::manage::{ConfigReader, Maintainer, RefManager, RemoteManager};
10use crate::options::{CleanOptions, FetchOptions, PushOptions};
11use crate::types::{Branch, BranchFilter, Remote, Signature, Tag};
12
13use super::GitCli;
14
15impl RefManager for GitCli {
16 fn list_branches(&self, filter: BranchFilter) -> AppResult<Vec<Branch>> {
17 let mut args = vec![
18 "for-each-ref".to_string(),
19 "--format=%(refname:short)%00%(objectname)%00%(upstream:short)".to_string(),
20 ];
21 match filter {
22 BranchFilter::Local => args.push("refs/heads".to_string()),
23 BranchFilter::Remote => args.push("refs/remotes".to_string()),
24 BranchFilter::All => {
25 args.push("refs/heads".to_string());
26 args.push("refs/remotes".to_string());
27 }
28 }
29
30 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
31 let output = self.run(&refs)?;
32 String::from_utf8_lossy(&output)
33 .lines()
34 .filter(|line| !line.trim().is_empty())
35 .map(parse_branch)
36 .collect::<AppResult<Vec<_>>>()
37 }
38
39 fn list_tags(&self) -> AppResult<Vec<Tag>> {
40 let output = self.run(&[
41 "for-each-ref",
42 "-z",
43 "refs/tags",
44 "--format=%(refname:short)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(taggername)%00%(taggeremail)%00%(taggerdate:unix)%00%(contents)",
45 ])?;
46 parse_tags(&output)
47 }
48
49 fn create_branch(&self, name: &str, target: &str) -> AppResult<()> {
50 self.run(&["branch", "--", name, target])?;
51 Ok(())
52 }
53
54 fn delete_branch(&self, name: &str) -> AppResult<()> {
55 let args = ["branch", "-d", "--", name];
56 let output = self.run_result(&args)?;
57 if output.success() {
58 return Ok(());
59 }
60 map_delete_ref_failure(name, &output).map_or_else(
61 || Err(GitCli::command_failed(&args, output)),
62 |error| Err(error.into()),
63 )
64 }
65
66 fn create_tag(&self, name: &str, target: &str, message: Option<&str>) -> AppResult<()> {
67 if let Some(message) = message {
68 self.run(&["tag", "-a", "-m", message, "--", name, target])?;
69 } else {
70 self.run(&["tag", "--", name, target])?;
71 }
72 Ok(())
73 }
74
75 fn delete_tag(&self, name: &str) -> AppResult<()> {
76 let args = ["tag", "-d", "--", name];
77 let output = self.run_result(&args)?;
78 if output.success() {
79 return Ok(());
80 }
81 map_delete_ref_failure(name, &output).map_or_else(
82 || Err(GitCli::command_failed(&args, output)),
83 |error| Err(error.into()),
84 )
85 }
86}
87
88fn map_delete_ref_failure(name: &str, output: &ProcessResult) -> Option<GitError> {
89 let stderr = output.stderr.to_ascii_lowercase();
90 let stdout = output.stdout.to_ascii_lowercase();
91 let diagnostic = if stderr.is_empty() { &stdout } else { &stderr };
92 if diagnostic.contains("not found")
93 || diagnostic.contains("no such branch")
94 || diagnostic.contains("no such tag")
95 {
96 Some(GitError::RefNotFound {
97 refname: name.to_string(),
98 })
99 } else {
100 None
101 }
102}
103
104#[cfg(test)]
105mod delete_ref_tests {
106 use std::time::Duration;
107
108 use rskit_process::ProcessResult;
109
110 use super::map_delete_ref_failure;
111 use crate::error::GitError;
112
113 fn failed(stderr: &[u8]) -> ProcessResult {
114 ProcessResult::completed(
115 Some(1),
116 Vec::new(),
117 stderr.to_vec(),
118 false,
119 false,
120 Duration::ZERO,
121 false,
122 false,
123 )
124 }
125
126 #[test]
127 fn delete_ref_maps_missing_branch_diagnostic() {
128 let error =
129 map_delete_ref_failure("missing", &failed(b"error: branch 'missing' not found."))
130 .expect("missing ref should map");
131
132 assert!(matches!(error, GitError::RefNotFound { refname } if refname == "missing"));
133 }
134
135 #[test]
136 fn delete_ref_leaves_unrelated_command_failure_unmapped() {
137 let output = failed(b"error: branch 'main' is not fully merged");
138
139 assert!(map_delete_ref_failure("main", &output).is_none());
140 }
141}
142
143impl RemoteManager for GitCli {
144 fn list_remotes(&self) -> AppResult<Vec<Remote>> {
145 let output = self.run(&["remote", "-v"])?;
146 let text = String::from_utf8_lossy(&output);
147 let mut remotes = Vec::new();
148 let mut seen = std::collections::BTreeSet::new();
149
150 for line in text.lines().filter(|line| !line.trim().is_empty()) {
151 let Some((name, rest)) = line.split_once('\t') else {
152 continue;
153 };
154 if !seen.insert(name.to_string()) {
155 continue;
156 }
157 let url = rest.rsplit_once(" (").map_or(rest, |(u, _)| u).to_string();
158 remotes.push(Remote {
159 name: name.to_string(),
160 url,
161 fetch_specs: self.config_get_all(&format!("remote.{name}.fetch"))?,
162 push_specs: self.config_get_all(&format!("remote.{name}.push"))?,
163 });
164 }
165
166 Ok(remotes)
167 }
168
169 fn fetch(&self, remote: &str, opts: Option<&FetchOptions>) -> AppResult<()> {
170 let opts = opts.cloned().unwrap_or_default();
171 let mut args = vec!["fetch".to_string()];
172 if opts.prune {
173 args.push("--prune".to_string());
174 }
175 if let Some(depth) = opts.depth {
176 args.push("--depth".to_string());
177 args.push(depth.to_string());
178 }
179 args.extend(opts.extra_args);
180 args.push(remote.to_string());
181 args.extend(opts.refspecs);
182
183 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
184 self.run(&refs)?;
185 Ok(())
186 }
187
188 fn push(&self, remote: &str, opts: Option<&PushOptions>) -> AppResult<()> {
189 let opts = opts.cloned().unwrap_or_default();
190 let mut args = vec!["push".to_string()];
191 if opts.force {
192 args.push("--force".to_string());
193 }
194 args.extend(opts.extra_args);
195 args.push(remote.to_string());
196 args.extend(opts.refspecs);
197
198 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
199 self.run(&refs)?;
200 Ok(())
201 }
202
203 fn tracking_branch(&self, branch: &str) -> AppResult<String> {
204 let remote = self.config_get(&format!("branch.{branch}.remote"))?;
205 let merge = self.config_get(&format!("branch.{branch}.merge"))?;
206 let short = merge.trim_start_matches("refs/heads/");
207 Ok(format!("{remote}/{short}"))
208 }
209}
210
211impl ConfigReader for GitCli {
212 fn config_get(&self, key: &str) -> AppResult<String> {
213 let args = ["config", "--get", "--", key];
214 let output = self.run_result(&args)?;
215
216 if output.success() && !output.stdout_truncated && !output.stderr_truncated {
217 Ok(output.stdout.trim().to_string())
218 } else if output.exit_code == Some(1) {
219 Err(GitError::ConfigNotFound {
220 key: key.to_string(),
221 }
222 .into())
223 } else {
224 Err(GitCli::command_failed(&args, output))
225 }
226 }
227
228 fn config_get_all(&self, key: &str) -> AppResult<Vec<String>> {
229 let output = run_allow_empty(self, &["config", "--get-all", key])?;
230 Ok(String::from_utf8_lossy(&output)
231 .lines()
232 .filter(|line| !line.trim().is_empty())
233 .map(str::to_string)
234 .collect())
235 }
236
237 fn config_set(&self, key: &str, value: &str) -> AppResult<()> {
238 self.run(&["config", key, value])?;
239 Ok(())
240 }
241}
242
243impl Maintainer for GitCli {
244 fn gc(&self) -> AppResult<()> {
245 self.run(&["gc"])?;
246 Ok(())
247 }
248
249 fn prune(&self) -> AppResult<()> {
250 self.run(&["prune"])?;
251 Ok(())
252 }
253
254 fn fsck(&self) -> AppResult<()> {
255 self.run(&["fsck"])?;
256 Ok(())
257 }
258
259 fn clean(&self, opts: Option<&CleanOptions>) -> AppResult<Vec<String>> {
260 let opts = opts.cloned().unwrap_or_default();
261 let mut args = vec!["clean".to_string()];
262 if opts.directories {
263 args.push("-d".to_string());
264 }
265 if opts.ignored {
266 args.push("-x".to_string());
267 }
268 if opts.force {
269 args.push("-f".to_string());
270 } else {
271 args.push("-n".to_string());
272 }
273 args.extend(opts.extra_args);
274
275 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
276 let output = self.run(&refs)?;
277 Ok(String::from_utf8_lossy(&output)
278 .lines()
279 .filter_map(|line| {
280 let line = line.trim();
281 line.strip_prefix("Removing ")
282 .or_else(|| line.strip_prefix("Would remove "))
283 .map(str::to_string)
284 })
285 .collect())
286 }
287}
288
289fn run_allow_empty(backend: &GitCli, args: &[&str]) -> AppResult<Vec<u8>> {
290 let output = backend.run_result(args)?;
291 if (output.success() || output.exit_code == Some(1))
292 && !output.stdout_truncated
293 && !output.stderr_truncated
294 {
295 Ok(output.stdout_bytes)
296 } else {
297 Err(GitCli::command_failed(args, output))
298 }
299}
300
301fn parse_branch(line: &str) -> AppResult<Branch> {
302 let mut parts = line.split('\0');
303 let name = parts.next().unwrap_or_default().to_string();
304 let target = super::parse_oid(parts.next().unwrap_or_default())?;
305 let upstream = parts
306 .next()
307 .filter(|value| !value.is_empty())
308 .map(str::to_string);
309 Ok(Branch {
310 name,
311 target,
312 upstream,
313 })
314}
315
316fn parse_tags(output: &[u8]) -> AppResult<Vec<Tag>> {
317 let text = String::from_utf8_lossy(output);
318 let fields = text.split_terminator('\0').collect::<Vec<_>>();
319 let chunks = fields.chunks_exact(8);
320 if !chunks.remainder().is_empty() {
321 return Err(AppError::invalid_format(
322 "tag list",
323 "records with 8 NUL-separated fields",
324 ));
325 }
326
327 chunks.map(parse_tag).collect()
328}
329
330fn parse_tag(fields: &[&str]) -> AppResult<Tag> {
331 let &[
332 name,
333 object_type,
334 object_oid,
335 peeled_oid,
336 tagger_name,
337 tagger_email,
338 tagger_date,
339 message,
340 ] = fields
341 else {
342 return Err(AppError::invalid_format("tag", "8 NUL-separated fields"));
343 };
344 let target = if object_type == "tag" && !peeled_oid.is_empty() {
345 super::parse_oid(peeled_oid)?
346 } else {
347 super::parse_oid(object_oid)?
348 };
349 let tagger = if !tagger_name.is_empty() {
350 let when = tagger_date
351 .trim()
352 .parse::<i64>()
353 .ok()
354 .and_then(|secs| {
355 if secs >= 0 {
356 UNIX_EPOCH.checked_add(Duration::from_secs(secs as u64))
357 } else {
358 UNIX_EPOCH.checked_sub(Duration::from_secs(secs.unsigned_abs()))
359 }
360 })
361 .unwrap_or(UNIX_EPOCH);
362 Some(Signature {
363 name: (*tagger_name).to_string(),
364 email: tagger_email
365 .trim_matches(|c| c == '<' || c == '>')
366 .to_string(),
367 when,
368 })
369 } else {
370 None
371 };
372 Ok(Tag {
373 name: (*name).to_string(),
374 target,
375 tagger,
376 message: if object_type == "tag" {
377 message.trim_end_matches('\n').to_string()
378 } else {
379 String::new()
380 },
381 })
382}
383
384#[cfg(test)]
385mod tests {
386 use super::parse_tags;
387
388 #[test]
389 fn parse_tags_preserves_multiline_contents() {
390 let output = concat!(
391 "v0.2.0\0",
392 "tag\0",
393 "0000000000000000000000000000000000000000\0",
394 "1111111111111111111111111111111111111111\0",
395 "Test User\0",
396 "test@example.com\0",
397 "1700000000\0",
398 "release\nnotes\0"
399 )
400 .as_bytes();
401 let tags = parse_tags(output).unwrap();
402
403 assert_eq!(tags.len(), 1);
404 assert_eq!(tags[0].name, "v0.2.0");
405 assert_eq!(tags[0].message, "release\nnotes");
406 assert_eq!(
407 tags[0].target.to_string(),
408 "1111111111111111111111111111111111111111"
409 );
410 }
411}