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    ///
19    /// `repo` is the repository location resolved at the CLI boundary
20    /// (`None` = current working directory).
21    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
22        let yaml_output = run_info(self.base_branch.as_deref(), repo)?;
23        println!("{yaml_output}");
24        Ok(())
25    }
26
27    /// Reads the PR template file if it exists, returning both content and location.
28    ///
29    /// Resolves `.github/pull_request_template.md` against `repo_root` rather
30    /// than the process current working directory.
31    pub(crate) fn read_pr_template(repo_root: &Path) -> Result<(String, String)> {
32        use std::fs;
33
34        let template_path = repo_root.join(".github/pull_request_template.md");
35        if template_path.exists() {
36            let content = fs::read_to_string(&template_path)
37                .context("Failed to read .github/pull_request_template.md")?;
38            Ok((content, template_path.to_string_lossy().to_string()))
39        } else {
40            anyhow::bail!("PR template file does not exist")
41        }
42    }
43
44    /// Returns pull requests for the current branch using gh CLI.
45    ///
46    /// Runs `gh` pinned to `repo_root` (via `.current_dir`) so it resolves the
47    /// repository from the injected path rather than the process CWD.
48    pub(crate) fn get_branch_prs(
49        branch_name: &str,
50        repo_root: &Path,
51    ) -> Result<Vec<crate::data::PullRequest>> {
52        use serde_json::Value;
53        use std::process::Command;
54
55        // Use gh CLI to get PRs for the branch
56        let output = Command::new("gh")
57            .current_dir(repo_root)
58            .args([
59                "pr",
60                "list",
61                "--head",
62                branch_name,
63                "--json",
64                "number,title,state,url,body,baseRefName",
65                "--limit",
66                "50",
67            ])
68            .output()
69            .context("Failed to execute gh command")?;
70
71        if !output.status.success() {
72            anyhow::bail!(
73                "gh command failed: {}",
74                String::from_utf8_lossy(&output.stderr)
75            );
76        }
77
78        let json_str = String::from_utf8_lossy(&output.stdout);
79        let prs_json: Value =
80            serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
81
82        let mut prs = Vec::new();
83        if let Some(prs_array) = prs_json.as_array() {
84            for pr_json in prs_array {
85                if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
86                    pr_json.get("number").and_then(serde_json::Value::as_u64),
87                    pr_json.get("title").and_then(|t| t.as_str()),
88                    pr_json.get("state").and_then(|s| s.as_str()),
89                    pr_json.get("url").and_then(|u| u.as_str()),
90                    pr_json.get("body").and_then(|b| b.as_str()),
91                ) {
92                    let base = pr_json
93                        .get("baseRefName")
94                        .and_then(|b| b.as_str())
95                        .unwrap_or("")
96                        .to_string();
97                    prs.push(crate::data::PullRequest {
98                        number,
99                        title: title.to_string(),
100                        state: state.to_string(),
101                        url: url.to_string(),
102                        body: body.to_string(),
103                        base,
104                    });
105                }
106            }
107        }
108
109        Ok(prs)
110    }
111}
112
113/// Runs the info logic and returns the repository YAML as a `String`.
114///
115/// Shared by the CLI (which prints the result) and the MCP server (which
116/// returns it as tool content). When `repo_path` is `Some`, opens the
117/// repository at that path; otherwise opens at the current working directory.
118/// `base_branch` defaults to `main` or `master` when omitted.
119pub fn run_info<P: AsRef<Path>>(base_branch: Option<&str>, repo_path: Option<P>) -> Result<String> {
120    use crate::data::{
121        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
122        WorkingDirectoryInfo,
123    };
124    use crate::git::{GitRepository, RemoteInfo};
125    use crate::utils::ai_scratch;
126
127    // Resolve the repo location: the injected path, or the current working
128    // directory as the default (resolved here at the entry point). Both branches
129    // open via `open_at`, so nothing falls back to a no-arg CWD open.
130    let repo = if let Some(path) = repo_path {
131        GitRepository::open_at(path).context("Failed to open git repository at the given path")?
132    } else {
133        let cwd = std::env::current_dir().context("Failed to determine current directory")?;
134        GitRepository::open_at(cwd)
135            .context("Failed to open git repository. Make sure you're in a git repository.")?
136    };
137
138    // Resolve the workdir of the opened repo once; all sibling reads (PR
139    // template, branch PRs via `gh`, AI scratch dir) anchor to it so they can
140    // never silently mix data from the ambient CWD with the injected repo.
141    let repo_root = repo
142        .workdir()
143        .context("repository has no working directory (bare repositories are not supported)")?;
144
145    let current_branch = repo
146        .get_current_branch()
147        .context("Failed to get current branch. Make sure you're not in detached HEAD state.")?;
148
149    let resolved_base = match base_branch {
150        Some(branch) => {
151            if !repo.branch_exists(branch)? {
152                anyhow::bail!("Base branch '{branch}' does not exist");
153            }
154            branch.to_string()
155        }
156        None => {
157            if repo.branch_exists("main")? {
158                "main".to_string()
159            } else if repo.branch_exists("master")? {
160                "master".to_string()
161            } else {
162                anyhow::bail!("No default base branch found (main or master)");
163            }
164        }
165    };
166
167    let commit_range = format!("{resolved_base}..HEAD");
168
169    let wd_status = repo.get_working_directory_status()?;
170    let working_directory = WorkingDirectoryInfo {
171        clean: wd_status.clean,
172        untracked_changes: wd_status
173            .untracked_changes
174            .into_iter()
175            .map(|fs| FileStatusInfo {
176                status: fs.status,
177                file: fs.file,
178            })
179            .collect(),
180    };
181
182    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
183    let commits = repo.get_commits_in_range(&commit_range)?;
184
185    let (pr_template, pr_template_location) = match InfoCommand::read_pr_template(repo_root).ok() {
186        Some((content, location)) => (Some(content), Some(location)),
187        None => (None, None),
188    };
189
190    let branch_prs = InfoCommand::get_branch_prs(&current_branch, repo_root)
191        .ok()
192        .filter(|prs| !prs.is_empty());
193
194    let versions = Some(VersionInfo {
195        omni_dev: env!("CARGO_PKG_VERSION").to_string(),
196    });
197
198    let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
199        .context("Failed to determine AI scratch directory")?;
200    let ai_info = AiInfo {
201        scratch: ai_scratch_path.to_string_lossy().to_string(),
202    };
203
204    let mut repo_view = RepositoryView {
205        versions,
206        explanation: FieldExplanation::default(),
207        working_directory,
208        remotes,
209        ai: ai_info,
210        branch_info: Some(BranchInfo {
211            branch: current_branch,
212        }),
213        pr_template,
214        pr_template_location,
215        branch_prs,
216        commits,
217    };
218
219    repo_view.to_yaml_output()
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used, clippy::expect_used)]
224mod tests {
225    use super::*;
226    use git2::{Repository, Signature};
227    use tempfile::TempDir;
228
229    fn init_repo_with_commits() -> (TempDir, Vec<git2::Oid>) {
230        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
231        std::fs::create_dir_all(&tmp_root).unwrap();
232        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
233        let repo_path = temp_dir.path();
234        let repo = Repository::init(repo_path).unwrap();
235        {
236            let mut config = repo.config().unwrap();
237            config.set_str("user.name", "Test").unwrap();
238            config.set_str("user.email", "test@example.com").unwrap();
239            config.set_str("init.defaultBranch", "main").unwrap();
240        }
241
242        // Re-point HEAD at refs/heads/main so the first commit lands on "main"
243        repo.set_head("refs/heads/main").unwrap();
244
245        let signature = Signature::now("Test", "test@example.com").unwrap();
246        let mut commits = Vec::new();
247        for (i, msg) in ["base: init", "feat: work"].iter().enumerate() {
248            std::fs::write(repo_path.join("f.txt"), format!("c{i}")).unwrap();
249            let mut idx = repo.index().unwrap();
250            idx.add_path(std::path::Path::new("f.txt")).unwrap();
251            idx.write().unwrap();
252            let tree_id = idx.write_tree().unwrap();
253            let tree = repo.find_tree(tree_id).unwrap();
254            let parents: Vec<git2::Commit<'_>> = match commits.last() {
255                Some(id) => vec![repo.find_commit(*id).unwrap()],
256                None => vec![],
257            };
258            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
259            let oid = repo
260                .commit(
261                    Some("HEAD"),
262                    &signature,
263                    &signature,
264                    msg,
265                    &tree,
266                    &parent_refs,
267                )
268                .unwrap();
269            commits.push(oid);
270        }
271        (temp_dir, commits)
272    }
273
274    #[test]
275    fn run_info_default_branch_uses_main() {
276        let (temp_dir, _commits) = init_repo_with_commits();
277        // With only a `main` branch, HEAD==main → main..HEAD is empty, so the
278        // output lacks commits but still returns YAML with branch_info.
279        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
280        assert!(
281            yaml.contains("branch:"),
282            "yaml should include branch_info: {yaml}"
283        );
284    }
285
286    #[test]
287    fn execute_against_injected_repo_succeeds() {
288        // Drives `execute()` (not just `run_info`) so its run_info+println body
289        // is covered deterministically via the injected repo path. Those lines
290        // were previously only hit by the live-repo dispatch test and flickered
291        // covered<->uncovered depending on the checkout state.
292        let (temp_dir, _commits) = init_repo_with_commits();
293        InfoCommand { base_branch: None }
294            .execute(Some(temp_dir.path()))
295            .unwrap();
296    }
297
298    #[test]
299    fn run_info_with_explicit_missing_base_errors() {
300        let (temp_dir, _commits) = init_repo_with_commits();
301        let err = run_info(Some("no-such-branch"), Some(temp_dir.path())).unwrap_err();
302        let msg = format!("{err:#}");
303        assert!(
304            msg.contains("no-such-branch"),
305            "expected missing-branch error: {msg}"
306        );
307    }
308
309    #[test]
310    fn run_info_no_default_base_branch_errors() {
311        // Init an empty repo with only a non-main branch.
312        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
313        std::fs::create_dir_all(&tmp_root).unwrap();
314        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
315        let repo = Repository::init(temp_dir.path()).unwrap();
316        {
317            let mut config = repo.config().unwrap();
318            config.set_str("user.name", "Test").unwrap();
319            config.set_str("user.email", "test@example.com").unwrap();
320        }
321        let signature = Signature::now("Test", "test@example.com").unwrap();
322        // Create on a non-default branch "dev" only.
323        repo.set_head("refs/heads/dev").unwrap();
324        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
325        let mut idx = repo.index().unwrap();
326        idx.add_path(std::path::Path::new("f.txt")).unwrap();
327        idx.write().unwrap();
328        let tree_id = idx.write_tree().unwrap();
329        let tree = repo.find_tree(tree_id).unwrap();
330        repo.commit(Some("HEAD"), &signature, &signature, "first", &tree, &[])
331            .unwrap();
332
333        let err = run_info(None, Some(temp_dir.path())).unwrap_err();
334        let msg = format!("{err:#}");
335        assert!(msg.contains("main or master"), "got: {msg}");
336    }
337
338    #[test]
339    fn run_info_with_invalid_path_returns_error() {
340        let err = run_info(None, Some("/no/such/path/exists")).unwrap_err();
341        let msg = format!("{err:#}");
342        assert!(
343            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repo"),
344            "expected git/repo error, got: {msg}"
345        );
346    }
347
348    /// The injected `repo_path` fully determines the repository with no
349    /// dependence on the process current working directory — the path is passed
350    /// directly rather than via any process-CWD mutation.
351    #[test]
352    fn run_info_uses_injected_repo_without_cwd() {
353        let (temp_dir, _commits) = init_repo_with_commits();
354        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
355        assert!(yaml.contains("branch:"));
356    }
357
358    #[test]
359    fn run_info_with_explicit_existing_base_succeeds() {
360        let (temp_dir, _commits) = init_repo_with_commits();
361        // Explicitly pass "main" as base — branch exists, validation succeeds.
362        let yaml = run_info(Some("main"), Some(temp_dir.path())).unwrap();
363        assert!(yaml.contains("branch:"));
364    }
365
366    #[test]
367    fn run_info_falls_back_to_master_when_main_missing() {
368        // Init a repo with a `master` branch (no `main`) — exercises the
369        // master fallback in the default-base resolution.
370        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
371        std::fs::create_dir_all(&tmp_root).unwrap();
372        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
373        let repo = Repository::init(temp_dir.path()).unwrap();
374        {
375            let mut cfg = repo.config().unwrap();
376            cfg.set_str("user.name", "Test").unwrap();
377            cfg.set_str("user.email", "test@example.com").unwrap();
378        }
379        repo.set_head("refs/heads/master").unwrap();
380        let signature = Signature::now("Test", "test@example.com").unwrap();
381        std::fs::write(temp_dir.path().join("f.txt"), "x").unwrap();
382        let mut idx = repo.index().unwrap();
383        idx.add_path(std::path::Path::new("f.txt")).unwrap();
384        idx.write().unwrap();
385        let tree_id = idx.write_tree().unwrap();
386        let tree = repo.find_tree(tree_id).unwrap();
387        repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
388            .unwrap();
389
390        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
391        assert!(yaml.contains("branch:"));
392    }
393
394    /// Exercises the `read_pr_template` Some arm by placing a PR template in
395    /// the injected repo root's `.github/` — proving the template is read from
396    /// the repo root, not the process current working directory.
397    #[test]
398    fn run_info_picks_up_pr_template_from_repo_root() {
399        let (temp_dir, _commits) = init_repo_with_commits();
400        let github_dir = temp_dir.path().join(".github");
401        std::fs::create_dir_all(&github_dir).unwrap();
402        std::fs::write(
403            github_dir.join("pull_request_template.md"),
404            "## Sample Template",
405        )
406        .unwrap();
407
408        let yaml = run_info(None, Some(temp_dir.path())).unwrap();
409        assert!(
410            yaml.contains("pr_template:") || yaml.contains("Sample Template"),
411            "expected PR template info in yaml: {yaml}"
412        );
413    }
414
415    /// "No silent mix" guard: `read_pr_template` resolves only against its
416    /// `repo_root` argument and has **no** fallback to the ambient CWD. A repo
417    /// root with a template returns it; a different root without one errors —
418    /// even though the process CWD (the omni-dev checkout) *does* ship a
419    /// `.github/pull_request_template.md`. This is the regression guard for the
420    /// silent-mix bug the injection closes.
421    #[test]
422    fn read_pr_template_anchors_to_repo_root() {
423        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
424        std::fs::create_dir_all(&tmp_root).unwrap();
425
426        let with = tempfile::tempdir_in(&tmp_root).unwrap();
427        std::fs::create_dir_all(with.path().join(".github")).unwrap();
428        std::fs::write(
429            with.path().join(".github/pull_request_template.md"),
430            "## Marker ABC",
431        )
432        .unwrap();
433        let (content, location) = InfoCommand::read_pr_template(with.path()).unwrap();
434        assert!(content.contains("Marker ABC"));
435        assert!(location.contains("pull_request_template.md"));
436
437        let without = tempfile::tempdir_in(&tmp_root).unwrap();
438        assert!(
439            InfoCommand::read_pr_template(without.path()).is_err(),
440            "read_pr_template must not fall back to the ambient CWD template"
441        );
442    }
443}