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