Skip to main content

rskit_git/cli/
write.rs

1//! Write operations for the Git CLI runner.
2
3use rskit_errors::{AppError, AppResult};
4
5use crate::options::{CheckoutOptions, CherryPickOptions, MergeOptions, RebaseOptions};
6use crate::read::Inspector;
7use crate::types::{MergeResult, Oid, RebaseResult, ResetMode, StashEntry};
8use crate::write::{CheckoutManager, CherryPicker, Merger, Rebaser, Resetter, Stasher};
9
10use super::GitCli;
11
12impl Merger for GitCli {
13    fn merge(&self, branch: &str, opts: Option<&MergeOptions>) -> AppResult<MergeResult> {
14        let opts = opts.cloned().unwrap_or_default();
15        let mut args = vec!["merge".to_string()];
16        if opts.no_fast_forward {
17            args.push("--no-ff".to_string());
18        }
19        if opts.squash {
20            args.push("--squash".to_string());
21        }
22        if let Some(message) = opts.message {
23            args.push("-m".to_string());
24            args.push(message);
25        }
26        args.extend(opts.extra_args);
27        args.push("--".to_string());
28        args.push(branch.to_string());
29
30        let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
31        match self.run(&refs) {
32            Ok(output) => {
33                let head = self.rev_parse("HEAD").ok();
34                let stdout = String::from_utf8_lossy(&output);
35                let fast_forward = stdout.contains("Fast-forward");
36                Ok(MergeResult {
37                    head,
38                    fast_forward,
39                    conflicts: Vec::new(),
40                })
41            }
42            Err(err) => match conflict_stderr(&err) {
43                Some(stderr) if stderr.contains("CONFLICT") => Ok(MergeResult {
44                    head: None,
45                    fast_forward: false,
46                    conflicts: parse_conflict_paths(self),
47                }),
48                _ => Err(err),
49            },
50        }
51    }
52
53    fn abort_merge(&self) -> AppResult<()> {
54        self.run(&["merge", "--abort"])?;
55        Ok(())
56    }
57}
58
59impl Rebaser for GitCli {
60    fn rebase(&self, onto: &str, opts: Option<&RebaseOptions>) -> AppResult<RebaseResult> {
61        let opts = opts.cloned().unwrap_or_default();
62        let mut args = vec!["rebase".to_string()];
63        if opts.interactive {
64            args.push("-i".to_string());
65        }
66        if opts.autosquash {
67            args.push("--autosquash".to_string());
68        }
69        args.extend(opts.extra_args);
70        args.push(onto.to_string());
71
72        let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
73        let old_head = self.rev_parse("HEAD").ok();
74        match self.run(&refs) {
75            Ok(_) => {
76                let new_head = self.rev_parse("HEAD")?;
77                let applied = match &old_head {
78                    Some(oh) => {
79                        let range = format!("{}..{}", oh, new_head);
80                        self.run(&["rev-list", "--count", &range])
81                            .ok()
82                            .and_then(|out| String::from_utf8_lossy(&out).trim().parse().ok())
83                            .unwrap_or(0)
84                    }
85                    None => 0,
86                };
87                Ok(RebaseResult {
88                    head: Some(new_head),
89                    applied,
90                    conflicts: Vec::new(),
91                })
92            }
93            Err(err) => match conflict_stderr(&err) {
94                Some(stderr) if stderr.contains("CONFLICT") => Ok(RebaseResult {
95                    head: None,
96                    applied: 0,
97                    conflicts: parse_conflict_paths(self),
98                }),
99                _ => Err(err),
100            },
101        }
102    }
103
104    fn abort_rebase(&self) -> AppResult<()> {
105        self.run(&["rebase", "--abort"])?;
106        Ok(())
107    }
108
109    fn continue_rebase(&self) -> AppResult<RebaseResult> {
110        let old_head = self.rev_parse("HEAD").ok();
111        match self.run(&["rebase", "--continue"]) {
112            Ok(_) => {
113                let new_head = self.rev_parse("HEAD")?;
114                let applied = match &old_head {
115                    Some(old) => self
116                        .run(&["rev-list", "--count", &format!("{old}..{new_head}")])
117                        .ok()
118                        .and_then(|out| String::from_utf8_lossy(&out).trim().parse::<usize>().ok())
119                        .unwrap_or(0),
120                    None => 0,
121                };
122                Ok(RebaseResult {
123                    head: Some(new_head),
124                    applied,
125                    conflicts: Vec::new(),
126                })
127            }
128            Err(err) => match conflict_stderr(&err) {
129                Some(stderr) if stderr.contains("CONFLICT") => Ok(RebaseResult {
130                    head: None,
131                    applied: 0,
132                    conflicts: parse_conflict_paths(self),
133                }),
134                _ => Err(err),
135            },
136        }
137    }
138}
139
140impl CherryPicker for GitCli {
141    fn cherry_pick(&self, commit: &str, opts: Option<&CherryPickOptions>) -> AppResult<Oid> {
142        let opts = opts.cloned().unwrap_or_default();
143        let mut args = vec!["cherry-pick".to_string()];
144        if let Some(mainline) = opts.mainline {
145            args.push("-m".to_string());
146            args.push(mainline.to_string());
147        }
148        if opts.no_commit {
149            args.push("--no-commit".to_string());
150        }
151        args.extend(opts.extra_args);
152        args.push(commit.to_string());
153
154        let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
155        self.run(&refs)?;
156        self.rev_parse("HEAD")
157    }
158
159    fn cherry_pick_continue(&self) -> AppResult<Oid> {
160        self.run(&["cherry-pick", "--continue"])?;
161        self.rev_parse("HEAD")
162    }
163
164    fn cherry_pick_abort(&self) -> AppResult<()> {
165        self.run(&["cherry-pick", "--abort"])?;
166        Ok(())
167    }
168}
169
170impl Resetter for GitCli {
171    fn reset(&self, target: &str, mode: ResetMode) -> AppResult<()> {
172        let mode = match mode {
173            ResetMode::Mixed => "--mixed",
174            ResetMode::Soft => "--soft",
175            ResetMode::Hard => "--hard",
176        };
177        self.run(&["reset", mode, target])?;
178        Ok(())
179    }
180}
181
182impl CheckoutManager for GitCli {
183    fn checkout(&self, ref_name: &str, opts: Option<&CheckoutOptions>) -> AppResult<()> {
184        let opts = opts.cloned().unwrap_or_default();
185        let mut args = vec!["checkout".to_string()];
186        if opts.force {
187            args.push("-f".to_string());
188        }
189        if let Some(branch) = opts.create_branch {
190            args.push("-b".to_string());
191            args.push(branch);
192        }
193        if opts.detach {
194            args.push("--detach".to_string());
195        }
196        args.extend(opts.extra_args);
197        args.push(ref_name.to_string());
198
199        let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
200        self.run(&refs)?;
201        Ok(())
202    }
203
204    fn checkout_files(&self, paths: &[&str]) -> AppResult<()> {
205        if paths.is_empty() {
206            return Ok(());
207        }
208
209        let mut args = vec!["checkout"];
210        args.push("--");
211        args.extend(paths.iter().copied());
212        self.run(&args)?;
213        Ok(())
214    }
215}
216
217impl Stasher for GitCli {
218    fn stash(&self, message: &str) -> AppResult<Oid> {
219        self.run(&["stash", "push", "-m", message])?;
220        self.rev_parse("stash@{0}")
221    }
222
223    fn stash_pop(&self) -> AppResult<()> {
224        self.run(&["stash", "pop"])?;
225        Ok(())
226    }
227
228    fn stash_pop_index(&self, index: usize) -> AppResult<()> {
229        let reference = format!("stash@{{{index}}}");
230        self.run(&["stash", "pop", &reference])?;
231        Ok(())
232    }
233
234    fn stash_list(&self) -> AppResult<Vec<StashEntry>> {
235        let output = self.run(&["stash", "list"])?;
236        String::from_utf8_lossy(&output)
237            .lines()
238            .filter(|line| !line.trim().is_empty())
239            .map(|line| parse_stash_entry(self, line))
240            .collect()
241    }
242}
243
244fn parse_stash_entry(backend: &GitCli, line: &str) -> AppResult<StashEntry> {
245    let (reference, message) = line
246        .split_once(": ")
247        .ok_or_else(|| AppError::invalid_format("stash entry", line))?;
248    let start = reference
249        .find('{')
250        .ok_or_else(|| AppError::invalid_format("stash entry", line))?;
251    let end = reference[start + 1..]
252        .find('}')
253        .map(|idx| idx + start + 1)
254        .ok_or_else(|| AppError::invalid_format("stash entry", line))?;
255    let index = reference[start + 1..end]
256        .parse::<usize>()
257        .map_err(|_| AppError::invalid_format("stash index", line))?;
258
259    Ok(StashEntry {
260        index,
261        oid: backend.rev_parse(reference)?,
262        message: message.to_string(),
263    })
264}
265
266fn conflict_stderr(error: &AppError) -> Option<String> {
267    error
268        .cause()
269        .as_ref()
270        .map(|cause| cause.to_string())
271        .or_else(|| Some(error.message().to_string()))
272}
273
274fn parse_conflict_paths(backend: &GitCli) -> Vec<String> {
275    // Query git directly for conflicted paths rather than parsing human-readable messages.
276    match backend.run(&["diff", "--name-only", "--diff-filter=U"]) {
277        Ok(output) => {
278            let mut paths: Vec<String> = String::from_utf8_lossy(&output)
279                .lines()
280                .filter(|l| !l.trim().is_empty())
281                .map(str::to_string)
282                .collect();
283            paths.sort();
284            paths.dedup();
285            paths
286        }
287        _ => Vec::new(),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use rskit_errors::{AppError, ErrorCode};
294
295    use super::*;
296
297    fn run_git(dir: &std::path::Path, args: &[&str]) {
298        let status = std::process::Command::new("git")
299            .args(args)
300            .current_dir(dir)
301            .status()
302            .unwrap();
303        assert!(status.success(), "git {args:?} failed");
304    }
305
306    fn initialized_repo() -> tempfile::TempDir {
307        let repo = tempfile::tempdir().unwrap();
308        run_git(repo.path(), &["init", "-q"]);
309        run_git(repo.path(), &["config", "user.name", "Test User"]);
310        run_git(repo.path(), &["config", "user.email", "test@example.com"]);
311        std::fs::write(repo.path().join("file.txt"), "one\n").unwrap();
312        run_git(repo.path(), &["add", "file.txt"]);
313        run_git(repo.path(), &["commit", "-q", "-m", "initial"]);
314        repo
315    }
316
317    #[test]
318    fn parse_stash_entry_rejects_invalid_reference_shapes() {
319        let backend = GitCli::new(std::env::temp_dir());
320
321        assert!(parse_stash_entry(&backend, "stash@{0} missing separator").is_err());
322        assert!(parse_stash_entry(&backend, "stash@: message").is_err());
323        assert!(parse_stash_entry(&backend, "stash@{bad}: message").is_err());
324    }
325
326    #[test]
327    fn conflict_stderr_falls_back_to_error_message_without_cause() {
328        let error = AppError::new(ErrorCode::ExternalService, "merge failed");
329
330        assert_eq!(conflict_stderr(&error).as_deref(), Some("merge failed"));
331    }
332
333    #[test]
334    fn conflict_stderr_prefers_cause_and_conflict_paths_are_empty_outside_repo() {
335        let error = AppError::new(ErrorCode::ExternalService, "merge failed")
336            .with_cause(std::io::Error::other("CONFLICT in file.txt"));
337        let backend = GitCli::new(std::env::temp_dir());
338
339        assert_eq!(
340            conflict_stderr(&error).as_deref(),
341            Some("CONFLICT in file.txt")
342        );
343        assert!(parse_conflict_paths(&backend).is_empty());
344    }
345
346    #[test]
347    fn checkout_files_empty_path_list_is_noop() {
348        let backend = GitCli::new(std::env::temp_dir());
349
350        backend.checkout_files(&[]).unwrap();
351    }
352
353    #[test]
354    fn checkout_reset_and_stash_paths_execute_against_real_repo() {
355        let repo = initialized_repo();
356        let backend = GitCli::new(repo.path().to_path_buf());
357
358        backend
359            .checkout(
360                "HEAD",
361                Some(&CheckoutOptions {
362                    force: true,
363                    create_branch: Some("feature".to_string()),
364                    detach: false,
365                    extra_args: Vec::new(),
366                }),
367            )
368            .unwrap();
369        std::fs::write(repo.path().join("file.txt"), "two\n").unwrap();
370        backend.checkout_files(&["file.txt"]).unwrap();
371        assert_eq!(
372            std::fs::read_to_string(repo.path().join("file.txt")).unwrap(),
373            "one\n"
374        );
375
376        std::fs::write(repo.path().join("file.txt"), "three\n").unwrap();
377        let oid = backend.stash("work in progress").unwrap();
378        assert!(!oid.is_zero());
379        let entries = backend.stash_list().unwrap();
380        assert_eq!(entries[0].index, 0);
381        assert!(entries[0].message.contains("work in progress"));
382        backend.stash_pop_index(0).unwrap();
383        std::fs::write(repo.path().join("file.txt"), "four\n").unwrap();
384        backend.stash("second stash").unwrap();
385        backend.stash_pop().unwrap();
386        backend.reset("HEAD", ResetMode::Mixed).unwrap();
387    }
388}