Skip to main content

git_cli/
reset.rs

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(&current_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, &current_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}