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")]
14 pub base_branch: Option<String>,
15}
16
17impl InfoCommand {
18 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
23 let yaml_output = run_info(self.base_branch.as_deref(), repo)?;
24 println!("{yaml_output}");
25 Ok(())
26 }
27
28 pub(crate) fn read_pr_template(repo_root: &Path) -> Result<(String, String)> {
33 use std::fs;
34
35 let template_path = repo_root.join(".github/pull_request_template.md");
36 if template_path.exists() {
37 let content = fs::read_to_string(&template_path)
38 .context("Failed to read .github/pull_request_template.md")?;
39 Ok((content, template_path.to_string_lossy().to_string()))
40 } else {
41 anyhow::bail!("PR template file does not exist")
42 }
43 }
44
45 pub(crate) fn get_branch_prs(
50 branch_name: &str,
51 repo_root: &Path,
52 ) -> Result<Vec<crate::data::PullRequest>> {
53 use serde_json::Value;
54
55 let output = crate::github_metrics::run_gh(
58 &crate::pr_status::resolve_gh_binary(),
59 [
60 "pr",
61 "list",
62 "--head",
63 branch_name,
64 "--json",
65 "number,title,state,url,body,baseRefName",
66 "--limit",
67 "50",
68 ],
69 "pr list",
70 Some(repo_root),
71 )
72 .context("Failed to execute gh command")?;
73
74 if !output.status.success() {
75 anyhow::bail!(
76 "gh command failed: {}",
77 String::from_utf8_lossy(&output.stderr)
78 );
79 }
80
81 let json_str = String::from_utf8_lossy(&output.stdout);
82 let prs_json: Value =
83 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
84
85 let mut prs = Vec::new();
86 if let Some(prs_array) = prs_json.as_array() {
87 for pr_json in prs_array {
88 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
89 pr_json.get("number").and_then(serde_json::Value::as_u64),
90 pr_json.get("title").and_then(|t| t.as_str()),
91 pr_json.get("state").and_then(|s| s.as_str()),
92 pr_json.get("url").and_then(|u| u.as_str()),
93 pr_json.get("body").and_then(|b| b.as_str()),
94 ) {
95 let base = pr_json
96 .get("baseRefName")
97 .and_then(|b| b.as_str())
98 .unwrap_or("")
99 .to_string();
100 prs.push(crate::data::PullRequest {
101 number,
102 title: title.to_string(),
103 state: state.to_string(),
104 url: url.to_string(),
105 body: body.to_string(),
106 base,
107 });
108 }
109 }
110 }
111
112 Ok(prs)
113 }
114}
115
116pub fn run_info<P: AsRef<Path>>(base_branch: Option<&str>, repo_path: Option<P>) -> Result<String> {
123 use crate::data::{
124 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
125 WorkingDirectoryInfo,
126 };
127 use crate::git::{GitRepository, RemoteInfo};
128 use crate::utils::ai_scratch;
129
130 let repo = if let Some(path) = repo_path {
134 GitRepository::open_at(path).context("Failed to open git repository at the given path")?
135 } else {
136 let cwd = std::env::current_dir().context("Failed to determine current directory")?;
137 GitRepository::open_at(cwd)
138 .context("Failed to open git repository. Make sure you're in a git repository.")?
139 };
140
141 let repo_root = repo
145 .workdir()
146 .context("repository has no working directory (bare repositories are not supported)")?;
147
148 let current_branch = repo
149 .get_current_branch()
150 .context("Failed to get current branch. Make sure you're not in detached HEAD state.")?;
151
152 let commit_range = match base_branch {
153 Some(branch) => {
154 if !repo.branch_exists(branch)? {
155 anyhow::bail!("Base branch '{branch}' does not exist");
156 }
157 format!("{branch}..HEAD")
158 }
159 None => super::default_commit_range(&repo)?,
160 };
161
162 let wd_status = repo.get_working_directory_status()?;
163 let working_directory = WorkingDirectoryInfo {
164 clean: wd_status.clean,
165 untracked_changes: wd_status
166 .untracked_changes
167 .into_iter()
168 .map(|fs| FileStatusInfo {
169 status: fs.status,
170 file: fs.file,
171 })
172 .collect(),
173 };
174
175 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
176 let commits = repo.get_commits_in_range(&commit_range)?;
177
178 let (pr_template, pr_template_location) = match InfoCommand::read_pr_template(repo_root).ok() {
179 Some((content, location)) => (Some(content), Some(location)),
180 None => (None, None),
181 };
182
183 let branch_prs = InfoCommand::get_branch_prs(¤t_branch, repo_root)
184 .ok()
185 .filter(|prs| !prs.is_empty());
186
187 let versions = Some(VersionInfo {
188 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
189 });
190
191 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
192 .context("Failed to determine AI scratch directory")?;
193 let ai_info = AiInfo {
194 scratch: ai_scratch_path.to_string_lossy().to_string(),
195 };
196
197 let mut repo_view = RepositoryView {
198 versions,
199 explanation: FieldExplanation::default(),
200 working_directory,
201 remotes,
202 ai: ai_info,
203 branch_info: Some(BranchInfo {
204 branch: current_branch,
205 }),
206 pr_template,
207 pr_template_location,
208 branch_prs,
209 commits,
210 };
211
212 repo_view.to_yaml_output()
213}
214
215#[cfg(test)]
216#[allow(clippy::unwrap_used, clippy::expect_used)]
217mod tests {
218 use super::*;
219 use git2::{Repository, Signature};
220 use tempfile::TempDir;
221
222 fn init_repo_with_commits() -> (TempDir, Vec<git2::Oid>) {
223 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
224 std::fs::create_dir_all(&tmp_root).unwrap();
225 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
226 let repo_path = temp_dir.path();
227 let repo = Repository::init(repo_path).unwrap();
228 {
229 let mut config = repo.config().unwrap();
230 config.set_str("user.name", "Test").unwrap();
231 config.set_str("user.email", "test@example.com").unwrap();
232 config.set_str("init.defaultBranch", "main").unwrap();
233 }
234
235 repo.set_head("refs/heads/main").unwrap();
237
238 let signature = Signature::now("Test", "test@example.com").unwrap();
239 let mut commits = Vec::new();
240 for (i, msg) in ["base: init", "feat: work"].iter().enumerate() {
241 std::fs::write(repo_path.join("f.txt"), format!("c{i}")).unwrap();
242 let mut idx = repo.index().unwrap();
243 idx.add_path(std::path::Path::new("f.txt")).unwrap();
244 idx.write().unwrap();
245 let tree_id = idx.write_tree().unwrap();
246 let tree = repo.find_tree(tree_id).unwrap();
247 let parents: Vec<git2::Commit<'_>> = match commits.last() {
248 Some(id) => vec![repo.find_commit(*id).unwrap()],
249 None => vec![],
250 };
251 let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
252 let oid = repo
253 .commit(
254 Some("HEAD"),
255 &signature,
256 &signature,
257 msg,
258 &tree,
259 &parent_refs,
260 )
261 .unwrap();
262 commits.push(oid);
263 }
264 (temp_dir, commits)
265 }
266
267 #[test]
268 fn run_info_default_branch_uses_main() {
269 let (temp_dir, _commits) = init_repo_with_commits();
270 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
273 assert!(
274 yaml.contains("branch:"),
275 "yaml should include branch_info: {yaml}"
276 );
277 }
278
279 #[test]
280 fn execute_against_injected_repo_succeeds() {
281 let (temp_dir, _commits) = init_repo_with_commits();
286 InfoCommand { base_branch: None }
287 .execute(Some(temp_dir.path()))
288 .unwrap();
289 }
290
291 #[test]
292 fn run_info_with_explicit_missing_base_errors() {
293 let (temp_dir, _commits) = init_repo_with_commits();
294 let err = run_info(Some("no-such-branch"), Some(temp_dir.path())).unwrap_err();
295 let msg = format!("{err:#}");
296 assert!(
297 msg.contains("no-such-branch"),
298 "expected missing-branch error: {msg}"
299 );
300 }
301
302 #[test]
303 fn run_info_no_default_base_branch_errors() {
304 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
306 std::fs::create_dir_all(&tmp_root).unwrap();
307 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
308 let repo = Repository::init(temp_dir.path()).unwrap();
309 {
310 let mut config = repo.config().unwrap();
311 config.set_str("user.name", "Test").unwrap();
312 config.set_str("user.email", "test@example.com").unwrap();
313 }
314 let signature = Signature::now("Test", "test@example.com").unwrap();
315 repo.set_head("refs/heads/dev").unwrap();
317 std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
318 let mut idx = repo.index().unwrap();
319 idx.add_path(std::path::Path::new("f.txt")).unwrap();
320 idx.write().unwrap();
321 let tree_id = idx.write_tree().unwrap();
322 let tree = repo.find_tree(tree_id).unwrap();
323 repo.commit(Some("HEAD"), &signature, &signature, "first", &tree, &[])
324 .unwrap();
325
326 let err = run_info(None, Some(temp_dir.path())).unwrap_err();
327 let msg = format!("{err:#}");
328 assert!(msg.contains("No default base branch found"), "got: {msg}");
329 }
330
331 #[test]
336 fn run_info_prefers_origin_main_over_stale_local_main() {
337 let (temp_dir, commits) = init_repo_with_commits();
338 let repo = Repository::open(temp_dir.path()).unwrap();
339
340 repo.reference("refs/heads/work", commits[1], true, "test")
343 .unwrap();
344 repo.set_head("refs/heads/work").unwrap();
345 repo.reference("refs/heads/main", commits[0], true, "test")
346 .unwrap();
347 repo.reference("refs/remotes/origin/main", commits[1], true, "test")
348 .unwrap();
349
350 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
351 assert!(
354 !yaml.contains("feat: work"),
355 "range bound to stale local main: {yaml}"
356 );
357 }
358
359 #[test]
360 fn run_info_with_invalid_path_returns_error() {
361 let err = run_info(None, Some("/no/such/path/exists")).unwrap_err();
362 let msg = format!("{err:#}");
363 assert!(
364 msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repo"),
365 "expected git/repo error, got: {msg}"
366 );
367 }
368
369 #[test]
373 fn run_info_uses_injected_repo_without_cwd() {
374 let (temp_dir, _commits) = init_repo_with_commits();
375 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
376 assert!(yaml.contains("branch:"));
377 }
378
379 #[test]
380 fn run_info_with_explicit_existing_base_succeeds() {
381 let (temp_dir, _commits) = init_repo_with_commits();
382 let yaml = run_info(Some("main"), Some(temp_dir.path())).unwrap();
384 assert!(yaml.contains("branch:"));
385 }
386
387 #[test]
388 fn run_info_falls_back_to_master_when_main_missing() {
389 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
392 std::fs::create_dir_all(&tmp_root).unwrap();
393 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
394 let repo = Repository::init(temp_dir.path()).unwrap();
395 {
396 let mut cfg = repo.config().unwrap();
397 cfg.set_str("user.name", "Test").unwrap();
398 cfg.set_str("user.email", "test@example.com").unwrap();
399 }
400 repo.set_head("refs/heads/master").unwrap();
401 let signature = Signature::now("Test", "test@example.com").unwrap();
402 std::fs::write(temp_dir.path().join("f.txt"), "x").unwrap();
403 let mut idx = repo.index().unwrap();
404 idx.add_path(std::path::Path::new("f.txt")).unwrap();
405 idx.write().unwrap();
406 let tree_id = idx.write_tree().unwrap();
407 let tree = repo.find_tree(tree_id).unwrap();
408 repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
409 .unwrap();
410
411 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
412 assert!(yaml.contains("branch:"));
413 }
414
415 #[test]
419 fn run_info_picks_up_pr_template_from_repo_root() {
420 let (temp_dir, _commits) = init_repo_with_commits();
421 let github_dir = temp_dir.path().join(".github");
422 std::fs::create_dir_all(&github_dir).unwrap();
423 std::fs::write(
424 github_dir.join("pull_request_template.md"),
425 "## Sample Template",
426 )
427 .unwrap();
428
429 let yaml = run_info(None, Some(temp_dir.path())).unwrap();
430 assert!(
431 yaml.contains("pr_template:") || yaml.contains("Sample Template"),
432 "expected PR template info in yaml: {yaml}"
433 );
434 }
435
436 #[test]
443 fn read_pr_template_anchors_to_repo_root() {
444 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
445 std::fs::create_dir_all(&tmp_root).unwrap();
446
447 let with = tempfile::tempdir_in(&tmp_root).unwrap();
448 std::fs::create_dir_all(with.path().join(".github")).unwrap();
449 std::fs::write(
450 with.path().join(".github/pull_request_template.md"),
451 "## Marker ABC",
452 )
453 .unwrap();
454 let (content, location) = InfoCommand::read_pr_template(with.path()).unwrap();
455 assert!(content.contains("Marker ABC"));
456 assert!(location.contains("pull_request_template.md"));
457
458 let without = tempfile::tempdir_in(&tmp_root).unwrap();
459 assert!(
460 InfoCommand::read_pr_template(without.path()).is_err(),
461 "read_pr_template must not fall back to the ambient CWD template"
462 );
463 }
464}