From d5c5ed83790d925aa7324edd49b44021eb8ae544 Mon Sep 17 00:00:00 2001 From: Christoph Siedentop Date: Fri, 13 Nov 2020 22:27:26 -0800 Subject: [PATCH] Provide porcelain output for scripts. Options: * local branches * remote branches * JSON output. \#138 --- Cargo.lock | 47 +++++- Cargo.toml | 2 + src/args.rs | 39 +++++ src/branch.rs | 7 +- src/core.rs | 7 +- src/lib.rs | 1 + src/main.rs | 21 ++- src/porcelain_outputs.rs | 53 +++++++ tests/filter_accidential_track_porcelain.rs | 155 ++++++++++++++++++++ 9 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/porcelain_outputs.rs create mode 100644 tests/filter_accidential_track_porcelain.rs diff --git a/Cargo.lock b/Cargo.lock index f0e3ac4..6ae217e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,6 +256,8 @@ dependencies = [ "rayon", "regex", "rson_rs", + "serde", + "serde_json", "tempfile", "textwrap 0.12.1", "thiserror", @@ -341,6 +343,12 @@ dependencies = [ "regex", ] +[[package]] +name = "itoa" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" + [[package]] name = "jobserver" version = "0.1.21" @@ -698,6 +706,12 @@ dependencies = [ "serde", ] +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + [[package]] name = "scopeguard" version = "1.1.0" @@ -709,6 +723,31 @@ name = "serde" version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcac07dbffa1c65e7f816ab9eba78eb142c6d44410f4eeba1e26e4f5dfa56b95" +dependencies = [ + "itoa", + "ryu", + "serde", +] [[package]] name = "strsim" @@ -763,9 +802,9 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.1.13" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a14cd9f8c72704232f0bfc8455c0e861f0ad4eb60cc9ec8a170e231414c1e13" +checksum = "4bd2d183bd3fac5f5fe38ddbeb4dc9aec4a39a9d7d59e7491d900302da01cbe1" dependencies = [ "libc", "winapi", @@ -865,9 +904,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" +checksum = "db8716a166f290ff49dabc18b44aa407cb7c6dbe1aa0971b44b8a24b0ca35aae" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index dfb93b1..860f763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ man = { version = "0.3.0", optional = true } rson_rs = { version = "0.2.1", optional = true } regex = { version = "1.4.2", optional = true } indicatif = { version = "0.15.0" , features = ["rayon"]} +serde = { version = "1.0.117", features = ["derive"] } +serde_json = "1.0.59" [dev-dependencies] tempfile = "3.1.0" diff --git a/src/args.rs b/src/args.rs index 4badf7a..c1512c1 100644 --- a/src/args.rs +++ b/src/args.rs @@ -40,6 +40,10 @@ pub struct Args { #[clap(long, hidden(true))] pub update: bool, + /// Output for scripting. Options are "json" for full structured output or "local" or "remote" for a list of branches to be deleted. + #[clap(long)] + pub porcelain: Option, + /// Prevents too frequent updates. Seconds between updates in seconds. 0 to disable. /// [default: 5] [config: trim.updateInterval] #[clap(long)] @@ -127,6 +131,41 @@ fn exclusive_bool( } } +/// Configuration of --porcelain format. +#[derive(Debug)] +pub enum PorcelainFormat { + /// List of to-be-deleted local branches + LocalBranches, + /// List of remote branches to be deleted. + RemoteBranches, + /// Full structured JSON output + JSON, +} + +impl FromStr for PorcelainFormat { + type Err = PorcelainFormatParseError; + + fn from_str(s: &str) -> Result { + match s.trim() { + "" => Err(PorcelainFormatParseError { + message: "Porcelain format is empty".to_owned(), + }), + "json" => Ok(PorcelainFormat::JSON), + "local" | "l" => Ok(PorcelainFormat::LocalBranches), + "remote" | "r" => Ok(PorcelainFormat::RemoteBranches), + unknown => Err(PorcelainFormatParseError { + message: format!("Unknown porcelain format: {}", unknown), + }), + } + } +} + +#[derive(Error, Debug)] +#[error("{message}")] +pub struct PorcelainFormatParseError { + message: String, +} + #[derive(Hash, Eq, PartialEq, Clone, Debug)] pub enum Scope { All, diff --git a/src/branch.rs b/src/branch.rs index 9274b7d..98898c7 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -3,6 +3,7 @@ use std::convert::TryFrom; use anyhow::{Context, Result}; use git2::{Branch, Config, Direction, Reference, Repository}; use log::*; +use serde::Serialize; use thiserror::Error; use crate::config; @@ -12,7 +13,7 @@ pub trait Refname { fn refname(&self) -> &str; } -#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone, Serialize)] pub struct LocalBranch { pub refname: String, } @@ -83,7 +84,7 @@ impl<'repo> TryFrom<&git2::Reference<'repo>> for LocalBranch { } } -#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone, Serialize)] pub struct RemoteTrackingBranch { pub refname: String, } @@ -202,7 +203,7 @@ pub enum RemoteTrackingBranchStatus { None, } -#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug, Serialize)] pub struct RemoteBranch { pub remote: String, pub refname: String, diff --git a/src/core.rs b/src/core.rs index 4685db0..64fe938 100644 --- a/src/core.rs +++ b/src/core.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result}; use git2::{BranchType, Config, Repository}; use log::*; use rayon::prelude::*; +use serde::Serialize; use crate::args::DeleteFilter; use crate::branch::{ @@ -19,12 +20,14 @@ use crate::{config, BaseSpec, Git}; use indicatif::ParallelProgressIterator; use rayon::iter::ParallelIterator; +#[derive(Serialize)] pub struct TrimPlan { pub skipped: HashMap, pub to_delete: HashSet, pub preserved: Vec, } +#[derive(Serialize)] pub struct Preserved { pub branch: ClassifiedBranch, pub reason: String, @@ -392,7 +395,7 @@ impl TrimPlan { } } -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq, Serialize)] pub enum SkipSuggestion { Tracking, TrackingRemote(String), @@ -436,7 +439,7 @@ fn get_protect_pattern<'a, B: Refname>( Ok(None) } -#[derive(Hash, Eq, PartialEq, Debug, Clone)] +#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize)] pub enum ClassifiedBranch { MergedLocal(LocalBranch), Stray(LocalBranch), diff --git a/src/lib.rs b/src/lib.rs index 6279b2c..b9edd52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod branch; pub mod config; mod core; mod merge_tracker; +pub mod porcelain_outputs; mod simple_glob; mod subprocess; mod util; diff --git a/src/main.rs b/src/main.rs index 593265a..a809f68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,9 @@ use dialoguer::Confirm; use git2::{BranchType, Repository}; use log::*; -use git_trim::args::Args; +use git_trim::args::{Args, PorcelainFormat}; use git_trim::config::{self, get, Config, ConfigValue}; +use git_trim::porcelain_outputs::{print_json, print_local, print_remote}; use git_trim::{ delete_local_branches, delete_remote_branches, get_trim_plan, ls_remote_head, remote_update, ClassifiedBranch, ForceSendSync, Git, LocalBranch, PlanParam, RemoteHead, RemoteTrackingBranch, @@ -58,7 +59,23 @@ fn main(args: Args) -> Result<()> { }, )?; - print_summary(&plan, &git.repo)?; + match args.porcelain { + None => { + print_summary(&plan, &git.repo)?; + } + Some(PorcelainFormat::LocalBranches) => { + print_local(&plan, &git.repo, &mut std::io::stdout())?; + return Ok(()); + } + Some(PorcelainFormat::RemoteBranches) => { + print_remote(&plan, &git.repo, &mut std::io::stdout())?; + return Ok(()); + } + Some(PorcelainFormat::JSON) => { + print_json(&plan, &git.repo, &mut std::io::stdout())?; + return Ok(()); + } + } let locals = plan.locals_to_delete(); let remotes = plan.remotes_to_delete(&git.repo)?; diff --git a/src/porcelain_outputs.rs b/src/porcelain_outputs.rs new file mode 100644 index 0000000..414fe6f --- /dev/null +++ b/src/porcelain_outputs.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use git2::Repository; + +use crate::TrimPlan; + +/// Prints all locally to-be-deleted branches. +pub fn print_local( + plan: &TrimPlan, + _repo: &Repository, + mut writer: impl std::io::Write, +) -> Result<()> { + let mut merged_locals = Vec::new(); + for branch in &plan.to_delete { + if let Some(local) = branch.local() { + merged_locals.push(local.short_name().to_owned()); + } + } + + merged_locals.sort(); + for branch in merged_locals { + writeln!(writer, "{}", branch)?; + } + + Ok(()) +} + +/// Print all remotely to-be-deleted branches in the form "/" +pub fn print_remote( + plan: &TrimPlan, + repo: &Repository, + mut writer: impl std::io::Write, +) -> Result<()> { + let mut merged_remotes = Vec::new(); + for branch in &plan.to_delete { + if let Some(remote) = branch.remote(repo)? { + merged_remotes.push(remote); + } + } + + merged_remotes.sort(); + for branch in merged_remotes { + let branch_name = &branch.refname["/refs/heads".len()..]; + writeln!(writer, "{}/{}", branch.remote, branch_name)?; + } + + Ok(()) +} + +pub fn print_json(plan: &TrimPlan, _repo: &Repository, writer: impl std::io::Write) -> Result<()> { + serde_json::to_writer(writer, &plan)?; + + Ok(()) +} diff --git a/tests/filter_accidential_track_porcelain.rs b/tests/filter_accidential_track_porcelain.rs new file mode 100644 index 0000000..bbf4480 --- /dev/null +++ b/tests/filter_accidential_track_porcelain.rs @@ -0,0 +1,155 @@ +mod fixture; + +use std::convert::TryFrom; +use std::iter::FromIterator; + +use anyhow::Result; +use git2::Repository; + +use git_trim::args::{DeleteFilter, DeleteRange, Scope}; +use git_trim::{ + get_trim_plan, ClassifiedBranch, Git, LocalBranch, PlanParam, RemoteTrackingBranch, +}; + +use git_trim::porcelain_outputs::*; + +use fixture::{rc, test_default_param, Fixture}; + +fn fixture() -> Fixture { + rc().append_fixture_trace( + r#" + git init origin --bare + + git clone origin local + local < README.md + git add README.md + git commit -m "Initial commit" + git push -u origin master + EOF + + git clone origin contributer + within contributer < PlanParam<'static> { + PlanParam { + delete: DeleteFilter::from_iter(vec![ + DeleteRange::MergedLocal, + DeleteRange::MergedRemote(Scope::Scoped("origin".to_string())), + ]), + ..test_default_param() + } +} + +#[test] +fn test_default_config_tries_to_delete_accidential_track() -> Result<()> { + let guard = fixture().prepare( + "local", + r#" + local < Result<()> { + let guard = fixture().prepare( + "local", + r#" + local <