1use 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 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}