Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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,
Expand Down Expand Up @@ -101,8 +107,8 @@ pub fn cli() -> Result<RunResult, RunnerError> {

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),
Expand Down
31 changes: 22 additions & 9 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use core::fmt;
use std::{
fs::File,
path::{Path, PathBuf},
process::Command,
};

use error_stack::{Context, Result, ResultExt};
Expand Down Expand Up @@ -91,12 +92,12 @@ pub fn validate(run_config: &RunConfig, _file_paths: Vec<String>) -> 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<String>) -> RunResult {
run_with_runner(run_config, |runner| runner.generate_and_validate())
pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec<String>, git_stage: bool) -> RunResult {
run_with_runner(run_config, |runner| runner.generate_and_validate(git_stage))
}

pub fn delete_cache(run_config: &RunConfig) -> RunResult {
Expand Down Expand Up @@ -199,29 +200,41 @@ 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()
},
}
}

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)
Expand Down
100 changes: 100 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
);
}
44 changes: 44 additions & 0 deletions tests/git_stage_test.rs
Original file line number Diff line number Diff line change
@@ -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<F>(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");
}