Skip to main content

omni_dev/cli/git/
view.rs

1//! View command — outputs repository information in YAML format.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7
8/// View command options.
9#[derive(Parser)]
10pub struct ViewCommand {
11    /// Commit range to analyze (e.g., HEAD~3..HEAD, abc123..def456).
12    #[arg(value_name = "COMMIT_RANGE")]
13    pub commit_range: Option<String>,
14}
15
16impl ViewCommand {
17    /// Executes the view 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 commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
23        let yaml_output = run_view(commit_range, repo)?;
24        println!("{yaml_output}");
25        Ok(())
26    }
27}
28
29/// Runs the view logic and returns the YAML output as a `String`.
30///
31/// When `repo_path` is `Some`, opens the repository at that path; otherwise
32/// opens the repository at the current working directory. Callers that print
33/// to stdout (the CLI) and callers that return the string (the MCP server)
34/// share this implementation.
35pub fn run_view<P: AsRef<Path>>(commit_range: &str, repo_path: Option<P>) -> Result<String> {
36    use crate::data::{
37        AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo, WorkingDirectoryInfo,
38    };
39    use crate::git::{GitRepository, RemoteInfo};
40    use crate::utils::ai_scratch;
41
42    // Resolve the repo location: the injected path, or the current working
43    // directory as the default (resolved here at the entry point). Both branches
44    // open via `open_at`, so nothing falls back to a no-arg CWD open.
45    let repo = if let Some(path) = repo_path {
46        GitRepository::open_at(path).context("Failed to open git repository at the given path")?
47    } else {
48        let cwd = std::env::current_dir().context("Failed to determine current directory")?;
49        GitRepository::open_at(cwd)
50            .context("Failed to open git repository. Make sure you're in a git repository.")?
51    };
52
53    // Anchor the AI-scratch read to the opened repo's workdir (covers both the
54    // injected-path and CWD-default branches) so it never resolves against an
55    // unrelated ambient CWD.
56    let repo_root = repo
57        .workdir()
58        .context("repository has no working directory (bare repositories are not supported)")?;
59
60    let wd_status = repo.get_working_directory_status()?;
61    let working_directory = WorkingDirectoryInfo {
62        clean: wd_status.clean,
63        untracked_changes: wd_status
64            .untracked_changes
65            .into_iter()
66            .map(|fs| FileStatusInfo {
67                status: fs.status,
68                file: fs.file,
69            })
70            .collect(),
71    };
72
73    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
74    let commits = repo.get_commits_in_range(commit_range)?;
75
76    let versions = Some(VersionInfo {
77        omni_dev: env!("CARGO_PKG_VERSION").to_string(),
78    });
79
80    let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
81        .context("Failed to determine AI scratch directory")?;
82    let ai_info = AiInfo {
83        scratch: ai_scratch_path.to_string_lossy().to_string(),
84    };
85
86    let mut repo_view = RepositoryView {
87        versions,
88        explanation: FieldExplanation::default(),
89        working_directory,
90        remotes,
91        ai: ai_info,
92        branch_info: None,
93        pr_template: None,
94        pr_template_location: None,
95        branch_prs: None,
96        commits,
97    };
98
99    repo_view.to_yaml_output()
100}
101
102#[cfg(test)]
103#[allow(clippy::unwrap_used, clippy::expect_used)]
104mod tests {
105    use super::*;
106    use git2::{Repository, Signature};
107    use tempfile::TempDir;
108
109    fn init_repo_with_commits() -> (TempDir, Vec<git2::Oid>) {
110        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
111        std::fs::create_dir_all(&tmp_root).unwrap();
112        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
113        let repo_path = temp_dir.path();
114        let repo = Repository::init(repo_path).unwrap();
115        {
116            let mut config = repo.config().unwrap();
117            config.set_str("user.name", "Test").unwrap();
118            config.set_str("user.email", "test@example.com").unwrap();
119        }
120
121        let signature = Signature::now("Test", "test@example.com").unwrap();
122        let mut commits = Vec::new();
123        for (i, msg) in ["feat: one", "fix: two"].iter().enumerate() {
124            std::fs::write(repo_path.join("f.txt"), format!("c{i}")).unwrap();
125            let mut idx = repo.index().unwrap();
126            idx.add_path(std::path::Path::new("f.txt")).unwrap();
127            idx.write().unwrap();
128            let tree_id = idx.write_tree().unwrap();
129            let tree = repo.find_tree(tree_id).unwrap();
130            let parents: Vec<git2::Commit<'_>> = match commits.last() {
131                Some(id) => vec![repo.find_commit(*id).unwrap()],
132                None => vec![],
133            };
134            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
135            let oid = repo
136                .commit(
137                    Some("HEAD"),
138                    &signature,
139                    &signature,
140                    msg,
141                    &tree,
142                    &parent_refs,
143                )
144                .unwrap();
145            commits.push(oid);
146        }
147        (temp_dir, commits)
148    }
149
150    #[test]
151    fn run_view_with_explicit_path_returns_yaml_with_commits() {
152        let (temp_dir, _commits) = init_repo_with_commits();
153        let yaml = run_view("HEAD~1..HEAD", Some(temp_dir.path())).unwrap();
154        assert!(yaml.contains("commits:"), "yaml lacks commits: {yaml}");
155        assert!(yaml.contains("fix: two"), "yaml missing latest: {yaml}");
156    }
157
158    #[test]
159    fn run_view_default_head_returns_latest_commit() {
160        let (temp_dir, _commits) = init_repo_with_commits();
161        let yaml = run_view("HEAD", Some(temp_dir.path())).unwrap();
162        assert!(yaml.contains("fix: two"));
163    }
164
165    #[test]
166    fn run_view_with_invalid_path_returns_error() {
167        let err = run_view("HEAD", Some("/no/such/path/exists")).unwrap_err();
168        let msg = format!("{err:#}");
169        assert!(
170            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repo"),
171            "expected git/repo error, got: {msg}"
172        );
173    }
174
175    #[test]
176    fn execute_with_explicit_repo_path_succeeds() {
177        let (temp_dir, _commits) = init_repo_with_commits();
178        let result = ViewCommand {
179            commit_range: Some("HEAD".to_string()),
180        }
181        .execute(Some(temp_dir.path()));
182        result.expect("execute should succeed against the injected repo");
183    }
184
185    #[test]
186    fn execute_default_range_uses_head() {
187        let (temp_dir, _commits) = init_repo_with_commits();
188        let result = ViewCommand { commit_range: None }.execute(Some(temp_dir.path()));
189        result.expect("execute with default range should succeed");
190    }
191
192    #[test]
193    fn run_view_includes_untracked_changes_in_output() {
194        let (temp_dir, _commits) = init_repo_with_commits();
195        // Add an untracked file so the working_directory.untracked_changes
196        // mapping closure runs and produces a FileStatusInfo entry.
197        std::fs::write(temp_dir.path().join("untracked.txt"), "stray").unwrap();
198
199        let yaml = run_view("HEAD", Some(temp_dir.path())).unwrap();
200        assert!(
201            yaml.contains("untracked.txt"),
202            "expected untracked.txt in output, got: {yaml}"
203        );
204        assert!(
205            yaml.contains("clean: false"),
206            "expected dirty status: {yaml}"
207        );
208    }
209}