git_plumber/cli/
mod.rs

1use clap::{CommandFactory, Parser, Subcommand};
2use std::io::{self, Write};
3use std::path::PathBuf;
4
5pub mod formatters;
6
7/// Safe print function that handles broken pipe errors gracefully
8///
9/// When output is piped to commands like `head` that close early,
10/// subsequent writes will fail with a broken pipe error. This is
11/// expected behavior and should not cause the program to panic.
12pub fn safe_print(content: &str) -> Result<(), String> {
13    match io::stdout().write_all(content.as_bytes()) {
14        Ok(()) => {
15            // Attempt to flush, but ignore broken pipe errors
16            match io::stdout().flush() {
17                Ok(()) => Ok(()),
18                Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
19                Err(e) => Err(format!("Error flushing stdout: {e}")),
20            }
21        }
22        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
23        Err(e) => Err(format!("Error writing to stdout: {e}")),
24    }
25}
26
27/// Safe println function that handles broken pipe errors gracefully
28pub fn safe_println(content: &str) -> Result<(), String> {
29    safe_print(&format!("{content}\n"))
30}
31
32/// Determine if a string looks like a git object hash
33fn is_likely_hash(input: &str) -> bool {
34    // Must be 4-40 characters and all hex
35    if input.len() < 4 || input.len() > 40 {
36        return false;
37    }
38
39    // Check if all characters are valid hex
40    input.chars().all(|c| c.is_ascii_hexdigit())
41}
42
43/// Determine if a string looks like a file path
44fn is_likely_path(input: &str) -> bool {
45    // Contains path separators or file extensions
46    input.contains('/') || input.contains('\\') || input.contains('.')
47}
48
49#[derive(Parser)]
50#[command(name = "git-plumber")]
51#[command(about = "Explorer for git internals, the plumbing", long_about = None)]
52pub struct Cli {
53    #[arg(long = "repo", short = 'r', default_value = ".", global = true)]
54    pub repo_path: PathBuf,
55
56    /// Show version information
57    #[arg(long = "version", short = 'v', action = clap::ArgAction::SetTrue)]
58    pub version: bool,
59
60    #[command(subcommand)]
61    pub command: Option<Commands>,
62}
63
64#[derive(Subcommand)]
65pub enum Commands {
66    /// Start the TUI interface
67    Tui {
68        /// Reduce motion/animations in the TUI
69        #[arg(long = "reduced-motion", short = 'm', action = clap::ArgAction::SetTrue)]
70        reduced_motion: bool,
71    },
72
73    /// List objects
74    List {
75        #[arg(
76            default_value = "all",
77            help = "The type of objects to list:\n  pack  - for pack files only\n  loose - for loose objects only\n  all   - for everything supported\n"
78        )]
79        object_type: String,
80    },
81
82    /// View an object or file with detailed formatting
83    View {
84        /// Object hash (4-40 hex chars) or file path to view
85        #[arg(
86            required = true,
87            help = "Object hash (4-40 characters) or path to file"
88        )]
89        target: String,
90    },
91}
92
93/// Run the CLI application
94///
95/// # Errors
96///
97/// This function will return an error if:
98/// - The repository is not a valid git repository
99/// - Pack files cannot be read or parsed
100/// - File system operations fail
101/// - Command parsing fails
102pub fn run() -> Result<(), String> {
103    let cli = Cli::parse();
104
105    // Handle version flag first
106    if cli.version {
107        safe_print(&crate::version::get_version_info().to_string())?;
108        return Ok(());
109    }
110
111    let plumber = crate::GitPlumber::new(&cli.repo_path);
112
113    match &cli.command {
114        Some(Commands::Tui { reduced_motion }) => crate::tui::run_tui_with_options(
115            plumber,
116            crate::tui::RunOptions {
117                reduced_motion: *reduced_motion,
118            },
119        ),
120        Some(Commands::List { object_type }) => {
121            match object_type.as_str() {
122                "pack" => {
123                    // List pack files only
124                    match plumber.list_pack_files() {
125                        Ok(pack_files) => {
126                            if pack_files.is_empty() {
127                                safe_println("No pack files found")?;
128                            } else {
129                                safe_println(&format!("Found {} pack files:", pack_files.len()))?;
130                                for (i, file) in pack_files.iter().enumerate() {
131                                    safe_println(&format!("{}. {}", i + 1, file.display()))?;
132                                }
133                            }
134                            Ok(())
135                        }
136                        Err(e) => Err(format!("Error listing pack files: {e}")),
137                    }
138                }
139                "loose" => {
140                    // List loose objects only
141                    match plumber.get_loose_object_stats() {
142                        Ok(stats) => {
143                            safe_println("Loose object statistics:")?;
144                            safe_println(&stats.summary())?;
145                            safe_println("")?;
146
147                            // Show all loose objects
148                            match plumber.list_parsed_loose_objects(stats.total_count) {
149                                Ok(loose_objects) => {
150                                    if loose_objects.is_empty() {
151                                        safe_println("No loose objects found")?;
152                                    } else {
153                                        safe_println("Loose objects:")?;
154                                        for (i, obj) in loose_objects.iter().enumerate() {
155                                            let (short_hash, rest_hash) = obj.object_id.split_at(8);
156                                            safe_println(&format!(
157                                                "{}. \x1b[1m{}\x1b[22m{} ({}) - {} bytes",
158                                                i + 1,
159                                                short_hash,
160                                                rest_hash,
161                                                obj.object_type,
162                                                obj.size
163                                            ))?;
164                                        }
165                                    }
166                                    Ok(())
167                                }
168                                Err(e) => Err(format!("Error listing loose objects: {e}")),
169                            }
170                        }
171                        Err(e) => Err(format!("Error getting loose object stats: {e}")),
172                    }
173                }
174                _ => {
175                    // List all object types
176                    let mut has_error = false;
177                    let mut error_messages = Vec::new();
178
179                    // List pack files
180                    safe_println("Pack files:")?;
181                    match plumber.list_pack_files() {
182                        Ok(pack_files) => {
183                            if pack_files.is_empty() {
184                                safe_println("  No pack files found")?;
185                            } else {
186                                for file in pack_files {
187                                    safe_println(&format!("  {}", file.display()))?;
188                                }
189                            }
190                        }
191                        Err(e) => {
192                            has_error = true;
193                            error_messages.push(format!("Error listing pack files: {e}"));
194                            safe_println(&format!("  Error listing pack files: {e}"))?;
195                        }
196                    }
197
198                    safe_println("")?;
199
200                    // List loose objects
201                    safe_println("Loose objects:")?;
202                    match plumber.get_loose_object_stats() {
203                        Ok(stats) => {
204                            safe_println(&format!("  {}", stats.summary().replace('\n', "\n  ")))?;
205                        }
206                        Err(e) => {
207                            has_error = true;
208                            error_messages.push(format!("Error getting loose object stats: {e}"));
209                            safe_println(&format!("  Error getting loose object stats: {e}"))?;
210                        }
211                    }
212
213                    if has_error {
214                        Err(error_messages.join("; "))
215                    } else {
216                        Ok(())
217                    }
218                }
219            }
220        }
221        Some(Commands::View { target }) => {
222            // Determine if target is a hash or path
223            if is_likely_path(target) && !is_likely_hash(target) {
224                // Treat as file path
225                let path = PathBuf::from(target);
226                if path.exists() {
227                    // Check if it's a pack file or other git object file
228                    if path.extension().and_then(|s| s.to_str()) == Some("pack") {
229                        plumber.parse_pack_file_rich(&path)
230                    } else {
231                        // Try to parse as loose object file
232                        plumber.view_file_as_object(&path)
233                    }
234                } else {
235                    Err(format!("File not found: {}", path.display()))
236                }
237            } else if is_likely_hash(target) {
238                // Treat as object hash
239                plumber.view_object_by_hash(target)
240            } else {
241                // Ambiguous - try both approaches
242                let path = PathBuf::from(target);
243                if path.exists() {
244                    // File exists, treat as path
245                    if path.extension().and_then(|s| s.to_str()) == Some("pack") {
246                        plumber.parse_pack_file_rich(&path)
247                    } else {
248                        plumber.view_file_as_object(&path)
249                    }
250                } else if target.chars().all(|c| c.is_ascii_hexdigit()) {
251                    // Looks like hex but too short or too long
252                    if target.len() < 4 {
253                        Err(format!(
254                            "Hash too short: '{target}'. Git object hashes must be at least 4 characters long."
255                        ))
256                    } else if target.len() > 40 {
257                        Err(format!(
258                            "Hash too long: '{target}'. Git object hashes must be at most 40 characters long."
259                        ))
260                    } else {
261                        // Valid length hex but object not found
262                        plumber.view_object_by_hash(target)
263                    }
264                } else {
265                    Err(format!(
266                        "Invalid target: '{target}' is neither a valid file path nor object hash (hashes must be 4-40 hex characters)"
267                    ))
268                }
269            }
270        }
271        None => {
272            let mut cmd = Cli::command();
273            cmd.print_help().map_err(|e| e.to_string())?;
274            Ok(())
275        }
276    }
277}