use std::{ fs::OpenOptions, io::Write, ops::Deref, path::{Path, PathBuf}, }; use clap::{Args, Parser, Subcommand}; use config::Config; use directories::ProjectDirs; use miette::{Context, IntoDiagnostic, Result, miette}; use temp_dir::TempDir; use utils::OptExt; mod utils; const COVER_LETTER_NAME: &str = "cover-letter"; #[derive(Parser, Debug)] struct Arg { #[arg( long, global = true, help = "git repository to use, defaults to current repository" )] repo: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { /// Format a patch. alias "p" #[command(alias = "p")] FormatPatch(FormatPatch), /// List all currently known patch series #[command(alias = "ls")] List(List), /// Send a patch series by mail Send(Send), /// Delete a series Delete(Delete), } #[derive(Args, Debug)] struct Delete { /// Only delete the local branch of the series #[arg(short, long)] local_only: bool, /// Force the deletion of the branch (-D) #[arg(short, long)] force: bool, /// Branch to delete (defaults to the current branch) branch: Option, } impl Delete { pub fn run( self, config: GsmConfig, git_cd: impl Fn(&[&str]) -> Result, patch_dir: &Path, ) -> Result<()> { let current_branch = git_cd(&["branch", "--show-current"])?; let branch = self .branch .as_ref() .try_m_unwrap_or_else(|| Ok(¤t_branch))?; if !self.local_only && let Ok(remote) = git_cd(&[ "rev-parse", "--symbolic-full-name", &format!("{branch}@{{u}}"), ]) { let mut components = remote.split("/"); let refs = components .next() .ok_or_else(|| miette!("Missing `refs` component in {remote}"))?; miette::ensure!(refs == "refs", "Unexepected {refs} component in `{remote}`"); let remotes = components .next() .ok_or_else(|| miette!("Missing `remotes` in {remote}"))?; miette::ensure!( remotes == "remotes", "Unexepected {remotes} component in `{remote}`" ); let remote_name = components .next() .ok_or_else(|| miette!("Missing remote name in {remote}"))?; let remote_branch_name = components .next() .ok_or_else(|| miette!("Missing remote branch name in {remote}"))?; println!("Removing branch {remote_branch_name} from remote repository ({remote_name})"); git_cd(&["push", "-d", remote_name, remote_branch_name])?; } if branch == ¤t_branch { println!("Branch {branch} currently checked out, switching to master"); git_cd(&[ "switch", config.interdiff_base.as_deref().unwrap_or("master"), ])?; } let branch_delete = match self.force { true => "-D", false => "-d", }; git_cd(&["branch", branch_delete, branch])?; let branch_dir = patch_dir.join(&branch); std::fs::remove_dir_all(branch_dir).into_diagnostic()?; Ok(()) } } #[derive(Args, Debug)] struct List { #[arg(short, long)] verbose: bool, } impl List { pub fn run( self, _config: GsmConfig, _git_cd: impl Fn(&[&str]) -> Result, patch_dir: &Path, ) -> Result<()> { for entry in patch_dir .read_dir() .into_diagnostic() .wrap_err("Could not read patch dir")? { let entry = entry .into_diagnostic() .wrap_err("Could not read patch dir entry")?; if entry.file_name() == "config.toml" { continue; } let branch_dir = patch_dir.join(entry.file_name()); let Some(branch_version) = latest_version(&branch_dir).wrap_err("Could not fetch latest version")? else { continue; }; println!( " - {}: v{branch_version}", entry.file_name().to_string_lossy() ); if self.verbose { println!(" Patches:"); for entry in branch_dir .join(branch_version.to_string()) .read_dir() .into_diagnostic() .wrap_err("Could not read patchset dir")? { let entry = entry .into_diagnostic() .wrap_err("Could not read patchset entry")?; println!(" - {}", entry.file_name().to_string_lossy()); } } } Ok(()) } } #[derive(Args, Debug)] struct Send { #[arg( short, long, help = "Version of the patchset to set. Defaults to the latest version" )] version: Option, #[arg(help = "Patch series to send. Defaults to the current branch")] series: Option, } impl Send { pub fn run( self, config: GsmConfig, git_cd: impl Fn(&[&str]) -> Result, patch_dir: &Path, ) -> Result<()> { let current_branch = git_cd(&["branch", "--show-current"])?; let branch = self .series .as_ref() .try_m_unwrap_or_else(|| Ok(¤t_branch))?; let branch_dir = patch_dir.join(&branch); let version = match self.version { Some(v) => v, None => match latest_version(&branch_dir)? { None => return Err(miette!("No patch set for the branch {branch}")), Some(v) => v, }, }; let version_dir = &branch_dir.join(&version.to_string()); let mut cmd = std::process::Command::new("git"); cmd.arg("send-email"); if let Some(args) = &config.sendmail_args { cmd.args(args.iter()); } cmd.arg(version_dir); let status = cmd .status() .into_diagnostic() .wrap_err("Could not send emails")?; if !status.success() { return Err(miette!("Could not send emails")); } Ok(()) } } #[derive(Args, Debug)] struct FormatPatch { #[arg(short, long, help = "Branch to use (defaults to the current branch)")] branch: Option, #[arg(short, long, help = "Jenkins job number")] ci: Option, #[arg( short, long, help = "Version of the patchset. Defaults to last patchset + 1" )] version: Option, #[arg(long, help = "Override the current version of the patchset")] force: bool, #[arg(long, help = "Silence the CI warning")] no_ci: bool, #[arg(short, long, help = "Send the patches directly")] send: bool, #[arg(short, long, help = "Perform the interdiff with the supplied version")] diff: Option, #[arg( short = 'B', long, help = "Reference for the interdiff (defaults to ${config.interdiff_base})" )] base_diff: Option, #[arg( short = 'D', long, help = "Perform the interdiff with an explicit reference", conflicts_with = "diff" )] diff_to: Option, extra_args: Vec, } impl FormatPatch { pub fn run( self, config: GsmConfig, git_cd: impl Fn(&[&str]) -> Result, patch_dir: &Path, ) -> Result<()> { if config.ci_url.is_some() && self.ci.is_none() { eprintln!("WARNING: CI was not specified\n"); } let branch = self .branch .try_m_unwrap_or_else(|| Ok(git_cd(&["branch", "--show-current"])?))?; let component = config.component.try_m_unwrap_or_else(|| { let url = git_cd(&["remote", "get-url", "origin"])?; Ok(url .strip_prefix(&config.repo_url_base) .ok_or(miette!( "remote {url} does not start with url base {}", config.repo_url_base ))? .trim_end_matches(".git") .to_string()) })?; println!("Component: {component}"); println!("Branch: {branch}"); let ci_link = match (config.ci_url, self.ci) { (Some(ci_template), Some(id)) => Some( ci_template .replace("${component}", &component) .replace("${branch}", &branch) .replace("${ci_job}", &id.to_string()), ), (Some(ci_template), None) => Some( ci_template .replace("${component}", &component) .replace("${branch}", &branch), ), (None, _) => None, }; let branch_dir = patch_dir.join(&branch); std::fs::create_dir_all(&branch_dir) .into_diagnostic() .wrap_err("could not create branch dir")?; let version = match self.version { Some(v) => Some(v), None => latest_version(&branch_dir) .wrap_err("could not get version")? .map(|v| v + 1), }; let version_dir = branch_dir.join(version.unwrap_or(1).to_string()); if version_dir.exists() { if !self.force { return Err(miette!( "Patch dir {version_dir:?} exists, pass --force to delete it" )); } else { std::fs::remove_dir_all(&version_dir).into_diagnostic()?; } } let version_dir = version_dir .to_str() .ok_or(miette!("Temp dir is not utf-8"))?; struct VersionDir { path: PathBuf, } impl Drop for VersionDir { fn drop(&mut self) { std::fs::remove_dir_all(&self.path).expect("could not delete version dir on error"); } } let format_patch = |extra_args: &[&str]| -> Result<_> { let mut format_patch_args = vec!["format-patch", "-o", &version_dir]; let version_str = version.map(|s| s.to_string()); if let Some(version) = &version_str { format_patch_args.push("-v"); format_patch_args.push(version); } let subject_prefix = format!(r#"--subject-prefix=PATCH {component}"#); format_patch_args.extend_from_slice(&[&subject_prefix, "--cover-letter"]); format_patch_args.extend_from_slice(extra_args); format_patch_args.extend(self.extra_args.iter().map(|s| s.deref())); git_cd(&format_patch_args)?; Ok(VersionDir { path: version_dir.into(), }) }; let _version_dir = match (self.diff, self.diff_to) { (None, None) => format_patch(&[])?, (Some(_), Some(_)) => unreachable!(), (Some(patch_version), None) => { let base = self .base_diff .or(config.interdiff_base) .unwrap_or_else(|| String::from("origin/master")); struct TempBranch<'a> { name: &'a str, git: &'a dyn Fn(&[&str]) -> Result, } impl<'a> Drop for TempBranch<'a> { fn drop(&mut self) { (self.git)(&["branch", "-D", self.name]).unwrap(); } } let branch = { let name = "__patch_old"; git_cd(&["branch", name, &base])?; TempBranch { name, git: &git_cd } }; struct GitWorktree { _dir: TempDir, path: String, } impl GitWorktree { pub fn exec(&self, args: &[&str]) -> Result { let mut a = vec!["-C", &self.path]; a.extend_from_slice(args); git_bare(a) } } impl Drop for GitWorktree { fn drop(&mut self) { self.exec(&["worktree", "remove", "--force", &self.path]) .unwrap(); } } let wt = { let worktree = temp_dir::TempDir::new() .into_diagnostic() .wrap_err("Could not create worktree directory")?; let worktree_path = worktree .path() .to_str() .ok_or(miette!("Temp dir is not utf-8"))? .to_string(); git_cd(&["worktree", "add", "--detach", &worktree_path])?; GitWorktree { path: worktree_path, _dir: worktree, } }; wt.exec(&["switch", branch.name])?; let patches = branch_dir .join(&patch_version.to_string()) .read_dir() .into_diagnostic() .wrap_err("Could not read interdiff folder")? .map(|e| -> Result<_> { let e = e .into_diagnostic() .wrap_err("Could not read interdiff entry")?; let path = e .path() .to_str() .ok_or(miette!("Interdiff patch path is not utf-8"))? .to_string(); match path.contains("cover-letter") { true => Ok(None), false => Ok(Some(path)), } }) .filter_map(|e| e.transpose()) .collect::>>()?; let mut apply_args = vec!["am", "-3"]; apply_args.extend(patches.iter().map(|s| s.deref())); wt.exec(&apply_args)?; let interdiff_branch = format!("--interdiff={}", branch.name); format_patch(&[&interdiff_branch])? } (None, Some(diff_to)) => format_patch(&[&format!("--interdiff={diff_to}")])?, }; let cover_letter = branch_dir.join(COVER_LETTER_NAME); if !cover_letter.exists() { let mut cover_letter_template = format!("Title: \n\nBranch: {branch}\n"); if let Some(ci_link) = &ci_link { cover_letter_template += &format!("CI: {ci_link}\n"); } std::fs::write(&cover_letter, cover_letter_template) .into_diagnostic() .wrap_err("Could not write cover letter")?; } std::process::Command::new(config.editor) .arg(&cover_letter) .status() .into_diagnostic() .wrap_err("Could not edit cover letter")?; let cover_letter = std::fs::read_to_string(cover_letter) .into_diagnostic() .wrap_err("Error while reading back the cover letter")?; let Some((title, body)) = cover_letter.split_once('\n') else { return Err(miette!("Missing title newline")); }; let Some(title) = title.strip_prefix("Title: ") else { return Err(miette!("Missing `Title: ` prefix")); }; let mut cover_letter = None; for entry in Path::new(version_dir) .read_dir() .into_diagnostic() .wrap_err("Could not read patch directory")? { let entry = entry .into_diagnostic() .wrap_err("Error while reading patch directory")?; if entry .file_name() .as_encoded_bytes() .ends_with(b"cover-letter.patch") { cover_letter = Some(entry.file_name()); } } let cover_letter = Path::new(version_dir) .join(cover_letter.ok_or(miette!("Did not find cover letter in {version_dir}"))?); let cover_letter_content = std::fs::read_to_string(&cover_letter) .into_diagnostic() .wrap_err("Could not read patchset cover letter")?; let cover_letter_content = cover_letter_content .replace("*** SUBJECT HERE ***", title.trim()) .replace("*** BLURB HERE ***", body.trim()); let mut file = OpenOptions::new() .write(true) .truncate(true) .open(cover_letter) .into_diagnostic() .wrap_err("Could not re-open cover letter")?; file.write(cover_letter_content.as_bytes()) .into_diagnostic() .wrap_err("Could not save cover letter")?; std::mem::forget(_version_dir); Ok(()) } } #[derive(Debug, serde::Deserialize)] struct GsmConfig { sendmail_args: Option>, editor: String, repo_url_base: String, component: Option, ci_url: Option, interdiff_base: Option, } fn latest_version(branch_dir: &Path) -> Result> { let mut dir_content = branch_dir .read_dir() .into_diagnostic() .wrap_err("could not read branch dir")? .peekable(); match dir_content.peek() { Some(_) => Ok(Some( dir_content .filter_map(|e| { let entry = match e.into_diagnostic().wrap_err("Could not read entry") { Ok(e) => e, Err(e) => return Some(Err(e)), }; let name = entry.file_name(); let name = name.to_str().expect("patch set entry is not utf8"); if name == "cover-letter" { None } else { Some(Ok(name.parse().expect("version is not an int"))) } }) .try_fold(0, |cur, version| -> Result<_> { let version = version?; Ok(std::cmp::max(version, cur)) })?, )), None => Ok(None), } } fn git_bare(args: Vec<&str>) -> Result { let out = duct::cmd("git", args) .stderr_to_stdout() .unchecked() .stdout_capture() .run() .into_diagnostic() .wrap_err("failed to launch git")?; let output = String::from_utf8_lossy(&out.stdout); let output = output.trim(); if !out.status.success() { return Err(miette!("{output}").wrap_err("git command failed")); } else { Ok(output.to_string()) } } fn main() -> Result<()> { let args = Arg::parse(); let project_dir = ProjectDirs::from("net", "traxys", "git-series-manager") .ok_or(miette!("Could not create project dirs"))?; let repo_root = args.repo.try_m_unwrap_or_else(|| { Ok(PathBuf::from(git_bare(vec![ "rev-parse", "--show-toplevel", ])?)) })?; let patch_dir = repo_root.join(".patches"); let git_cd = |args: &[&str]| { let mut a = vec![ "-C", repo_root .to_str() .ok_or(miette!("{repo_root:?} is not a valid string"))?, ]; a.extend_from_slice(args); git_bare(a) }; let config: GsmConfig = Config::builder() .add_source( config::File::from(project_dir.config_dir().join("config.toml")).required(false), ) .add_source(config::File::from(patch_dir.join("config.toml")).required(false)) .build() .into_diagnostic() .wrap_err("Failed to read the configuration")? .try_deserialize() .into_diagnostic() .wrap_err("Failed to deserialize the configuration")?; std::fs::create_dir_all(&patch_dir) .into_diagnostic() .wrap_err("could not create patch directory")?; match args.command { Command::FormatPatch(args) => args.run(config, git_cd, &patch_dir), Command::List(list) => list.run(config, git_cd, &patch_dir), Command::Send(send) => send.run(config, git_cd, &patch_dir), Command::Delete(delete) => delete.run(config, git_cd, &patch_dir), } }