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, /// 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 }; apply_patches(patch_files, to, debug_parse)?; } } Ok(()) } fn create_patches(tag: String) -> Result> { 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, 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(()) }