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) -> 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
26pub 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 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}