omni_dev/cli/
git.rs

1//! Git-related CLI commands
2
3use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5
6/// Git operations
7#[derive(Parser)]
8pub struct GitCommand {
9    /// Git subcommand to execute
10    #[command(subcommand)]
11    pub command: GitSubcommands,
12}
13
14/// Git subcommands
15#[derive(Subcommand)]
16pub enum GitSubcommands {
17    /// Commit-related operations
18    Commit(CommitCommand),
19    /// Branch-related operations
20    Branch(BranchCommand),
21}
22
23/// Commit operations
24#[derive(Parser)]
25pub struct CommitCommand {
26    /// Commit subcommand to execute
27    #[command(subcommand)]
28    pub command: CommitSubcommands,
29}
30
31/// Commit subcommands
32#[derive(Subcommand)]
33pub enum CommitSubcommands {
34    /// Commit message operations
35    Message(MessageCommand),
36}
37
38/// Message operations
39#[derive(Parser)]
40pub struct MessageCommand {
41    /// Message subcommand to execute
42    #[command(subcommand)]
43    pub command: MessageSubcommands,
44}
45
46/// Message subcommands
47#[derive(Subcommand)]
48pub enum MessageSubcommands {
49    /// Analyze commits and output repository information in YAML format
50    View(ViewCommand),
51    /// Amend commit messages based on a YAML configuration file
52    Amend(AmendCommand),
53}
54
55/// View command options
56#[derive(Parser)]
57pub struct ViewCommand {
58    /// Commit range to analyze (e.g., HEAD~3..HEAD, abc123..def456)
59    #[arg(value_name = "COMMIT_RANGE")]
60    pub commit_range: Option<String>,
61}
62
63/// Amend command options  
64#[derive(Parser)]
65pub struct AmendCommand {
66    /// YAML file containing commit amendments
67    #[arg(value_name = "YAML_FILE")]
68    pub yaml_file: String,
69}
70
71/// Branch operations
72#[derive(Parser)]
73pub struct BranchCommand {
74    /// Branch subcommand to execute
75    #[command(subcommand)]
76    pub command: BranchSubcommands,
77}
78
79/// Branch subcommands
80#[derive(Subcommand)]
81pub enum BranchSubcommands {
82    /// Analyze branch commits and output repository information in YAML format
83    Info(InfoCommand),
84}
85
86/// Info command options
87#[derive(Parser)]
88pub struct InfoCommand {
89    /// Base branch to compare against (defaults to main/master)
90    #[arg(value_name = "BASE_BRANCH")]
91    pub base_branch: Option<String>,
92}
93
94impl GitCommand {
95    /// Execute git command
96    pub fn execute(self) -> Result<()> {
97        match self.command {
98            GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
99            GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
100        }
101    }
102}
103
104impl CommitCommand {
105    /// Execute commit command
106    pub fn execute(self) -> Result<()> {
107        match self.command {
108            CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
109        }
110    }
111}
112
113impl MessageCommand {
114    /// Execute message command
115    pub fn execute(self) -> Result<()> {
116        match self.command {
117            MessageSubcommands::View(view_cmd) => view_cmd.execute(),
118            MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
119        }
120    }
121}
122
123impl ViewCommand {
124    /// Execute view command
125    pub fn execute(self) -> Result<()> {
126        use crate::data::{
127            AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
128            WorkingDirectoryInfo,
129        };
130        use crate::git::{GitRepository, RemoteInfo};
131        use crate::utils::ai_scratch;
132
133        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
134
135        // Open git repository
136        let repo = GitRepository::open()
137            .context("Failed to open git repository. Make sure you're in a git repository.")?;
138
139        // Get working directory status
140        let wd_status = repo.get_working_directory_status()?;
141        let working_directory = WorkingDirectoryInfo {
142            clean: wd_status.clean,
143            untracked_changes: wd_status
144                .untracked_changes
145                .into_iter()
146                .map(|fs| FileStatusInfo {
147                    status: fs.status,
148                    file: fs.file,
149                })
150                .collect(),
151        };
152
153        // Get remote information
154        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
155
156        // Parse commit range and get commits
157        let commits = repo.get_commits_in_range(commit_range)?;
158
159        // Create version information
160        let versions = Some(VersionInfo {
161            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
162        });
163
164        // Get AI scratch directory
165        let ai_scratch_path =
166            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
167        let ai_info = AiInfo {
168            scratch: ai_scratch_path.to_string_lossy().to_string(),
169        };
170
171        // Build repository view
172        let mut repo_view = RepositoryView {
173            versions,
174            explanation: FieldExplanation::default(),
175            working_directory,
176            remotes,
177            ai: ai_info,
178            branch_info: None,
179            pr_template: None,
180            branch_prs: None,
181            commits,
182        };
183
184        // Update field presence based on actual data
185        repo_view.update_field_presence();
186
187        // Output as YAML
188        let yaml_output = crate::data::to_yaml(&repo_view)?;
189        println!("{}", yaml_output);
190
191        Ok(())
192    }
193}
194
195impl AmendCommand {
196    /// Execute amend command
197    pub fn execute(self) -> Result<()> {
198        use crate::git::AmendmentHandler;
199
200        println!("🔄 Starting commit amendment process...");
201        println!("📄 Loading amendments from: {}", self.yaml_file);
202
203        // Create amendment handler and apply amendments
204        let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
205
206        handler
207            .apply_amendments(&self.yaml_file)
208            .context("Failed to apply amendments")?;
209
210        Ok(())
211    }
212}
213
214impl BranchCommand {
215    /// Execute branch command
216    pub fn execute(self) -> Result<()> {
217        match self.command {
218            BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
219        }
220    }
221}
222
223impl InfoCommand {
224    /// Execute info command
225    pub fn execute(self) -> Result<()> {
226        use crate::data::{
227            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
228            WorkingDirectoryInfo,
229        };
230        use crate::git::{GitRepository, RemoteInfo};
231        use crate::utils::ai_scratch;
232
233        // Open git repository
234        let repo = GitRepository::open()
235            .context("Failed to open git repository. Make sure you're in a git repository.")?;
236
237        // Get current branch name
238        let current_branch = repo.get_current_branch().context(
239            "Failed to get current branch. Make sure you're not in detached HEAD state.",
240        )?;
241
242        // Determine base branch
243        let base_branch = match self.base_branch {
244            Some(branch) => {
245                // Validate that the specified base branch exists
246                if !repo.branch_exists(&branch)? {
247                    anyhow::bail!("Base branch '{}' does not exist", branch);
248                }
249                branch
250            }
251            None => {
252                // Default to main or master
253                if repo.branch_exists("main")? {
254                    "main".to_string()
255                } else if repo.branch_exists("master")? {
256                    "master".to_string()
257                } else {
258                    anyhow::bail!("No default base branch found (main or master)");
259                }
260            }
261        };
262
263        // Calculate commit range: [base_branch]..HEAD
264        let commit_range = format!("{}..HEAD", base_branch);
265
266        // Get working directory status
267        let wd_status = repo.get_working_directory_status()?;
268        let working_directory = WorkingDirectoryInfo {
269            clean: wd_status.clean,
270            untracked_changes: wd_status
271                .untracked_changes
272                .into_iter()
273                .map(|fs| FileStatusInfo {
274                    status: fs.status,
275                    file: fs.file,
276                })
277                .collect(),
278        };
279
280        // Get remote information
281        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
282
283        // Parse commit range and get commits
284        let commits = repo.get_commits_in_range(&commit_range)?;
285
286        // Check for PR template
287        let pr_template = Self::read_pr_template().ok();
288
289        // Get PRs for current branch
290        let branch_prs = Self::get_branch_prs(&current_branch)
291            .ok()
292            .filter(|prs| !prs.is_empty());
293
294        // Create version information
295        let versions = Some(VersionInfo {
296            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
297        });
298
299        // Get AI scratch directory
300        let ai_scratch_path =
301            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
302        let ai_info = AiInfo {
303            scratch: ai_scratch_path.to_string_lossy().to_string(),
304        };
305
306        // Build repository view with branch info
307        let mut repo_view = RepositoryView {
308            versions,
309            explanation: FieldExplanation::default(),
310            working_directory,
311            remotes,
312            ai: ai_info,
313            branch_info: Some(BranchInfo {
314                branch: current_branch,
315            }),
316            pr_template,
317            branch_prs,
318            commits,
319        };
320
321        // Update field presence based on actual data
322        repo_view.update_field_presence();
323
324        // Output as YAML
325        let yaml_output = crate::data::to_yaml(&repo_view)?;
326        println!("{}", yaml_output);
327
328        Ok(())
329    }
330
331    /// Read PR template file if it exists
332    fn read_pr_template() -> Result<String> {
333        use std::fs;
334        use std::path::Path;
335
336        let template_path = Path::new(".github/pull_request_template.md");
337        if template_path.exists() {
338            fs::read_to_string(template_path)
339                .context("Failed to read .github/pull_request_template.md")
340        } else {
341            anyhow::bail!("PR template file does not exist")
342        }
343    }
344
345    /// Get pull requests for the current branch using gh CLI
346    fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
347        use serde_json::Value;
348        use std::process::Command;
349
350        // Use gh CLI to get PRs for the branch
351        let output = Command::new("gh")
352            .args([
353                "pr",
354                "list",
355                "--head",
356                branch_name,
357                "--json",
358                "number,title,state,url,body",
359                "--limit",
360                "50",
361            ])
362            .output()
363            .context("Failed to execute gh command")?;
364
365        if !output.status.success() {
366            anyhow::bail!(
367                "gh command failed: {}",
368                String::from_utf8_lossy(&output.stderr)
369            );
370        }
371
372        let json_str = String::from_utf8_lossy(&output.stdout);
373        let prs_json: Value =
374            serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
375
376        let mut prs = Vec::new();
377        if let Some(prs_array) = prs_json.as_array() {
378            for pr_json in prs_array {
379                if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
380                    pr_json.get("number").and_then(|n| n.as_u64()),
381                    pr_json.get("title").and_then(|t| t.as_str()),
382                    pr_json.get("state").and_then(|s| s.as_str()),
383                    pr_json.get("url").and_then(|u| u.as_str()),
384                    pr_json.get("body").and_then(|b| b.as_str()),
385                ) {
386                    prs.push(crate::data::PullRequest {
387                        number,
388                        title: title.to_string(),
389                        state: state.to_string(),
390                        url: url.to_string(),
391                        body: body.to_string(),
392                    });
393                }
394            }
395        }
396
397        Ok(prs)
398    }
399}