diff options
Diffstat (limited to 'pkgs/by-name/ro/rollup/src/main.rs')
| -rw-r--r-- | pkgs/by-name/ro/rollup/src/main.rs | 315 |
1 files changed, 315 insertions, 0 deletions
diff --git a/pkgs/by-name/ro/rollup/src/main.rs b/pkgs/by-name/ro/rollup/src/main.rs new file mode 100644 index 00000000..ed4e29b4 --- /dev/null +++ b/pkgs/by-name/ro/rollup/src/main.rs @@ -0,0 +1,315 @@ +use std::{ + env::current_dir, + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; + +use crate::parse::{FileDiff, Patch}; + +mod parse; + +#[derive(Parser)] +struct Cli { + #[command(subcommand)] + subcommand: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Apply patches to a rollup target. + Apply { + /// The files to pull the patches to rollup from. + patch_files: Vec<PathBuf>, + + /// Which repository to use as a target for the rollup + /// + /// The repository needs to be clean. + #[arg(short, long)] + to: PathBuf, + + /// Show debug information related to the parsing of patch files. + #[arg(short, long)] + debug_parse: bool, + }, + + /// Create the patch files (based on the given tag) + Create { + /// Which tag to use to mark the already applied patches. + #[arg(default_value = "CONFIG_APPLIED")] + tag: String, + }, + + /// First create the patches, and then automatically apply them. + CreateApply { + /// Which tag to use to mark the already applied patches. + #[arg(default_value = "CONFIG_APPLIED")] + tag: String, + + /// Which repository to use as a target for the rollup + /// + /// The repository needs to be clean. + #[arg(short, long)] + to: PathBuf, + + /// Show debug information related to the parsing of patch files. + #[arg(short, long)] + debug_parse: bool, + }, +} + +macro_rules! git { + (@_base for $finalizer:ident in $to:expr; $($args:expr),*) => { + Command::new("git") + .args([ + "-C", + $to.display().to_string().as_str(), + $($args),* + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .$finalizer() + .with_context(|| format!("Failed to run {}", git!(@_fmt_cmd $($args),*)))? + }; + (@_handle_status $output:expr; $($args:expr),*) => {{ + if !$output.status.success() { + bail!( + "{} failed!\n\nStderr was:\n {}", + git!(@_fmt_cmd $($args),*), + String::from_utf8($output.stderr).unwrap() + ) + } + if !$output.stderr.is_empty() { + let stderr = String::from_utf8($output.stderr).expect("git should always produce utf8"); + bail!("{} returned some errors:\n{stderr}", git!(@_fmt_cmd $($args),*)); + } + }}; + (@_fmt_cmd $($args:expr),*) => {{ + let all = ["git", $($args),*].join(" "); + format!("`{}`", all) + }}; + (in $to:expr; $($args:expr),*) => {{ + let output = git!(@_base for output in $to; $($args),*); + git!(@_handle_status output; $($args),*); + + let stdout = String::from_utf8(output.stdout).expect("git should always produce utf8"); + let stderr = String::from_utf8(output.stderr).expect("git should always produce utf8"); + + (stdout, stderr) + }}; + (@set_stdin_to $stdin:expr; in $to:expr; $($args:expr),*) => {{ + let mut child = git!(@_base for spawn in $to; $($args),*); + let mut stdin = child.stdin.take().expect("is piped"); + write!(stdin, "{}", $stdin)?; + drop(stdin); + let output = child.wait_with_output() + .with_context(|| format!("Failed to run `{}`", git!(@_fmt_cmd $($args),*)))?; + git!(@_handle_status output; $($args),*); + + let stdout = String::from_utf8(output.stdout).expect("git should always produce utf8"); + let stderr = String::from_utf8(output.stderr).expect("git should always produce utf8"); + + (stdout, stderr) + }}; +} + +fn main() -> Result<()> { + let args = Cli::parse(); + + match args.subcommand { + Cmd::Apply { + patch_files, + to, + debug_parse, + } => { + check_to(&to)?; + apply_patches(patch_files, to, debug_parse)? + } + Cmd::Create { tag } => { + create_patches(tag)?; + } + Cmd::CreateApply { + tag, + to, + debug_parse, + } => { + check_to(&to)?; + + let Some(patches_directory) = create_patches(tag)? else { + return Ok(()); + }; + let patch_files = { + let iter = fs::read_dir(patches_directory)?; + let mut output = vec![]; + for entry in iter { + let entry = entry?; + if !entry.file_type()?.is_file() { + unreachable!("Should not be able to get non-files in patches directory"); + } + output.push(entry.path()); + } + output.sort(); + output + }; + apply_patches(patch_files, to, debug_parse)?; + } + } + + Ok(()) +} + +fn create_patches(tag: String) -> Result<Option<PathBuf>> { + let current_dir = current_dir()?; + + let (stdout, _) = git!(in current_dir; "tag", "--contains", "HEAD", tag.as_str()); + if !stdout.is_empty() { + println!( + "There are no tags to generate. Tag {} already points to HEAD.", + tag + ); + return Ok(None); + } + + let mktemp = Command::new("mktemp") + .args(["-t", "rollup_patches_XXXXXX", "-d"]) + .output() + .context("Failed to run `mktemp -t rollup_patches_XXXXXX -d`")?; + if !mktemp.status.success() { + bail!("Failed to generate temp-directory for generated patches."); + } + + let tempdir = PathBuf::from(String::from_utf8(mktemp.stdout).unwrap().trim()); + + git!(in current_dir; "format-patch", format!("{}..", tag).as_str(), "-o", tempdir.display().to_string().as_str()); + git!(in current_dir; "tag", "--delete", tag.as_str()); + git!(in current_dir; "tag", "--annotate", tag.as_str(), "-m", ""); + + println!("Patches have been generated here: `{}`", tempdir.display()); + + Ok(Some(tempdir)) +} + +fn apply_patches(patch_files: Vec<PathBuf>, to: PathBuf, debug_parse: bool) -> Result<()> { + let mut patches = vec![]; + for file in &patch_files { + patches.push(parse::parse_file(file, debug_parse)?); + } + + eprintln!("Successfully parsed all {} patch files.", patches.len()); + + { + let (stdout, _) = git!(in to; "status", "--porcelain"); + if !stdout.is_empty() { + bail!( + "`--to` (`{}`) workspace is dirty, refusing to continue.", + to.display() + ); + } + } + + for (idx, patch) in patches.iter().enumerate() { + eprintln!( + "Applying {}. patch: `{}`... ", + idx + 1, + patch_files[idx].display() + ); + + let mut final_diffs = vec![]; + for file_diff in &patch.files { + match file_diff { + FileDiff::Add { path, .. } => { + if to.join(path).exists() { + bail!("A file was added, that already existed. This should be impossible.") + } + final_diffs.push(file_diff.to_owned()); + // eprintln!(" > Keeping add of `{}`", path.display()); + } + FileDiff::Remove { path, .. } => { + if to.join(path).exists() { + final_diffs.push(file_diff.to_owned()); + // eprintln!(" > Keeping remove of `{}`", path.display()); + } else { + eprintln!( + " > Dropping remove of `{}`, as the file does not exist in the rollup target.", + path.display() + ); + } + } + FileDiff::RenameNoChange { from, to, .. } + | FileDiff::RenameWithChange { from, to, .. } => { + if to.join(from).exists() { + if to.join(to).exists() { + bail!( + "A rename (`{}` -> `{}`) tried to rename a path to an already existing path. This should be impossible.", + from.display(), + to.display() + ) + } + final_diffs.push(file_diff.to_owned()); + // eprintln!(" > Keeping rename of `{}` to `{}`", from.display(), to.display()); + } else { + eprintln!( + " > Dropping rename from `{}` to `{}`, as the from file does not exist in the rollup target.", + from.display(), + to.display() + ); + } + } + FileDiff::Change { path, .. } => { + if to.join(path).exists() { + final_diffs.push(file_diff.to_owned()); + } else { + eprintln!( + " > Dropping change to `{}`, as the file does not exist in the rollup target.", + path.display() + ); + } + } + } + } + + if final_diffs.is_empty() { + eprintln!(" -> Patch is empty after dropping, skipping it.",); + continue; + } + + let filtered_patch = Patch { + files: final_diffs, + ..patch.to_owned() + }; + + git!(@set_stdin_to filtered_patch; in to; "apply", "-", "--whitespace=nowarn"); + git!(in to; "add", "."); + git!(@set_stdin_to filtered_patch.commit_message; in to; "commit", "--file=-", format!("--date={}", filtered_patch.date).as_str(), "--quiet"); + } + + Ok(()) +} + +fn check_to(to: &Path) -> Result<()> { + let to = to.join(".git"); + + // NOTE: We cannot just try to stat() the path, because landlock might be in use and does not + // currently deny that (tracked in: https://github.com/landlock-lsm/linux/issues/52) <2026-11-09> + // So we instead try to list it. + let is_listable = { + let dir_entries = fs::read_dir(&to).with_context(|| { + format!( + "Failed check regarding `--to` directory (`{}`) readability. Are you in a sandbox?", + to.display() + ) + })?; + dir_entries.count() > 0 + }; + + if !is_listable { + bail!("To directory (`{}`) is not readable", to.display()); + } + + Ok(()) +} |
