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