Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support for Submodule Versioning #23

Merged
merged 22 commits into from
Dec 11, 2023
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
176 changes: 176 additions & 0 deletions git-version-macro/src/describe_submodules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
extern crate proc_macro;
use crate::canonicalize_path;
use crate::git_dependencies;
use crate::utils::run_git;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::quote;
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use syn::{
bracketed,
parse::{Parse, ParseStream},
punctuated::Punctuated,
token::{Comma, Eq},
Ident, LitStr,
};

macro_rules! error {
($($args:tt)*) => {
syn::Error::new(Span::call_site(), format!($($args)*))
};
}

#[derive(Default)]
pub(crate) struct GitModArgs {
args: Option<Punctuated<LitStr, Comma>>,
prefix: Option<LitStr>,
suffix: Option<LitStr>,
fallback: Option<LitStr>,
}

impl Parse for GitModArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut result = GitModArgs::default();
loop {
if input.is_empty() {
break;
}
let ident: Ident = input.parse()?;
let _: Eq = input.parse()?;
let check_dup = |dup: bool| {
if dup {
Err(error!("`{} = ` can only appear once", ident))
} else {
Ok(())
}
};
match ident.to_string().as_str() {
"args" => {
check_dup(result.args.is_some())?;
let content;
bracketed!(content in input);
result.args = Some(Punctuated::parse_terminated(&content)?);
}
"prefix" => {
check_dup(result.prefix.is_some())?;
result.prefix = Some(input.parse()?);
}
"suffix" => {
check_dup(result.suffix.is_some())?;
result.suffix = Some(input.parse()?);
}
"fallback" => {
check_dup(result.fallback.is_some())?;
result.fallback = Some(input.parse()?);
}
x => Err(error!("Unexpected argument name `{}`", x))?,
}
if input.is_empty() {
break;
}
let _: Comma = input.parse()?;
}
Ok(result)
}
}

pub(crate) fn git_module_versions_impl(args: GitModArgs) -> syn::Result<TokenStream2> {
let mut modules = match get_modules() {
Ok(x) => x,
Err(err) => return Err(error!("{}", err)),
};

modules.retain(|path| !path.is_empty());

let mut describe_paths: Vec<(String, String)> = vec![];

for path in modules {
let path_obj = Path::new(&path);
let path_obj = canonicalize_path(path_obj)?;
describe_paths.push((path, path_obj));
}

let git_describe_args = args.args.map_or_else(
|| vec!["--always".to_string(), "--dirty=-modified".to_string()],
|list| list.iter().map(|x| x.value()).collect(),
);

let prefix = match args.prefix {
Some(x) => x.value(),
_ => "".to_string(),
};
let suffix = match args.suffix {
Some(x) => x.value(),
_ => "".to_string(),
};
let fallback = args.fallback.map(|x| x.value());

match describe_modules(describe_paths, &git_describe_args, prefix, suffix, fallback) {
Ok(result) => {
let dependencies = git_dependencies()?;
let (paths, versions) = result;

Ok(quote!({
#dependencies;

[#((#paths, #versions)),*]

}))
}
Err(e) => Err(error!("{}", e)),
}
}

/// Run `git submodule foreach` command to discover submodules in the project.
fn get_modules() -> Result<Vec<String>, String> {
let mut args: Vec<String> = "submodule foreach --quiet --recursive"
.to_string()
.split(' ')
.map(|x| x.to_string())
.collect();

args.push("echo $displaypath".to_string());

let result = run_git("git submodule", Command::new("git").args(args))?;
Comment on lines +127 to +135
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let mut args: Vec<String> = "submodule foreach --quiet --recursive"
.to_string()
.split(' ')
.map(|x| x.to_string())
.collect();
args.push("echo $displaypath".to_string());
let result = run_git("git submodule", Command::new("git").args(args))?;
let result = run_git(
"git submodule",
Command::new("git")
.arg("submodule")
.arg("foreach")
.arg("--quiet")
.arg("--recursive")
.arg("echo $displaypath"),
)?;

Copy link
Collaborator

@de-vri-es de-vri-es Dec 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does echo $displaypath work on windows though? Maybe we should parse the .gitmodules file instead relying on git submodule foreach.

Copy link
Contributor Author

@baxterjo baxterjo Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lemme check real quick.

Edit: Yep! It works. The shell that executes the command must be implemented by git. Tested on Powershell and Command Prompt

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok :)


Ok(result.split('\n').map(|x| x.to_string()).collect())
}

/// Run `git describe` for each submodule to get the git version with the specified args.
fn describe_modules<I, S>(
paths: Vec<(String, String)>,
describe_args: I,
prefix: String,
suffix: String,
fallback: Option<String>,
) -> Result<(Vec<String>, Vec<String>), String>
where
I: IntoIterator<Item = S> + Clone,
S: AsRef<OsStr>,
{
let mut paths_out: Vec<String> = vec![];
let mut versions: Vec<String> = vec![];

for (rel_path, abs_path) in paths.into_iter() {
// Get the submodule version or fallback.
let result = match run_git(
"git describe",
Command::new("git")
.current_dir(abs_path)
.arg("describe")
.args(describe_args.clone()),
) {
Ok(version) => version,
Err(_git_err) if fallback.is_some() => fallback.clone().unwrap(),
Err(git_err) => {
// If git error and no fallback provided, return error.
return Err(git_err);
}
};
paths_out.push(rel_path);
versions.push(format!("{}{}{}", prefix, result, suffix))
}

Ok((paths_out, versions))
}
94 changes: 74 additions & 20 deletions git-version-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::token::{Comma, Eq};
use syn::{Expr, Ident, LitStr};

pub(crate) mod describe_submodules;
mod utils;
use self::utils::{describe_cwd, git_dir_cwd};

Expand All @@ -17,8 +17,7 @@ macro_rules! error {
}

fn canonicalize_path(path: &Path) -> syn::Result<String> {
path
.canonicalize()
path.canonicalize()
.map_err(|e| error!("failed to canonicalize {}: {}", path.display(), e))?
.into_os_string()
.into_string()
Expand All @@ -29,12 +28,18 @@ fn canonicalize_path(path: &Path) -> syn::Result<String> {
fn git_dependencies() -> syn::Result<TokenStream2> {
let git_dir = git_dir_cwd().map_err(|e| error!("failed to determine .git directory: {}", e))?;

let deps: Vec<_> = ["logs/HEAD", "index"].iter().flat_map(|&file| {
canonicalize_path(&git_dir.join(file)).map(Some).unwrap_or_else(|e| {
eprintln!("Failed to add dependency on the git state: {}. Git state changes might not trigger a rebuild.", e);
None
let deps: Vec<_> = ["logs/HEAD", "index"]
.iter()
.flat_map(|&file| {
canonicalize_path(&git_dir.join(file)).map(Some).unwrap_or_else(|e| {
eprintln!(
"Failed to add dependency on the git state: {}. Git state changes might not trigger a rebuild.",
e
);
None
})
})
}).collect();
.collect();

Ok(quote! {
#( include_bytes!(#deps); )*
Expand All @@ -55,7 +60,9 @@ impl Parse for Args {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut result = Args::default();
loop {
if input.is_empty() { break; }
if input.is_empty() {
break;
}
let ident: Ident = input.parse()?;
let _: Eq = input.parse()?;
let check_dup = |dup: bool| {
Expand Down Expand Up @@ -94,7 +101,9 @@ impl Parse for Args {
}
x => Err(error!("Unexpected argument name `{}`", x))?,
}
if input.is_empty() { break; }
if input.is_empty() {
break;
}
let _: Comma = input.parse()?;
}
Ok(result)
Expand Down Expand Up @@ -151,7 +160,7 @@ pub fn git_version(input: TokenStream) -> TokenStream {
fn git_version_impl(args: Args) -> syn::Result<TokenStream2> {
let git_args = args.git_args.map_or_else(
|| vec!["--always".to_string(), "--dirty=-modified".to_string()],
|list| list.iter().map(|x| x.value()).collect()
|list| list.iter().map(|x| x.value()).collect(),
);

let cargo_fallback = args.cargo_prefix.is_some() || args.cargo_suffix.is_some();
Expand All @@ -170,20 +179,65 @@ fn git_version_impl(args: Args) -> syn::Result<TokenStream2> {
if let Ok(version) = std::env::var("CARGO_PKG_VERSION") {
let prefix = args.cargo_prefix.iter();
let suffix = args.cargo_suffix;
Ok(quote!(
concat!(#(#prefix,)* #version, #suffix)
))
Ok(quote!(concat!(#(#prefix,)* #version, #suffix)))
} else if let Some(fallback) = args.fallback {
Ok(fallback.to_token_stream())
} else {
Err(error!("Unable to get git or cargo version"))
}
}
Err(_) if args.fallback.is_some() => {
Ok(args.fallback.to_token_stream())
}
Err(e) => {
Err(error!("{}", e))
}
Err(_) if args.fallback.is_some() => Ok(args.fallback.to_token_stream()),
Err(e) => Err(error!("{}", e)),
}
}

/// Get the git version for submodules below the cargo project.
///
/// This macro will not infer type if there are no submodules in the project.
///
/// This macro expands to `[(&str, &str), N]` where `N` is the total number of
/// submodules below the root of the project (evaluated recursively)
///
/// The format of the array is as follows:
///
/// `[("relative/path/to/submodule", "{prefix}{git_describe_output}{suffix}")]`
///
/// The following (named) arguments can be given:
///
/// - `args`: The arguments to call `git describe` with.
/// Default: `args = ["--always", "--dirty=-modified"]`
///
/// - `prefix`, `suffix`:
/// The git version for each submodule will be prefixed/suffixed
/// by these strings.
///
/// - `fallback`:
/// If all else fails, this string will be given instead of reporting an
/// error. This will yield the same type as if the macro was a success, but
/// format will be `[("relative/path/to/submodule", {fallback})]`
///
/// # Examples
///
/// ```
/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!();
/// ```
///
/// ```
/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!(args = ["--abbrev=40", "--always"]);
/// ```
///
/// ```
/// # use git_version::git_version_modules;
/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!(prefix = "git:", fallback = "unknown");
/// ```
#[proc_macro]
pub fn git_module_versions(input: TokenStream) -> TokenStream {
let args = syn::parse_macro_input!(input as describe_submodules::GitModArgs);

let tokens = match describe_submodules::git_module_versions_impl(args) {
Ok(x) => x,
Err(e) => e.to_compile_error(),
};

TokenStream::from(tokens)
}
12 changes: 7 additions & 5 deletions git-version-macro/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub fn git_dir_cwd() -> Result<PathBuf, String> {
Ok(PathBuf::from(path))
}

fn run_git(program: &str, command: &mut std::process::Command) -> Result<String, String> {
pub(crate) fn run_git(program: &str, command: &mut std::process::Command) -> Result<String, String> {
let output = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
Expand All @@ -34,8 +34,8 @@ fn run_git(program: &str, command: &mut std::process::Command) -> Result<String,

let output = collect_output(program, output)?;
let output = strip_trailing_newline(output);
let output = String::from_utf8(output)
.map_err(|_| format!("Failed to parse output of `{}`: output contains invalid UTF-8", program))?;
let output =
String::from_utf8(output).map_err(|_| format!("Failed to parse output of `{}`: output contains invalid UTF-8", program))?;
Ok(output)
}

Expand All @@ -48,7 +48,9 @@ fn collect_output(program: &str, output: std::process::Output) -> Result<Vec<u8>
// If the command terminated with non-zero exit code, return an error.
} else if let Some(status) = output.status.code() {
// Include the first line of stderr in the error message, if it's valid UTF-8 and not empty.
let message = output.stderr.split(|c| *c == b'\n')
let message = output
.stderr
.split(|c| *c == b'\n')
.next()
.and_then(|x| std::str::from_utf8(x).ok())
.filter(|x| !x.is_empty());
Expand Down Expand Up @@ -82,8 +84,8 @@ fn strip_trailing_newline(mut input: Vec<u8>) -> Vec<u8> {

#[test]
fn test_git_dir() {
use std::path::Path;
use assert2::{assert, let_assert};
use std::path::Path;

let_assert!(Ok(git_dir) = git_dir_cwd());
let_assert!(Ok(git_dir) = git_dir.canonicalize());
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//! These macros do not depend on libgit, but simply uses the `git` binary directly.
//! So you must have `git` installed somewhere in your `PATH`.

pub use git_version_macro::git_version;
pub use git_version_macro::{git_module_versions, git_version};

/// Run `git describe` at compile time with custom flags.
///
Expand Down
Loading