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