Skip to main content

omni_dev/cli/git/
info.rs

1//! Info command — analyzes branch commits and outputs repository information.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7
8/// Info command options.
9#[derive(Parser)]
10pub struct InfoCommand {
11    /// Base branch to compare against (defaults to main/master).
12    #[arg(value_name = "BASE_BRANCH")]
13    pub base_branch: Option<String>,
14}
15
16impl InfoCommand {
17    /// Executes the info command.
18    pub fn execute(self) -> Result<()> {
19        let yaml_output = run_info(self.base_branch.as_deref(), None::<&str>)?;
20        println!("{yaml_output}");
21        Ok(())
22    }
23
24    /// Reads the PR template file if it exists, returning both content and location.
25    pub(crate) fn read_pr_template() -> Result<(String, String)> {
26        use std::fs;
27        use std::path::Path;
28
29        let template_path = Path::new(".github/pull_request_template.md");
30        if template_path.exists() {
31            let content = fs::read_to_string(template_path)
32                .context("Failed to read .github/pull_request_template.md")?;
33            Ok((content, template_path.to_string_lossy().to_string()))
34        } else {
35            anyhow::bail!("PR template file does not exist")
36        }
37    }
38
39    /// Returns pull requests for the current branch using gh CLI.
40    pub(crate) fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
41        use serde_json::Value;
42        use std::process::Command;
43
44        // Use gh CLI to get PRs for the branch
45        let output = Command::new("gh")
46            .args([
47                "pr",
48                "list",
49                "--head",
50                branch_name,
51                "--json",
52                "number,title,state,url,body,baseRefName",
53                "--limit",
54                "50",
55            ])
56            .output()
57            .context("Failed to execute gh command")?;
58
59        if !output.status.success() {
60            anyhow::bail!(
61                "gh command failed: {}",
62                String::from_utf8_lossy(&output.stderr)
63            );
64        }
65
66        let json_str = String::from_utf8_lossy(&output.stdout);
67        let prs_json: Value =
68            serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
69
70        let mut prs = Vec::new();
71        if let Some(prs_array) = prs_json.as_array() {
72            for pr_json in prs_array {
73                if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
74                    pr_json.get("number").and_then(serde_json::Value::as_u64),
75                    pr_json.get("title").and_then(|t| t.as_str()),
76                    pr_json.get("state").and_then(|s| s.as_str()),
77                    pr_json.get("url").and_then(|u| u.as_str()),
78                    pr_json.get("body").and_then(|b| b.as_str()),
79                ) {
80                    let base = pr_json
81                        .get("baseRefName")
82                        .and_then(|b| b.as_str())
83                        .unwrap_or("")
84                        .to_string();
85                    prs.push(crate::data::PullRequest {
86                        number,
87                        title: title.to_string(),
88                        state: state.to_string(),
89                        url: url.to_string(),
90                        body: body.to_string(),
91                        base,
92                    });
93                }
94            }
95        }
96
97        Ok(prs)
98    }
99}
100
101/// Runs the info logic and returns the repository YAML as a `String`.
102///
103/// Shared by the CLI (which prints the result) and the MCP server (which
104/// returns it as tool content). When `repo_path` is `Some`, opens the
105/// repository at that path; otherwise opens at the current working directory.
106/// `base_branch` defaults to `main` or `master` when omitted.
107pub fn run_info<P: AsRef<Path>>(base_branch: Option<&str>, repo_path: Option<P>) -> Result<String> {
108    use crate::data::{
109        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
110        WorkingDirectoryInfo,
111    };
112    use crate::git::{GitRepository, RemoteInfo};
113    use crate::utils::ai_scratch;
114
115    let repo = if let Some(path) = repo_path {
116        GitRepository::open_at(path).context("Failed to open git repository at the given path")?
117    } else {
118        crate::utils::check_git_repository()?;
119        GitRepository::open()
120            .context("Failed to open git repository. Make sure you're in a git repository.")?
121    };
122
123    let current_branch = repo
124        .get_current_branch()
125        .context("Failed to get current branch. Make sure you're not in detached HEAD state.")?;
126
127    let resolved_base = match base_branch {
128        Some(branch) => {
129            if !repo.branch_exists(branch)? {
130                anyhow::bail!("Base branch '{branch}' does not exist");
131            }
132            branch.to_string()
133        }
134        None => {
135            if repo.branch_exists("main")? {
136                "main".to_string()
137            } else if repo.branch_exists("master")? {
138                "master".to_string()
139            } else {
140                anyhow::bail!("No default base branch found (main or master)");
141            }
142        }
143    };
144
145    let commit_range = format!("{resolved_base}..HEAD");
146
147    let wd_status = repo.get_working_directory_status()?;
148    let working_directory = WorkingDirectoryInfo {
149        clean: wd_status.clean,
150        untracked_changes: wd_status
151            .untracked_changes
152            .into_iter()
153            .map(|fs| FileStatusInfo {
154                status: fs.status,
155                file: fs.file,
156            })
157            .collect(),
158    };
159
160    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
161    let commits = repo.get_commits_in_range(&commit_range)?;
162
163    let (pr_template, pr_template_location) = match InfoCommand::read_pr_template().ok() {
164        Some((content, location)) => (Some(content), Some(location)),
165        None => (None, None),
166    };
167
168    let branch_prs = InfoCommand::get_branch_prs(&current_branch)
169        .ok()
170        .filter(|prs| !prs.is_empty());
171
172    let versions = Some(VersionInfo {
173        omni_dev: env!("CARGO_PKG_VERSION").to_string(),
174    });
175
176    let ai_scratch_path =
177        ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
178    let ai_info = AiInfo {
179        scratch: ai_scratch_path.to_string_lossy().to_string(),
180    };
181
182    let mut repo_view = RepositoryView {
183        versions,
184        explanation: FieldExplanation::default(),
185        working_directory,
186        remotes,
187        ai: ai_info,
188        branch_info: Some(BranchInfo {
189            branch: current_branch,
190        }),
191        pr_template,
192        pr_template_location,
193        branch_prs,
194        commits,
195    };
196
197    repo_view.to_yaml_output()
198}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used, clippy::expect_used)]
202mod tests {
203    use super::*;
204    use git2::{Repository, Signature};
205    use tempfile::TempDir;
206
207    fn init_repo_with_commits() -> (TempDir, Vec<git2::Oid>) {
208        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
209        std::fs::create_dir_all(&tmp_root).unwrap();
210        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
211        let repo_path = temp_dir.path();
212        let repo = Repository::init(repo_path).unwrap();
213        {
214            let mut config = repo.config().unwrap();
215            config.set_str("user.name", "Test").unwrap();
216            config.set_str("user.email", "test@example.com").unwrap();
217            config.set_str("init.defaultBranch", "main").unwrap();
218        }
219
220        // Re-point HEAD at refs/heads/main so the first commit lands on "main"
221        repo.set_head("refs/heads/main").unwrap();
222
223        let signature = Signature::now("Test", "test@example.com").unwrap();
224        let mut commits = Vec::new();
225        for (i, msg) in ["base: init", "feat: work"].iter().enumerate() {
226            std::fs::write(repo_path.join("f.txt"), format!("c{i}")).unwrap();
227            let mut idx = repo.index().unwrap();
228            idx.add_path(std::path::Path::new("f.txt")).unwrap();
229            idx.write().unwrap();
230            let tree_id = idx.write_tree().unwrap();
231            let tree = repo.find_tree(tree_id).unwrap();
232            let parents: Vec<git2::Commit<'_>> = match commits.last() {
233                Some(id) => vec![repo.find_commit(*id).unwrap()],
234                None => vec![],
235            };
236            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
237            let oid = repo
238                .commit(
239                    Some("HEAD"),
240                    &signature,
241                    &signature,
242                    msg,
243                    &tree,
244                    &parent_refs,
245                )
246                .unwrap();
247            commits.push(oid);
248        }
249        (temp_dir, commits)
250    }
251
252    #[test]
253    fn run_info_default_branch_uses_main() {
254        let (temp_dir, _commits) = init_repo_with_commits();
255        // With only a `main` branch, HEAD==main → main..HEAD is empty, so the
256        // output lacks commits but still returns YAML with branch_info.
257        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
258        assert!(
259            yaml.contains("branch:"),
260            "yaml should include branch_info: {yaml}"
261        );
262    }
263
264    #[test]
265    fn run_info_with_explicit_missing_base_errors() {
266        let (temp_dir, _commits) = init_repo_with_commits();
267        let err = run_info(Some("no-such-branch"), Some(temp_dir.path())).unwrap_err();
268        let msg = format!("{err:#}");
269        assert!(
270            msg.contains("no-such-branch"),
271            "expected missing-branch error: {msg}"
272        );
273    }
274
275    #[test]
276    fn run_info_no_default_base_branch_errors() {
277        // Init an empty repo with only a non-main branch.
278        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
279        std::fs::create_dir_all(&tmp_root).unwrap();
280        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
281        let repo = Repository::init(temp_dir.path()).unwrap();
282        {
283            let mut config = repo.config().unwrap();
284            config.set_str("user.name", "Test").unwrap();
285            config.set_str("user.email", "test@example.com").unwrap();
286        }
287        let signature = Signature::now("Test", "test@example.com").unwrap();
288        // Create on a non-default branch "dev" only.
289        repo.set_head("refs/heads/dev").unwrap();
290        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
291        let mut idx = repo.index().unwrap();
292        idx.add_path(std::path::Path::new("f.txt")).unwrap();
293        idx.write().unwrap();
294        let tree_id = idx.write_tree().unwrap();
295        let tree = repo.find_tree(tree_id).unwrap();
296        repo.commit(Some("HEAD"), &signature, &signature, "first", &tree, &[])
297            .unwrap();
298
299        let err = run_info(None, Some(temp_dir.path())).unwrap_err();
300        let msg = format!("{err:#}");
301        assert!(msg.contains("main or master"), "got: {msg}");
302    }
303
304    #[test]
305    fn run_info_with_invalid_path_returns_error() {
306        let err = run_info(None, Some("/no/such/path/exists")).unwrap_err();
307        let msg = format!("{err:#}");
308        assert!(
309            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repo"),
310            "expected git/repo error, got: {msg}"
311        );
312    }
313
314    /// Exercises the `None` branch of `repo_path` (the CLI default), which
315    /// goes through `crate::utils::check_git_repository` and `GitRepository::open`.
316    /// We enter a fresh repo via `CwdGuard` so the test is hermetic.
317    #[tokio::test]
318    async fn run_info_opens_cwd_repo_when_no_path_given() {
319        let (temp_dir, _commits) = init_repo_with_commits();
320        let _guard = super::super::CwdGuard::enter(temp_dir.path())
321            .await
322            .unwrap();
323        let yaml = run_info(None, None::<&str>).unwrap();
324        assert!(yaml.contains("branch:"));
325    }
326
327    #[test]
328    fn run_info_with_explicit_existing_base_succeeds() {
329        let (temp_dir, _commits) = init_repo_with_commits();
330        // Explicitly pass "main" as base — branch exists, validation succeeds.
331        let yaml = run_info(Some("main"), Some(temp_dir.path())).unwrap();
332        assert!(yaml.contains("branch:"));
333    }
334
335    #[test]
336    fn run_info_falls_back_to_master_when_main_missing() {
337        // Init a repo with a `master` branch (no `main`) — exercises the
338        // master fallback in the default-base resolution.
339        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
340        std::fs::create_dir_all(&tmp_root).unwrap();
341        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
342        let repo = Repository::init(temp_dir.path()).unwrap();
343        {
344            let mut cfg = repo.config().unwrap();
345            cfg.set_str("user.name", "Test").unwrap();
346            cfg.set_str("user.email", "test@example.com").unwrap();
347        }
348        repo.set_head("refs/heads/master").unwrap();
349        let signature = Signature::now("Test", "test@example.com").unwrap();
350        std::fs::write(temp_dir.path().join("f.txt"), "x").unwrap();
351        let mut idx = repo.index().unwrap();
352        idx.add_path(std::path::Path::new("f.txt")).unwrap();
353        idx.write().unwrap();
354        let tree_id = idx.write_tree().unwrap();
355        let tree = repo.find_tree(tree_id).unwrap();
356        repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
357            .unwrap();
358
359        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
360        assert!(yaml.contains("branch:"));
361    }
362
363    /// Exercises the `read_pr_template()` Some arm by placing a PR template
364    /// in the expected location.
365    #[tokio::test]
366    async fn run_info_picks_up_pr_template_from_cwd() {
367        let (temp_dir, _commits) = init_repo_with_commits();
368        let github_dir = temp_dir.path().join(".github");
369        std::fs::create_dir_all(&github_dir).unwrap();
370        std::fs::write(
371            github_dir.join("pull_request_template.md"),
372            "## Sample Template",
373        )
374        .unwrap();
375
376        let _guard = super::super::CwdGuard::enter(temp_dir.path())
377            .await
378            .unwrap();
379        let yaml = run_info(None, None::<&str>).unwrap();
380        assert!(
381            yaml.contains("pr_template:") || yaml.contains("Sample Template"),
382            "expected PR template info in yaml: {yaml}"
383        );
384    }
385}