diff --git a/src/cli.rs b/src/cli.rs index 1f94124..714487c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,7 +26,10 @@ enum Command { about = "Generate the CODEOWNERS file and save it to '--codeowners-file-path'.", visible_alias = "g" )] - Generate, + Generate { + #[arg(long, short, default_value = "false", help = "Skip staging the CODEOWNERS file")] + skip_stage: bool, + }, #[clap( about = "Validate the validity of the CODEOWNERS file. A validation failure will exit with a failure code and a detailed output of the validation errors.", @@ -35,7 +38,10 @@ enum Command { Validate, #[clap(about = "Chains both `generate` and `validate` commands.", visible_alias = "gv")] - GenerateAndValidate, + GenerateAndValidate { + #[arg(long, short, default_value = "false", help = "Skip staging the CODEOWNERS file")] + skip_stage: bool, + }, #[clap(about = "Delete the cache file.", visible_alias = "d")] DeleteCache, @@ -101,8 +107,8 @@ pub fn cli() -> Result { let runner_result = match args.command { Command::Validate => runner::validate(&run_config, vec![]), - Command::Generate => runner::generate(&run_config), - Command::GenerateAndValidate => runner::generate_and_validate(&run_config, vec![]), + Command::Generate { skip_stage } => runner::generate(&run_config, !skip_stage), + Command::GenerateAndValidate { skip_stage } => runner::generate_and_validate(&run_config, vec![], !skip_stage), Command::ForFile { name, fast } => runner::for_file(&run_config, &name, fast), Command::ForTeam { name } => runner::for_team(&run_config, &name), Command::DeleteCache => runner::delete_cache(&run_config), diff --git a/src/runner.rs b/src/runner.rs index 2e5fd11..ef53829 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,6 +2,7 @@ use core::fmt; use std::{ fs::File, path::{Path, PathBuf}, + process::Command, }; use error_stack::{Context, Result, ResultExt}; @@ -91,12 +92,12 @@ pub fn validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { run_with_runner(run_config, |runner| runner.validate()) } -pub fn generate(run_config: &RunConfig) -> RunResult { - run_with_runner(run_config, |runner| runner.generate()) +pub fn generate(run_config: &RunConfig, git_stage: bool) -> RunResult { + run_with_runner(run_config, |runner| runner.generate(git_stage)) } -pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { - run_with_runner(run_config, |runner| runner.generate_and_validate()) +pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec, git_stage: bool) -> RunResult { + run_with_runner(run_config, |runner| runner.generate_and_validate(git_stage)) } pub fn delete_cache(run_config: &RunConfig) -> RunResult { @@ -199,14 +200,18 @@ impl Runner { }, } } - - pub fn generate(&self) -> RunResult { + pub fn generate(&self, git_stage: bool) -> RunResult { let content = self.ownership.generate_file(); if let Some(parent) = &self.run_config.codeowners_file_path.parent() { let _ = std::fs::create_dir_all(parent); } match std::fs::write(&self.run_config.codeowners_file_path, content) { - Ok(_) => RunResult::default(), + Ok(_) => { + if git_stage { + self.git_stage(); + } + RunResult::default() + } Err(err) => RunResult { io_errors: vec![err.to_string()], ..Default::default() @@ -214,14 +219,22 @@ impl Runner { } } - pub fn generate_and_validate(&self) -> RunResult { - let run_result = self.generate(); + pub fn generate_and_validate(&self, git_stage: bool) -> RunResult { + let run_result = self.generate(git_stage); if run_result.has_errors() { return run_result; } self.validate() } + fn git_stage(&self) { + let _ = Command::new("git") + .arg("add") + .arg(&self.run_config.codeowners_file_path) + .current_dir(&self.run_config.project_root) + .output(); + } + pub fn for_file(&self, file_path: &str) -> RunResult { let relative_file_path = Path::new(file_path) .strip_prefix(&self.run_config.project_root) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 836002a..780026b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,4 +1,9 @@ use std::fs; +use std::path::Path; +use std::process::Command; + +use codeowners::runner::{self, RunConfig}; +use tempfile::TempDir; #[allow(dead_code)] pub fn teardown() { @@ -11,3 +16,98 @@ pub fn teardown() { } }); } + +#[allow(dead_code)] +pub fn copy_dir_recursive(from: &Path, to: &Path) { + fs::create_dir_all(to).expect("failed to create destination root"); + for entry in fs::read_dir(from).expect("failed to read source dir") { + let entry = entry.expect("failed to read dir entry"); + let file_type = entry.file_type().expect("failed to read file type"); + let src_path = entry.path(); + let dest_path = to.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dest_path); + } else if file_type.is_file() { + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).expect("failed to create parent dir"); + } + fs::copy(&src_path, &dest_path).expect("failed to copy file"); + } + } +} + +#[allow(dead_code)] +pub fn init_git_repo(path: &Path) { + let status = Command::new("git") + .arg("init") + .current_dir(path) + .output() + .expect("failed to run git init"); + assert!( + status.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&status.stderr) + ); + + let _ = Command::new("git") + .arg("config") + .arg("user.email") + .arg("test@example.com") + .current_dir(path) + .output(); + let _ = Command::new("git") + .arg("config") + .arg("user.name") + .arg("Test User") + .current_dir(path) + .output(); +} + +#[allow(dead_code)] +pub fn is_file_staged(repo_root: &Path, rel_path: &str) -> bool { + let output = Command::new("git") + .arg("diff") + .arg("--name-only") + .arg("--cached") + .current_dir(repo_root) + .output() + .expect("failed to run git diff --cached"); + assert!( + output.status.success(), + "git diff failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| line.trim() == rel_path) +} + +#[allow(dead_code)] +pub fn build_run_config(project_root: &Path, codeowners_rel_path: &str) -> RunConfig { + let project_root = project_root.canonicalize().expect("failed to canonicalize project root"); + let codeowners_file_path = project_root.join(codeowners_rel_path); + let config_path = project_root.join("config/code_ownership.yml"); + RunConfig { + project_root, + codeowners_file_path, + config_path, + no_cache: true, + } +} + +#[allow(dead_code)] +pub fn setup_fixture_repo(fixture_root: &Path) -> TempDir { + let temp_dir = tempfile::tempdir().expect("failed to create tempdir"); + copy_dir_recursive(fixture_root, temp_dir.path()); + init_git_repo(temp_dir.path()); + temp_dir +} + +#[allow(dead_code)] +pub fn assert_no_run_errors(result: &runner::RunResult) { + assert!(result.io_errors.is_empty(), "io_errors: {:?}", result.io_errors); + assert!( + result.validation_errors.is_empty(), + "validation_errors: {:?}", + result.validation_errors + ); +} diff --git a/tests/git_stage_test.rs b/tests/git_stage_test.rs new file mode 100644 index 0000000..ae8f80d --- /dev/null +++ b/tests/git_stage_test.rs @@ -0,0 +1,44 @@ +use std::path::Path; + +use codeowners::runner::{self, RunConfig}; + +mod common; +use common::{assert_no_run_errors, build_run_config, is_file_staged, setup_fixture_repo}; + +#[test] +fn test_generate_stages_codeowners() { + run_and_check(runner::generate, true, true); +} + +#[test] +fn test_generate_and_validate_stages_codeowners() { + run_and_check(|rc, s| runner::generate_and_validate(rc, vec![], s), true, true); +} + +#[test] +fn test_generate_does_not_stage_codeowners() { + run_and_check(runner::generate, false, false); +} + +#[test] +fn test_generate_and_validate_does_not_stage_codeowners() { + run_and_check(|rc, s| runner::generate_and_validate(rc, vec![], s), false, false); +} + +const FIXTURE: &str = "tests/fixtures/valid_project"; +const CODEOWNERS_REL: &str = ".github/CODEOWNERS"; + +fn run_and_check(func: F, stage: bool, expected_staged: bool) +where + F: FnOnce(&RunConfig, bool) -> runner::RunResult, +{ + let temp_dir = setup_fixture_repo(Path::new(FIXTURE)); + let run_config = build_run_config(temp_dir.path(), CODEOWNERS_REL); + + let result = func(&run_config, stage); + assert_no_run_errors(&result); + + assert!(run_config.codeowners_file_path.exists(), "CODEOWNERS file was not created"); + let staged = is_file_staged(&run_config.project_root, CODEOWNERS_REL); + assert_eq!(staged, expected_staged, "unexpected staged state for CODEOWNERS"); +}