Skip to main content

rskit_git/cli/
manage.rs

1//! Management operations for the Git CLI runner.
2
3use 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}