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