1use std::path::Path;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7
8#[derive(Parser)]
10pub struct InfoCommand {
11 #[arg(value_name = "BASE_BRANCH")]
13 pub base_branch: Option<String>,
14}
15
16impl InfoCommand {
17 pub fn execute(self) -> Result<()> {
19 let yaml_output = run_info(self.base_branch.as_deref(), None::<&str>)?;
20 println!("{yaml_output}");
21 Ok(())
22 }
23
24 pub(crate) fn read_pr_template() -> Result<(String, String)> {
26 use std::fs;
27 use std::path::Path;
28
29 let template_path = Path::new(".github/pull_request_template.md");
30 if template_path.exists() {
31 let content = fs::read_to_string(template_path)
32 .context("Failed to read .github/pull_request_template.md")?;
33 Ok((content, template_path.to_string_lossy().to_string()))
34 } else {
35 anyhow::bail!("PR template file does not exist")
36 }
37 }
38
39 pub(crate) fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
41 use serde_json::Value;
42 use std::process::Command;
43
44 let output = Command::new("gh")
46 .args([
47 "pr",
48 "list",
49 "--head",
50 branch_name,
51 "--json",
52 "number,title,state,url,body,baseRefName",
53 "--limit",
54 "50",
55 ])
56 .output()
57 .context("Failed to execute gh command")?;
58
59 if !output.status.success() {
60 anyhow::bail!(
61 "gh command failed: {}",
62 String::from_utf8_lossy(&output.stderr)
63 );
64 }
65
66 let json_str = String::from_utf8_lossy(&output.stdout);
67 let prs_json: Value =
68 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
69
70 let mut prs = Vec::new();
71 if let Some(prs_array) = prs_json.as_array() {
72 for pr_json in prs_array {
73 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
74 pr_json.get("number").and_then(serde_json::Value::as_u64),
75 pr_json.get("title").and_then(|t| t.as_str()),
76 pr_json.get("state").and_then(|s| s.as_str()),
77 pr_json.get("url").and_then(|u| u.as_str()),
78 pr_json.get("body").and_then(|b| b.as_str()),
79 ) {
80 let base = pr_json
81 .get("baseRefName")
82 .and_then(|b| b.as_str())
83 .unwrap_or("")
84 .to_string();
85 prs.push(crate::data::PullRequest {
86 number,
87 title: title.to_string(),
88 state: state.to_string(),
89 url: url.to_string(),
90 body: body.to_string(),
91 base,
92 });
93 }
94 }
95 }
96
97 Ok(prs)
98 }
99}
100
101pub fn run_info<P: AsRef<Path>>(base_branch: Option<&str>, repo_path: Option<P>) -> Result<String> {
108 use crate::data::{
109 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
110 WorkingDirectoryInfo,
111 };
112 use crate::git::{GitRepository, RemoteInfo};
113 use crate::utils::ai_scratch;
114
115 let repo = if let Some(path) = repo_path {
116 GitRepository::open_at(path).context("Failed to open git repository at the given path")?
117 } else {
118 crate::utils::check_git_repository()?;
119 GitRepository::open()
120 .context("Failed to open git repository. Make sure you're in a git repository.")?
121 };
122
123 let current_branch = repo
124 .get_current_branch()
125 .context("Failed to get current branch. Make sure you're not in detached HEAD state.")?;
126
127 let resolved_base = match base_branch {
128 Some(branch) => {
129 if !repo.branch_exists(branch)? {
130 anyhow::bail!("Base branch '{branch}' does not exist");
131 }
132 branch.to_string()
133 }
134 None => {
135 if repo.branch_exists("main")? {
136 "main".to_string()
137 } else if repo.branch_exists("master")? {
138 "master".to_string()
139 } else {
140 anyhow::bail!("No default base branch found (main or master)");
141 }
142 }
143 };
144
145 let commit_range = format!("{resolved_base}..HEAD");
146
147 let wd_status = repo.get_working_directory_status()?;
148 let working_directory = WorkingDirectoryInfo {
149 clean: wd_status.clean,
150 untracked_changes: wd_status
151 .untracked_changes
152 .into_iter()
153 .map(|fs| FileStatusInfo {
154 status: fs.status,
155 file: fs.file,
156 })
157 .collect(),
158 };
159
160 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
161 let commits = repo.get_commits_in_range(&commit_range)?;
162
163 let (pr_template, pr_template_location) = match InfoCommand::read_pr_template().ok() {
164 Some((content, location)) => (Some(content), Some(location)),
165 None => (None, None),
166 };
167
168 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
169 .ok()
170 .filter(|prs| !prs.is_empty());
171
172 let versions = Some(VersionInfo {
173 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
174 });
175
176 let ai_scratch_path =
177 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
178 let ai_info = AiInfo {
179 scratch: ai_scratch_path.to_string_lossy().to_string(),
180 };
181
182 let mut repo_view = RepositoryView {
183 versions,
184 explanation: FieldExplanation::default(),
185 working_directory,
186 remotes,
187 ai: ai_info,
188 branch_info: Some(BranchInfo {
189 branch: current_branch,
190 }),
191 pr_template,
192 pr_template_location,
193 branch_prs,
194 commits,
195 };
196
197 repo_view.to_yaml_output()
198}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used, clippy::expect_used)]
202mod tests {
203 use super::*;
204 use git2::{Repository, Signature};
205 use tempfile::TempDir;
206
207 fn init_repo_with_commits() -> (TempDir, Vec<git2::Oid>) {
208 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
209 std::fs::create_dir_all(&tmp_root).unwrap();
210 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
211 let repo_path = temp_dir.path();
212 let repo = Repository::init(repo_path).unwrap();
213 {
214 let mut config = repo.config().unwrap();
215 config.set_str("user.name", "Test").unwrap();
216 config.set_str("user.email", "test@example.com").unwrap();
217 config.set_str("init.defaultBranch", "main").unwrap();
218 }
219
220 repo.set_head("refs/heads/main").unwrap();
222
223 let signature = Signature::now("Test", "test@example.com").unwrap();
224 let mut commits = Vec::new();
225 for (i, msg) in ["base: init", "feat: work"].iter().enumerate() {
226 std::fs::write(repo_path.join("f.txt"), format!("c{i}")).unwrap();
227 let mut idx = repo.index().unwrap();
228 idx.add_path(std::path::Path::new("f.txt")).unwrap();
229 idx.write().unwrap();
230 let tree_id = idx.write_tree().unwrap();
231 let tree = repo.find_tree(tree_id).unwrap();
232 let parents: Vec<git2::Commit<'_>> = match commits.last() {
233 Some(id) => vec![repo.find_commit(*id).unwrap()],
234 None => vec![],
235 };
236 let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
237 let oid = repo
238 .commit(
239 Some("HEAD"),
240 &signature,
241 &signature,
242 msg,
243 &tree,
244 &parent_refs,
245 )
246 .unwrap();
247 commits.push(oid);
248 }
249 (temp_dir, commits)
250 }
251
252 #[test]
253 fn run_info_default_branch_uses_main() {
254 let (temp_dir, _commits) = init_repo_with_commits();
255 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
258 assert!(
259 yaml.contains("branch:"),
260 "yaml should include branch_info: {yaml}"
261 );
262 }
263
264 #[test]
265 fn run_info_with_explicit_missing_base_errors() {
266 let (temp_dir, _commits) = init_repo_with_commits();
267 let err = run_info(Some("no-such-branch"), Some(temp_dir.path())).unwrap_err();
268 let msg = format!("{err:#}");
269 assert!(
270 msg.contains("no-such-branch"),
271 "expected missing-branch error: {msg}"
272 );
273 }
274
275 #[test]
276 fn run_info_no_default_base_branch_errors() {
277 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
279 std::fs::create_dir_all(&tmp_root).unwrap();
280 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
281 let repo = Repository::init(temp_dir.path()).unwrap();
282 {
283 let mut config = repo.config().unwrap();
284 config.set_str("user.name", "Test").unwrap();
285 config.set_str("user.email", "test@example.com").unwrap();
286 }
287 let signature = Signature::now("Test", "test@example.com").unwrap();
288 repo.set_head("refs/heads/dev").unwrap();
290 std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
291 let mut idx = repo.index().unwrap();
292 idx.add_path(std::path::Path::new("f.txt")).unwrap();
293 idx.write().unwrap();
294 let tree_id = idx.write_tree().unwrap();
295 let tree = repo.find_tree(tree_id).unwrap();
296 repo.commit(Some("HEAD"), &signature, &signature, "first", &tree, &[])
297 .unwrap();
298
299 let err = run_info(None, Some(temp_dir.path())).unwrap_err();
300 let msg = format!("{err:#}");
301 assert!(msg.contains("main or master"), "got: {msg}");
302 }
303
304 #[test]
305 fn run_info_with_invalid_path_returns_error() {
306 let err = run_info(None, Some("/no/such/path/exists")).unwrap_err();
307 let msg = format!("{err:#}");
308 assert!(
309 msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repo"),
310 "expected git/repo error, got: {msg}"
311 );
312 }
313
314 #[tokio::test]
318 async fn run_info_opens_cwd_repo_when_no_path_given() {
319 let (temp_dir, _commits) = init_repo_with_commits();
320 let _guard = super::super::CwdGuard::enter(temp_dir.path())
321 .await
322 .unwrap();
323 let yaml = run_info(None, None::<&str>).unwrap();
324 assert!(yaml.contains("branch:"));
325 }
326
327 #[test]
328 fn run_info_with_explicit_existing_base_succeeds() {
329 let (temp_dir, _commits) = init_repo_with_commits();
330 let yaml = run_info(Some("main"), Some(temp_dir.path())).unwrap();
332 assert!(yaml.contains("branch:"));
333 }
334
335 #[test]
336 fn run_info_falls_back_to_master_when_main_missing() {
337 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
340 std::fs::create_dir_all(&tmp_root).unwrap();
341 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
342 let repo = Repository::init(temp_dir.path()).unwrap();
343 {
344 let mut cfg = repo.config().unwrap();
345 cfg.set_str("user.name", "Test").unwrap();
346 cfg.set_str("user.email", "test@example.com").unwrap();
347 }
348 repo.set_head("refs/heads/master").unwrap();
349 let signature = Signature::now("Test", "test@example.com").unwrap();
350 std::fs::write(temp_dir.path().join("f.txt"), "x").unwrap();
351 let mut idx = repo.index().unwrap();
352 idx.add_path(std::path::Path::new("f.txt")).unwrap();
353 idx.write().unwrap();
354 let tree_id = idx.write_tree().unwrap();
355 let tree = repo.find_tree(tree_id).unwrap();
356 repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
357 .unwrap();
358
359 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
360 assert!(yaml.contains("branch:"));
361 }
362
363 #[tokio::test]
366 async fn run_info_picks_up_pr_template_from_cwd() {
367 let (temp_dir, _commits) = init_repo_with_commits();
368 let github_dir = temp_dir.path().join(".github");
369 std::fs::create_dir_all(&github_dir).unwrap();
370 std::fs::write(
371 github_dir.join("pull_request_template.md"),
372 "## Sample Template",
373 )
374 .unwrap();
375
376 let _guard = super::super::CwdGuard::enter(temp_dir.path())
377 .await
378 .unwrap();
379 let yaml = run_info(None, None::<&str>).unwrap();
380 assert!(
381 yaml.contains("pr_template:") || yaml.contains("Sample Template"),
382 "expected PR template info in yaml: {yaml}"
383 );
384 }
385}