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