1use std::path::Path;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7
8#[derive(Parser)]
10pub struct ViewCommand {
11 #[arg(value_name = "COMMIT_RANGE")]
13 pub commit_range: Option<String>,
14}
15
16impl ViewCommand {
17 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
29pub 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 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 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 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}