1use clap::{CommandFactory, Parser, Subcommand};
2use std::io::{self, Write};
3use std::path::PathBuf;
4
5pub mod formatters;
6
7pub fn safe_print(content: &str) -> Result<(), String> {
13 match io::stdout().write_all(content.as_bytes()) {
14 Ok(()) => {
15 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
27pub fn safe_println(content: &str) -> Result<(), String> {
29 safe_print(&format!("{content}\n"))
30}
31
32fn is_likely_hash(input: &str) -> bool {
34 if input.len() < 4 || input.len() > 40 {
36 return false;
37 }
38
39 input.chars().all(|c| c.is_ascii_hexdigit())
41}
42
43fn is_likely_path(input: &str) -> bool {
45 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 #[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 Tui {
68 #[arg(long = "reduced-motion", short = 'm', action = clap::ArgAction::SetTrue)]
70 reduced_motion: bool,
71 },
72
73 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 {
84 #[arg(
86 required = true,
87 help = "Object hash (4-40 characters) or path to file"
88 )]
89 target: String,
90 },
91}
92
93pub fn run() -> Result<(), String> {
103 let cli = Cli::parse();
104
105 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 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 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 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 let mut has_error = false;
177 let mut error_messages = Vec::new();
178
179 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 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 if is_likely_path(target) && !is_likely_hash(target) {
224 let path = PathBuf::from(target);
226 if path.exists() {
227 if path.extension().and_then(|s| s.to_str()) == Some("pack") {
229 plumber.parse_pack_file_rich(&path)
230 } else {
231 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 plumber.view_object_by_hash(target)
240 } else {
241 let path = PathBuf::from(target);
243 if path.exists() {
244 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 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 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}