1use crate::prompt;
2use nils_common::git as common_git;
3use std::io::{self, BufRead, Write};
4use std::process::Output;
5
6pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
7 match cmd {
8 "soft" => Some(reset_by_count("soft", args)),
9 "mixed" => Some(reset_by_count("mixed", args)),
10 "hard" => Some(reset_by_count("hard", args)),
11 "undo" => Some(reset_undo()),
12 "back-head" => Some(back_head()),
13 "back-checkout" => Some(back_checkout()),
14 "remote" => Some(reset_remote(args)),
15 _ => None,
16 }
17}
18
19fn reset_by_count(mode: &str, args: &[String]) -> i32 {
20 let count_arg = args.first();
21 let extra_arg = args.get(1);
22 if extra_arg.is_some() {
23 eprintln!("❌ Too many arguments.");
24 eprintln!("Usage: git-reset-{mode} [N]");
25 return 2;
26 }
27
28 let count = match count_arg {
29 Some(value) => match parse_positive_int(value) {
30 Some(value) => value,
31 None => {
32 eprintln!("❌ Invalid commit count: {value} (must be a positive integer).");
33 eprintln!("Usage: git-reset-{mode} [N]");
34 return 2;
35 }
36 },
37 None => 1,
38 };
39
40 let target = format!("HEAD~{count}");
41 if !git_success(&["rev-parse", "--verify", "--quiet", &target]) {
42 eprintln!("❌ Cannot resolve {target} (not enough commits?).");
43 return 1;
44 }
45
46 let commit_label = if count > 1 {
47 format!("last {count} commits")
48 } else {
49 "last commit".to_string()
50 };
51
52 let (preface, prompt, failure, success) = match mode {
53 "soft" => (
54 vec![
55 format!("⚠️ This will rewind your {commit_label} (soft reset)"),
56 "🧠 Your changes will remain STAGED. Useful for rewriting commit message."
57 .to_string(),
58 ],
59 format!("❓ Proceed with 'git reset --soft {target}'? [y/N] "),
60 "❌ Soft reset failed.".to_string(),
61 "✅ Reset completed. Your changes are still staged.".to_string(),
62 ),
63 "mixed" => (
64 vec![
65 format!("⚠️ This will rewind your {commit_label} (mixed reset)"),
66 "🧠 Your changes will become UNSTAGED and editable in working directory."
67 .to_string(),
68 ],
69 format!("❓ Proceed with 'git reset --mixed {target}'? [y/N] "),
70 "❌ Mixed reset failed.".to_string(),
71 "✅ Reset completed. Your changes are now unstaged.".to_string(),
72 ),
73 "hard" => (
74 vec![
75 format!("⚠️ This will HARD RESET your repository to {target}."),
76 "🔥 Tracked staged/unstaged changes will be OVERWRITTEN.".to_string(),
77 format!("🧨 This is equivalent to: git reset --hard {target}"),
78 ],
79 "❓ Are you absolutely sure? [y/N] ".to_string(),
80 "❌ Hard reset failed.".to_string(),
81 format!("✅ Hard reset completed. HEAD moved back to {target}."),
82 ),
83 _ => {
84 eprintln!("❌ Unknown reset mode: {mode}");
85 return 2;
86 }
87 };
88
89 for line in preface {
90 println!("{line}");
91 }
92 println!("🧾 Commits to be rewound:");
93
94 let output = match git_output(&[
95 "log",
96 "--no-color",
97 "-n",
98 &count.to_string(),
99 "--date=format:%m-%d %H:%M",
100 "--pretty=%h %ad %an %s",
101 ]) {
102 Some(output) => output,
103 None => return 1,
104 };
105 if !output.status.success() {
106 emit_output(&output);
107 return exit_code(&output);
108 }
109 emit_output(&output);
110
111 if !confirm_or_abort(&prompt) {
112 return 1;
113 }
114
115 let code = git_status(&["reset", &format!("--{mode}"), &target]).unwrap_or(1);
116 if code != 0 {
117 println!("{failure}");
118 return 1;
119 }
120
121 println!("{success}");
122 0
123}
124
125fn reset_undo() -> i32 {
126 if !git_success(&["rev-parse", "--is-inside-work-tree"]) {
127 println!("❌ Not a git repository.");
128 return 1;
129 }
130
131 let target_commit = match git_stdout_trimmed(&["rev-parse", "HEAD@{1}"]).and_then(non_empty) {
132 Some(value) => value,
133 None => {
134 println!("❌ Cannot resolve HEAD@{{1}} (no previous HEAD position in reflog).");
135 return 1;
136 }
137 };
138
139 let op_warnings = detect_in_progress_ops();
140 if !op_warnings.is_empty() {
141 println!("🛡️ Detected an in-progress Git operation:");
142 for warning in op_warnings {
143 println!(" - {warning}");
144 }
145 println!("⚠️ Resetting during these operations can be confusing.");
146 if !confirm_or_abort("❓ Still run git-reset-undo (move HEAD back)? [y/N] ") {
147 return 1;
148 }
149 }
150
151 let mut reflog_line_current =
152 git_stdout_trimmed(&["reflog", "-1", "--pretty=%h %gs", "HEAD@{0}"]).and_then(non_empty);
153 let mut reflog_subject_current =
154 git_stdout_trimmed(&["reflog", "-1", "--pretty=%gs", "HEAD@{0}"]).and_then(non_empty);
155
156 if reflog_line_current.is_none() || reflog_subject_current.is_none() {
157 reflog_line_current =
158 git_stdout_trimmed(&["reflog", "show", "-1", "--pretty=%h %gs", "HEAD"])
159 .and_then(non_empty);
160 reflog_subject_current =
161 git_stdout_trimmed(&["reflog", "show", "-1", "--pretty=%gs", "HEAD"])
162 .and_then(non_empty);
163 }
164
165 let mut reflog_line_target =
166 git_stdout_trimmed(&["reflog", "-1", "--pretty=%h %gs", "HEAD@{1}"]).and_then(non_empty);
167 if reflog_line_target.is_none() {
168 reflog_line_target = reflog_show_line(2, "%h %gs").and_then(non_empty);
169 }
170
171 let line_current = reflog_line_current.unwrap_or_else(|| "(unavailable)".to_string());
172 let line_target = reflog_line_target.unwrap_or_else(|| "(unavailable)".to_string());
173 let subject_current = reflog_subject_current.unwrap_or_else(|| "(unavailable)".to_string());
174
175 println!("🧾 Current HEAD@{{0}} (last action):");
176 println!(" {line_current}");
177 println!("🧾 Target HEAD@{{1}} (previous HEAD position):");
178 println!(" {line_target}");
179
180 if line_current == "(unavailable)" || line_target == "(unavailable)" {
181 println!(
182 "ℹ️ Reflog display unavailable here; reset target is still the resolved SHA: {target_commit}"
183 );
184 }
185
186 if subject_current != "(unavailable)" && !subject_current.starts_with("reset:") {
187 println!("⚠️ The last action does NOT look like a reset operation.");
188 println!("🧠 It may be from checkout/rebase/merge/pull, etc.");
189 if !confirm_or_abort(
190 "❓ Still proceed to move HEAD back to the previous HEAD position? [y/N] ",
191 ) {
192 return 1;
193 }
194 }
195
196 println!("🕰 Target commit (resolved from HEAD@{{1}}):");
197 let log_output = match git_output(&["log", "--oneline", "-1", &target_commit]) {
198 Some(output) => output,
199 None => return 1,
200 };
201 if !log_output.status.success() {
202 emit_output(&log_output);
203 return exit_code(&log_output);
204 }
205 emit_output(&log_output);
206
207 let status_lines = match git_stdout_raw(&["status", "--porcelain"]) {
208 Some(value) => value,
209 None => return 1,
210 };
211
212 if status_lines.trim().is_empty() {
213 println!("✅ Working tree clean. Proceeding with: git reset --hard {target_commit}");
214 let code = git_status(&["reset", "--hard", &target_commit]).unwrap_or(1);
215 if code != 0 {
216 println!("❌ Hard reset failed.");
217 return 1;
218 }
219 println!("✅ Repository reset back to previous HEAD: {target_commit}");
220 return 0;
221 }
222
223 println!("⚠️ Working tree has changes:");
224 print!("{status_lines}");
225 if !status_lines.ends_with('\n') {
226 println!();
227 }
228 println!();
229 println!("Choose how to proceed:");
230 println!(
231 " 1) Keep changes + PRESERVE INDEX (staged vs new base) (git reset --soft {target_commit})"
232 );
233 println!(
234 " 2) Keep changes + UNSTAGE ALL (git reset --mixed {target_commit})"
235 );
236 println!(
237 " 3) Discard tracked changes (git reset --hard {target_commit})"
238 );
239 println!(" 4) Abort");
240
241 let choice = match read_line("❓ Select [1/2/3/4] (default: 4): ") {
242 Ok(value) => value,
243 Err(_) => {
244 println!("🚫 Aborted");
245 return 1;
246 }
247 };
248
249 match choice.as_str() {
250 "1" => {
251 println!(
252 "🧷 Preserving INDEX (staged) and working tree. Running: git reset --soft {target_commit}"
253 );
254 println!(
255 "⚠️ Note: The index is preserved, but what appears staged is relative to the new HEAD."
256 );
257 let code = git_status(&["reset", "--soft", &target_commit]).unwrap_or(1);
258 if code != 0 {
259 println!("❌ Soft reset failed.");
260 return 1;
261 }
262 println!("✅ HEAD moved back while preserving index + working tree: {target_commit}");
263 0
264 }
265 "2" => {
266 println!(
267 "🧷 Preserving working tree but clearing INDEX (unstage all). Running: git reset --mixed {target_commit}"
268 );
269 let code = git_status(&["reset", "--mixed", &target_commit]).unwrap_or(1);
270 if code != 0 {
271 println!("❌ Mixed reset failed.");
272 return 1;
273 }
274 println!("✅ HEAD moved back; working tree preserved; index reset: {target_commit}");
275 0
276 }
277 "3" => {
278 println!("🔥 Discarding tracked changes. Running: git reset --hard {target_commit}");
279 println!("⚠️ This overwrites tracked files in working tree + index.");
280 println!("ℹ️ Untracked files are NOT removed by reset --hard.");
281 if !confirm_or_abort("❓ Are you absolutely sure? [y/N] ") {
282 return 1;
283 }
284 let code = git_status(&["reset", "--hard", &target_commit]).unwrap_or(1);
285 if code != 0 {
286 println!("❌ Hard reset failed.");
287 return 1;
288 }
289 println!("✅ Repository reset back to previous HEAD: {target_commit}");
290 0
291 }
292 _ => {
293 println!("🚫 Aborted");
294 1
295 }
296 }
297}
298
299fn back_head() -> i32 {
300 let prev_head = match git_stdout_trimmed(&["rev-parse", "HEAD@{1}"]).and_then(non_empty) {
301 Some(value) => value,
302 None => {
303 println!("❌ Cannot find previous HEAD in reflog.");
304 return 1;
305 }
306 };
307
308 println!("⏪ This will move HEAD back to the previous position (HEAD@{{1}}):");
309 if let Some(oneline) = git_stdout_trimmed(&["log", "--oneline", "-1", &prev_head]) {
310 println!("🔁 {oneline}");
311 }
312 if !confirm_or_abort("❓ Proceed with 'git checkout HEAD@{1}'? [y/N] ") {
313 return 1;
314 }
315
316 let code = git_status(&["checkout", "HEAD@{1}"]).unwrap_or(1);
317 if code != 0 {
318 println!("❌ Checkout failed (likely due to local changes or invalid reflog state).");
319 return 1;
320 }
321
322 println!("✅ Restored to previous HEAD (HEAD@{{1}}): {prev_head}");
323 0
324}
325
326fn back_checkout() -> i32 {
327 let current_branch =
328 match git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "HEAD"]).and_then(non_empty) {
329 Some(value) => value,
330 None => {
331 println!("❌ Cannot determine current branch.");
332 return 1;
333 }
334 };
335
336 if current_branch == "HEAD" {
337 println!(
338 "❌ You are in a detached HEAD state. This function targets branch-to-branch checkouts."
339 );
340 println!(
341 "🧠 Tip: Use `git reflog` to find the branch/commit you want, then `git checkout <branch>`."
342 );
343 return 1;
344 }
345
346 let from_branch = match find_previous_checkout(¤t_branch) {
347 Some(value) => value,
348 None => {
349 println!("❌ Could not find a previous checkout that switched to {current_branch}.");
350 return 1;
351 }
352 };
353
354 if !from_branch.chars().all(|c| c.is_ascii_digit())
355 && from_branch.len() >= 7
356 && from_branch.len() <= 40
357 && from_branch.chars().all(|c| c.is_ascii_hexdigit())
358 {
359 println!(
360 "❌ Previous 'from' looks like a commit SHA ({from_branch}). Refusing to checkout to avoid detached HEAD."
361 );
362 println!("🧠 Use `git reflog` to choose the correct branch explicitly.");
363 return 1;
364 }
365
366 if !git_success(&[
367 "show-ref",
368 "--verify",
369 "--quiet",
370 &format!("refs/heads/{from_branch}"),
371 ]) {
372 println!("❌ '{from_branch}' is not an existing local branch.");
373 println!("🧠 If it's a remote branch, try: git checkout -t origin/{from_branch}");
374 return 1;
375 }
376
377 println!("⏪ This will move HEAD back to previous branch: {from_branch}");
378 if !confirm_or_abort(&format!(
379 "❓ Proceed with 'git checkout {from_branch}'? [y/N] "
380 )) {
381 return 1;
382 }
383
384 let code = git_status(&["checkout", &from_branch]).unwrap_or(1);
385 if code != 0 {
386 println!("❌ Checkout failed (likely due to local changes or conflicts).");
387 return 1;
388 }
389
390 println!("✅ Restored to previous branch: {from_branch}");
391 0
392}
393
394fn reset_remote(args: &[String]) -> i32 {
395 let mut want_help = false;
396 let mut want_yes = false;
397 let mut want_fetch = true;
398 let mut want_prune = false;
399 let mut want_clean = false;
400 let mut want_set_upstream = false;
401 let mut remote_arg: Option<String> = None;
402 let mut branch_arg: Option<String> = None;
403 let mut ref_arg: Option<String> = None;
404
405 let mut i = 0usize;
406 while i < args.len() {
407 let arg = args[i].as_str();
408 match arg {
409 "-h" | "--help" => {
410 want_help = true;
411 }
412 "-y" | "--yes" => {
413 want_yes = true;
414 }
415 "-r" | "--remote" => {
416 let Some(value) = args.get(i + 1) else {
417 return 2;
418 };
419 remote_arg = Some(value.to_string());
420 i += 1;
421 }
422 "-b" | "--branch" => {
423 let Some(value) = args.get(i + 1) else {
424 return 2;
425 };
426 branch_arg = Some(value.to_string());
427 i += 1;
428 }
429 "--ref" => {
430 let Some(value) = args.get(i + 1) else {
431 return 2;
432 };
433 ref_arg = Some(value.to_string());
434 i += 1;
435 }
436 "--no-fetch" => {
437 want_fetch = false;
438 }
439 "--prune" => {
440 want_prune = true;
441 }
442 "--clean" => {
443 want_clean = true;
444 }
445 "--set-upstream" => {
446 want_set_upstream = true;
447 }
448 _ => {}
449 }
450 i += 1;
451 }
452
453 if want_help {
454 print_reset_remote_help();
455 return 0;
456 }
457
458 let mut remote = remote_arg.clone().unwrap_or_default();
459 let mut remote_branch = branch_arg.clone().unwrap_or_default();
460
461 if let Some(reference) = ref_arg {
462 let Some((remote_ref, branch_ref)) = reference.split_once('/') else {
463 eprintln!("❌ --ref must look like '<remote>/<branch>' (got: {reference})");
464 return 2;
465 };
466 remote = remote_ref.to_string();
467 remote_branch = branch_ref.to_string();
468 }
469
470 if !git_success(&["rev-parse", "--git-dir"]) {
471 eprintln!("❌ Not inside a Git repository.");
472 return 1;
473 }
474
475 let current_branch = match git_stdout_trimmed(&["symbolic-ref", "--quiet", "--short", "HEAD"])
476 .and_then(non_empty)
477 {
478 Some(value) => value,
479 None => {
480 eprintln!("❌ Detached HEAD. Switch to a branch first.");
481 return 1;
482 }
483 };
484
485 let upstream =
486 git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
487 .unwrap_or_default();
488
489 if remote.is_empty()
490 && let Some((remote_ref, _)) = upstream.split_once('/')
491 {
492 remote = remote_ref.to_string();
493 }
494 if remote.is_empty() {
495 remote = "origin".to_string();
496 }
497
498 if remote_branch.is_empty() {
499 if let Some((_, branch_ref)) = upstream.split_once('/')
500 && branch_ref != "HEAD"
501 {
502 remote_branch = branch_ref.to_string();
503 }
504 if remote_branch.is_empty() {
505 remote_branch = current_branch.clone();
506 }
507 }
508
509 let target_ref = format!("{remote}/{remote_branch}");
510
511 if want_fetch {
512 let fetch_args = if want_prune {
513 vec!["fetch", "--prune", "--", &remote]
514 } else {
515 vec!["fetch", "--", &remote]
516 };
517 let code = git_status(&fetch_args).unwrap_or(1);
518 if code != 0 {
519 return code;
520 }
521 }
522
523 if !git_success(&[
524 "show-ref",
525 "--verify",
526 "--quiet",
527 &format!("refs/remotes/{remote}/{remote_branch}"),
528 ]) {
529 eprintln!("❌ Remote-tracking branch not found: {target_ref}");
530 eprintln!(" Try: git fetch --prune -- {remote}");
531 eprintln!(" Or verify: git branch -r | rg -n -- \"^\\\\s*{remote}/{remote_branch}$\"");
532 return 1;
533 }
534
535 let status_porcelain = git_stdout_raw(&["status", "--porcelain"]).unwrap_or_default();
536 if !want_yes {
537 println!("⚠️ This will OVERWRITE local branch '{current_branch}' with '{target_ref}'.");
538 if !status_porcelain.trim().is_empty() {
539 println!("🔥 Tracked staged/unstaged changes will be DISCARDED by --hard.");
540 println!("🧹 Untracked files will be kept (use --clean to remove).");
541 }
542 if !confirm_or_abort(&format!(
543 "❓ Proceed with: git reset --hard {target_ref} ? [y/N] "
544 )) {
545 return 1;
546 }
547 }
548
549 let code = git_status(&["reset", "--hard", &target_ref]).unwrap_or(1);
550 if code != 0 {
551 return code;
552 }
553
554 if want_clean {
555 if !want_yes {
556 println!("⚠️ Next: git clean -fd (removes untracked files/dirs)");
557 let ok = prompt::confirm("❓ Proceed with: git clean -fd ? [y/N] ").unwrap_or_default();
558 if !ok {
559 println!("ℹ️ Skipped git clean -fd");
560 want_clean = false;
561 }
562 }
563 if want_clean {
564 let code = git_status(&["clean", "-fd"]).unwrap_or(1);
565 if code != 0 {
566 return code;
567 }
568 }
569 }
570
571 if want_set_upstream || upstream.is_empty() {
572 let _ = git_status(&["branch", "--set-upstream-to", &target_ref, ¤t_branch]);
573 }
574
575 println!("✅ Done. '{current_branch}' now matches '{target_ref}'.");
576 0
577}
578
579fn parse_positive_int(raw: &str) -> Option<i64> {
580 if raw.is_empty() || !raw.chars().all(|c| c.is_ascii_digit()) {
581 return None;
582 }
583 let value = raw.parse::<i64>().ok()?;
584 if value <= 0 { None } else { Some(value) }
585}
586
587fn detect_in_progress_ops() -> Vec<String> {
588 let mut warnings = Vec::new();
589 if git_path_exists("MERGE_HEAD", true) {
590 warnings.push("merge in progress (suggest: git merge --abort)".to_string());
591 }
592 if git_path_exists("rebase-apply", false) || git_path_exists("rebase-merge", false) {
593 warnings.push("rebase in progress (suggest: git rebase --abort)".to_string());
594 }
595 if git_path_exists("CHERRY_PICK_HEAD", true) {
596 warnings.push("cherry-pick in progress (suggest: git cherry-pick --abort)".to_string());
597 }
598 if git_path_exists("REVERT_HEAD", true) {
599 warnings.push("revert in progress (suggest: git revert --abort)".to_string());
600 }
601 if git_path_exists("BISECT_LOG", true) {
602 warnings.push("bisect in progress (suggest: git bisect reset)".to_string());
603 }
604 warnings
605}
606
607fn git_path_exists(name: &str, is_file: bool) -> bool {
608 let output = git_stdout_trimmed(&["rev-parse", "--git-path", name]);
609 let Some(path) = output else {
610 return false;
611 };
612 let path = std::path::Path::new(&path);
613 if is_file {
614 path.is_file()
615 } else {
616 path.is_dir()
617 }
618}
619
620fn reflog_show_line(index: usize, pretty: &str) -> Option<String> {
621 let output = git_stdout_raw(&[
622 "reflog",
623 "show",
624 "-2",
625 &format!("--pretty={pretty}"),
626 "HEAD",
627 ])?;
628 output.lines().nth(index - 1).map(|line| line.to_string())
629}
630
631fn find_previous_checkout(current_branch: &str) -> Option<String> {
632 let output = git_stdout_raw(&["reflog", "--format=%gs"])?;
633 for line in output.lines() {
634 if !line.starts_with("checkout: moving from ") {
635 continue;
636 }
637 if !line.ends_with(&format!(" to {current_branch}")) {
638 continue;
639 }
640 let mut value = line.trim_start_matches("checkout: moving from ");
641 value = value.trim_end_matches(&format!(" to {current_branch}"));
642 return Some(value.to_string());
643 }
644 None
645}
646
647fn confirm_or_abort(prompt: &str) -> bool {
648 prompt::confirm_or_abort(prompt).is_ok()
649}
650
651fn read_line(prompt: &str) -> io::Result<String> {
652 let mut output = io::stdout();
653 output.write_all(prompt.as_bytes())?;
654 output.flush()?;
655 let mut input = String::new();
656 io::stdin().lock().read_line(&mut input)?;
657 Ok(input.trim_end_matches(['\n', '\r']).to_string())
658}
659
660fn git_output(args: &[&str]) -> Option<Output> {
661 common_git::run_output(args).ok()
662}
663
664fn git_status(args: &[&str]) -> Option<i32> {
665 common_git::run_status_inherit(args)
666 .ok()
667 .map(|status| status.code().unwrap_or(1))
668}
669
670fn git_success(args: &[&str]) -> bool {
671 matches!(git_output(args), Some(output) if output.status.success())
672}
673
674fn git_stdout_trimmed(args: &[&str]) -> Option<String> {
675 let output = git_output(args)?;
676 if !output.status.success() {
677 return None;
678 }
679 Some(trim_trailing_newlines(&String::from_utf8_lossy(
680 &output.stdout,
681 )))
682}
683
684fn git_stdout_raw(args: &[&str]) -> Option<String> {
685 let output = git_output(args)?;
686 if !output.status.success() {
687 return None;
688 }
689 Some(String::from_utf8_lossy(&output.stdout).to_string())
690}
691
692fn trim_trailing_newlines(input: &str) -> String {
693 input.trim_end_matches(['\n', '\r']).to_string()
694}
695
696fn non_empty(value: String) -> Option<String> {
697 if value.is_empty() { None } else { Some(value) }
698}
699
700fn emit_output(output: &Output) {
701 let _ = io::stdout().write_all(&output.stdout);
702 let _ = io::stderr().write_all(&output.stderr);
703}
704
705fn exit_code(output: &Output) -> i32 {
706 output.status.code().unwrap_or(1)
707}
708
709fn print_reset_remote_help() {
710 println!(
711 "git-reset-remote: overwrite current local branch with a remote-tracking branch (DANGEROUS)"
712 );
713 println!();
714 println!("Usage:");
715 println!(" git-reset-remote # reset current branch to its upstream (or origin/<branch>)");
716 println!(" git-reset-remote --ref origin/main");
717 println!(" git-reset-remote -r origin -b main");
718 println!();
719 println!("Options:");
720 println!(" -r, --remote <name> Remote name (default: from upstream, else origin)");
721 println!(
722 " -b, --branch <name> Remote branch name (default: from upstream, else current branch)"
723 );
724 println!(" --ref <remote/branch> Shortcut for --remote/--branch");
725 println!(" --no-fetch Skip 'git fetch' (uses existing remote-tracking refs)");
726 println!(" --prune Use 'git fetch --prune'");
727 println!(" --set-upstream Set upstream of current branch to <remote>/<branch>");
728 println!(
729 " --clean After reset, optionally run 'git clean -fd' (removes untracked)"
730 );
731 println!(" -y, --yes Skip confirmations");
732}
733
734#[cfg(test)]
735mod tests {
736 use super::{dispatch, non_empty, parse_positive_int, trim_trailing_newlines};
737 use nils_test_support::{CwdGuard, GlobalStateLock};
738 use pretty_assertions::assert_eq;
739
740 #[test]
741 fn dispatch_returns_none_for_unknown_subcommand() {
742 assert_eq!(dispatch("unknown", &[]), None);
743 }
744
745 #[test]
746 fn parse_positive_int_accepts_digits_only() {
747 assert_eq!(parse_positive_int("1"), Some(1));
748 assert_eq!(parse_positive_int("42"), Some(42));
749 assert_eq!(parse_positive_int("001"), Some(1));
750 }
751
752 #[test]
753 fn parse_positive_int_rejects_invalid_values() {
754 assert_eq!(parse_positive_int(""), None);
755 assert_eq!(parse_positive_int("0"), None);
756 assert_eq!(parse_positive_int("-1"), None);
757 assert_eq!(parse_positive_int("1.0"), None);
758 assert_eq!(parse_positive_int("abc"), None);
759 }
760
761 #[test]
762 fn trim_trailing_newlines_only_removes_line_endings() {
763 assert_eq!(trim_trailing_newlines("line\n"), "line");
764 assert_eq!(trim_trailing_newlines("line\r\n"), "line");
765 assert_eq!(trim_trailing_newlines("line "), "line ");
766 }
767
768 #[test]
769 fn non_empty_returns_none_for_empty_string() {
770 assert_eq!(non_empty(String::new()), None);
771 assert_eq!(non_empty("value".to_string()), Some("value".to_string()));
772 }
773
774 #[test]
775 fn reset_by_count_modes_return_usage_errors_for_invalid_arguments() {
776 let lock = GlobalStateLock::new();
777 let dir = tempfile::TempDir::new().expect("tempdir");
778 let _cwd = CwdGuard::set(&lock, dir.path()).expect("cwd");
779
780 let args = vec!["1".to_string(), "2".to_string()];
781 assert_eq!(dispatch("soft", &args), Some(2));
782 assert_eq!(dispatch("mixed", &args), Some(2));
783 assert_eq!(dispatch("hard", &args), Some(2));
784
785 let args = vec!["abc".to_string()];
786 assert_eq!(dispatch("soft", &args), Some(2));
787 }
788
789 #[test]
790 fn reset_by_count_returns_runtime_error_when_target_commit_missing() {
791 let lock = GlobalStateLock::new();
792 let dir = tempfile::TempDir::new().expect("tempdir");
793 let _cwd = CwdGuard::set(&lock, dir.path()).expect("cwd");
794 let args = vec!["999999".to_string()];
795 assert_eq!(dispatch("soft", &args), Some(1));
796 }
797
798 #[test]
799 fn reset_remote_argument_parsing_covers_help_and_usage_failures() {
800 let help_args = vec!["--help".to_string()];
801 assert_eq!(dispatch("remote", &help_args), Some(0));
802
803 let bad_ref_args = vec!["--ref".to_string(), "invalid".to_string()];
804 assert_eq!(dispatch("remote", &bad_ref_args), Some(2));
805
806 let missing_remote_value = vec!["--remote".to_string()];
807 assert_eq!(dispatch("remote", &missing_remote_value), Some(2));
808 }
809}