Skip to main content

git_cli/
utils.rs

1use crate::clipboard;
2use nils_common::process;
3use nils_common::shell::quote_posix_single;
4use std::io::{self, Write};
5use std::process::Output;
6
7pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
8    match cmd {
9        "zip" => Some(zip(args)),
10        "copy-staged" | "copy" => Some(copy_staged(args)),
11        "root" => Some(root(args)),
12        "commit-hash" | "hash" => Some(commit_hash(args)),
13        _ => None,
14    }
15}
16
17fn zip(_args: &[String]) -> i32 {
18    let short = match git_stdout_trimmed(&["rev-parse", "--short", "HEAD"]) {
19        Ok(value) => value,
20        Err(code) => return code,
21    };
22    let filename = format!("backup-{short}.zip");
23    let output = match run_git_output(&["archive", "--format", "zip", "HEAD", "-o", &filename]) {
24        Some(output) => output,
25        None => return 1,
26    };
27    if output.status.success() {
28        0
29    } else {
30        emit_output(&output);
31        exit_code(&output)
32    }
33}
34
35fn copy_staged(args: &[String]) -> i32 {
36    let mut mode = CopyMode::Clipboard;
37    let mut mode_flags = 0usize;
38    let mut unknown_arg: Option<String> = None;
39
40    for arg in args {
41        match arg.as_str() {
42            "--stdout" | "-p" | "--print" => {
43                mode = CopyMode::Stdout;
44                mode_flags += 1;
45            }
46            "--both" => {
47                mode = CopyMode::Both;
48                mode_flags += 1;
49            }
50            "--help" | "-h" => {
51                print_copy_staged_help();
52                return 0;
53            }
54            _ => {
55                if unknown_arg.is_none() {
56                    unknown_arg = Some(arg.to_string());
57                }
58            }
59        }
60    }
61
62    if mode_flags > 1 {
63        eprintln!("❗ Only one output mode is allowed: --stdout or --both");
64        return 1;
65    }
66
67    if let Some(arg) = unknown_arg {
68        eprintln!("❗ Unknown argument: {arg}");
69        eprintln!("Usage: git-copy-staged [--stdout|--both]");
70        return 1;
71    }
72
73    let output = match run_git_output(&["diff", "--cached", "--no-color"]) {
74        Some(output) => output,
75        None => return 1,
76    };
77    if !output.status.success() {
78        emit_output(&output);
79        return exit_code(&output);
80    }
81
82    let diff = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string();
83    if diff.is_empty() {
84        println!("⚠️  No staged changes to copy");
85        return 1;
86    }
87
88    match mode {
89        CopyMode::Stdout => {
90            println!("{diff}");
91            0
92        }
93        CopyMode::Clipboard => {
94            let _ = clipboard::set_clipboard_best_effort(&diff);
95            println!("✅ Staged diff copied to clipboard");
96            0
97        }
98        CopyMode::Both => {
99            let _ = clipboard::set_clipboard_best_effort(&diff);
100            println!("{diff}");
101            println!("✅ Staged diff copied to clipboard");
102            0
103        }
104    }
105}
106
107fn root(args: &[String]) -> i32 {
108    let shell_mode = args.iter().any(|arg| arg == "--shell");
109    let output = match run_git_output(&["rev-parse", "--show-toplevel"]) {
110        Some(output) => output,
111        None => return 1,
112    };
113
114    if !output.status.success() {
115        eprintln!("❌ Not in a git repository");
116        return 1;
117    }
118
119    let root = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string();
120    if shell_mode {
121        println!("cd -- {}", shell_escape(&root));
122        eprintln!("📁 Jumped to Git root: {root}");
123    } else {
124        println!();
125        println!("📁 Jumped to Git root: {root}");
126    }
127    0
128}
129
130fn commit_hash(args: &[String]) -> i32 {
131    let Some(ref_arg) = args.first() else {
132        eprintln!("❌ Missing git ref");
133        return 1;
134    };
135
136    let ref_commit = format!("{ref_arg}^{{commit}}");
137    let output = match run_git_output(&["rev-parse", "--verify", "--quiet", &ref_commit]) {
138        Some(output) => output,
139        None => return 1,
140    };
141    if !output.status.success() {
142        emit_output(&output);
143        return exit_code(&output);
144    }
145
146    let _ = io::stdout().write_all(&output.stdout);
147    0
148}
149
150fn run_git_output(args: &[&str]) -> Option<Output> {
151    match run_output("git", args) {
152        Ok(output) => Some(output),
153        Err(err) => {
154            eprintln!("{err}");
155            None
156        }
157    }
158}
159
160fn run_output(cmd: &str, args: &[&str]) -> Result<Output, String> {
161    process::run_output(cmd, args)
162        .map(|output| output.into_std_output())
163        .map_err(|err| format!("spawn {cmd}: {err}"))
164}
165
166fn git_stdout_trimmed(args: &[&str]) -> Result<String, i32> {
167    let output = run_git_output(args).ok_or(1)?;
168    if !output.status.success() {
169        emit_output(&output);
170        return Err(exit_code(&output));
171    }
172    Ok(trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout)).to_string())
173}
174
175fn exit_code(output: &Output) -> i32 {
176    output.status.code().unwrap_or(1)
177}
178
179fn emit_output(output: &Output) {
180    let _ = io::stdout().write_all(&output.stdout);
181    let _ = io::stderr().write_all(&output.stderr);
182}
183
184fn trim_trailing_newlines(input: &str) -> &str {
185    input.trim_end_matches(['\n', '\r'])
186}
187
188fn shell_escape(value: &str) -> String {
189    quote_posix_single(value)
190}
191
192fn print_copy_staged_help() {
193    print!(
194        "Usage: git-copy-staged [--stdout|--both]\n  --stdout   Print staged diff to stdout (no status message)\n  --both     Print to stdout and copy to clipboard\n"
195    );
196}
197
198enum CopyMode {
199    Clipboard,
200    Stdout,
201    Both,
202}