1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
//! Derive macro for `git_testament`
//!
extern crate proc_macro;

use std::env;
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use proc_macro::TokenStream;
use quote::quote;
use syn::parse;
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, Ident};

use chrono::prelude::{DateTime, FixedOffset, NaiveDateTime, TimeZone, Utc};

use log::warn;

struct TestamentOptions {
    name: Ident,
}

impl Parse for TestamentOptions {
    fn parse(input: ParseStream) -> parse::Result<Self> {
        let name: Ident = input.parse()?;
        Ok(TestamentOptions { name })
    }
}

fn run_git<GD>(dir: GD, args: &[&str]) -> Result<Vec<u8>, Box<Error>>
where
    GD: AsRef<Path>,
{
    let output = Command::new("git")
        .args(args)
        .stdin(Stdio::null())
        .current_dir(dir)
        .output()?;
    if output.status.success() {
        Ok(output.stdout)
    } else {
        Err(String::from_utf8(output.stderr)?)?
    }
}

fn find_git_dir() -> Result<PathBuf, Box<Error>> {
    // run git rev-parse --show-toplevel in the MANIFEST DIR
    let dir = run_git(
        env::var("CARGO_MANIFEST_DIR").unwrap(),
        &["rev-parse", "--show-toplevel"],
    )?;
    // TODO: Find a way to go from the stdout to a pathbuf cleanly
    // without relying on utf8ness
    Ok(String::from_utf8(dir)?.trim_end().into())
}

fn revparse_single(git_dir: &Path, refname: &str) -> Result<(String, i64, i32), Box<Error>> {
    // TODO: Again, try and remove UTF8 assumptions somehow
    let sha = String::from_utf8(run_git(git_dir, &["rev-parse", refname])?)?
        .trim_end()
        .to_owned();
    let show = String::from_utf8(run_git(git_dir, &["cat-file", "-p", &sha])?)?;

    for line in show.lines() {
        if line.starts_with("committer ") {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() < 2 {
                Err(format!("Insufficient committer data in {}", line))?
            }
            let time: i64 = parts[parts.len() - 2].parse()?;
            let offset: &str = parts[parts.len() - 1];
            if offset.len() != 5 {
                Err(format!(
                    "Insufficient/Incorrect data in timezone offset: {}",
                    offset
                ))?
            }
            let offset: i32 = if offset.starts_with('-') {
                // Negative...
                let hours: i32 = offset[1..=2].parse()?;
                let mins: i32 = offset[3..=4].parse()?;
                -(mins + (hours * 60))
            } else {
                // Positive...
                let hours: i32 = offset[1..=2].parse()?;
                let mins: i32 = offset[3..=4].parse()?;
                (mins + (hours * 60))
            };
            return Ok((sha, time, offset));
        } else if line.is_empty() {
            // Ran out of input, without finding committer
            Err(format!(
                "Unable to find committer information in {}",
                refname
            ))?
        }
    }

    Err("Somehow fell off the end of the commit data")?
}

fn branch_name(dir: &Path) -> Result<Option<String>, Box<Error>> {
    let symref = match run_git(dir, &["symbolic-ref", "-q", "HEAD"]) {
        Ok(s) => s,
        Err(_) => run_git(dir, &["name-rev", "--name-only", "HEAD"])?,
    };
    let mut name = String::from_utf8(symref)?.trim().to_owned();
    if name.starts_with("refs/heads/") {
        name = name[11..].to_owned();
    }
    if name.is_empty() {
        Ok(None)
    } else {
        Ok(Some(name))
    }
}

fn describe(dir: &Path, sha: &str) -> Result<String, Box<Error>> {
    // TODO: Work out a way to not use UTF8?
    Ok(
        String::from_utf8(run_git(dir, &["describe", "--tags", "--long", sha])?)?
            .trim_end()
            .to_owned(),
    )
}

enum StatusFlag {
    Added,
    Deleted,
    Modified,
    Untracked,
}
use StatusFlag::*;

struct StatusEntry {
    path: String,
    status: StatusFlag,
}

fn status(dir: &Path) -> Result<Vec<StatusEntry>, Box<Error>> {
    // TODO: Work out a way to not use UTF8?
    let info = String::from_utf8(run_git(
        dir,
        &[
            "status",
            "--porcelain",
            "--untracked-files=normal",
            "--ignore-submodules=all",
        ],
    )?)?;

    let mut ret = Vec::new();

    for line in info.lines() {
        let index_change = line.chars().next().unwrap();
        let worktree_change = line.chars().nth(1).unwrap();
        match (index_change, worktree_change) {
            ('?', _) | (_, '?') => ret.push(StatusEntry {
                path: line[3..].to_owned(),
                status: Untracked,
            }),
            ('A', _) | (_, 'A') => ret.push(StatusEntry {
                path: line[3..].to_owned(),
                status: Added,
            }),
            ('M', _) | (_, 'M') => ret.push(StatusEntry {
                path: line[3..].to_owned(),
                status: Modified,
            }),
            ('D', _) | (_, 'D') => ret.push(StatusEntry {
                path: line[3..].to_owned(),
                status: Deleted,
            }),
            _ => {}
        }
    }

    Ok(ret)
}

/// Generate a testament for the working tree.
///
/// This macro declares a static data structure which represents a testament
/// to the state of a git repository at the point that a crate was built.
///
/// The intention is that the macro should be used at the top level of a binary
/// crate to provide information about the state of the codebase that the output
/// program was built from.  This includes a number of things such as the commit
/// SHA, any related tag, how many commits since the tag, the date of the commit,
/// and if there are any "dirty" parts to the working tree such as modified files,
/// uncommitted files, etc.
///
/// ```
/// // Bring the procedural macro into scope
/// use git_testament::git_testament;
///
/// // Declare a testament, it'll end up as a static, so give it a capital
/// // letters name or it'll result in a warning.
/// git_testament!(TESTAMENT);
/// # fn main() {
///
/// // ... later, you can display the testament.
/// println!("app version {}", TESTAMENT);
/// # }
/// ```
///
/// See [`git_testament::GitTestament`] for the type of the defined `TESTAMENT`.
#[proc_macro]
pub fn git_testament(input: TokenStream) -> TokenStream {
    let TestamentOptions { name } = parse_macro_input!(input as TestamentOptions);

    let pkgver = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "?.?.?".to_owned());
    let now = Utc::now();
    let now = format!("{}", now.format("%Y-%m-%d"));
    let sde = match env::var("SOURCE_DATE_EPOCH") {
        Ok(sde) => match sde.parse::<i64>() {
            Ok(sde) => Some(format!("{}", Utc.timestamp(sde, 0).format("%Y-%m-%d"))),
            Err(_) => None,
        },
        Err(_) => None,
    };
    let now = sde.unwrap_or(now);

    let git_dir = match find_git_dir() {
        Ok(dir) => dir,
        Err(e) => {
            warn!(
                "Unable to open a repo at {}: {}",
                env::var("CARGO_MANIFEST_DIR").unwrap(),
                e
            );
            return (quote! {
                #[allow(clippy::needless_update)]
                static #name: git_testament::GitTestament<'static> = git_testament::GitTestament {
                    commit: git_testament::CommitKind::NoRepository(#pkgver, #now),
                    .. git_testament::EMPTY_TESTAMENT
                };
            })
            .into();
        }
    };

    // Second simple preliminary step: attempt to get a branch name to report
    let branch_name = match branch_name(&git_dir) {
        Ok(Some(name)) => quote! {Some(#name)},
        Ok(None) => quote! {None},
        Err(e) => {
            warn!("Unable to determine branch name: {}", e);
            quote! {None}
        }
    };

    // Step one, determine the current commit ID and the date of that commit
    let (commit_id, commit_date) = {
        let (commit, commit_time, commit_offset) = match revparse_single(&git_dir, "HEAD") {
            Ok(commit_data) => commit_data,
            Err(e) => {
                warn!("No commit at HEAD: {}", e);
                return (quote! {
                #[allow(clippy::needless_update)]
                static #name: git_testament::GitTestament<'static> = git_testament::GitTestament {
                    commit: git_testament::CommitKind::NoCommit(#pkgver, #now),
                    branch_name: #branch_name,
                    .. git_testament::EMPTY_TESTAMENT
                };
            })
            .into();
            }
        };

        // Acquire the commit info

        let commit_id = commit;
        let naive = NaiveDateTime::from_timestamp(commit_time, 0);
        let offset = FixedOffset::east(commit_offset * 60);
        let commit_time = DateTime::<FixedOffset>::from_utc(naive, offset);
        let commit_date = format!("{}", commit_time.format("%Y-%m-%d"));

        (commit_id, commit_date)
    };

    // Next determine if there was a tag, and if so, what our relationship
    // to that tag is...

    let (tag, steps) = match describe(&git_dir, &commit_id) {
        Ok(res) => {
            let res = &res[..res.rfind('-').expect("No commit info in describe!")];
            let tag_name = &res[..res.rfind('-').expect("No commit count in describe!")];
            let commit_count = res[tag_name.len() + 1..]
                .parse::<usize>()
                .expect("Unable to parse commit count in describe!");

            (tag_name.to_owned(), commit_count)
        }
        Err(_) => {
            warn!("No tag info found!");
            ("".to_owned(), 0)
        }
    };

    let commit = if !tag.is_empty() {
        // We've a tag
        quote! {
            git_testament::CommitKind::FromTag(#tag, #commit_id, #commit_date, #steps)
        }
    } else {
        quote! {
            git_testament::CommitKind::NoTags(#commit_id, #commit_date)
        }
    };

    // Finally, we need to gather the modifications to the tree...
    let statuses: Vec<_> = status(&git_dir)
        .expect("Unable to generate status information for working tree!")
        .into_iter()
        .map(|status| {
            let path = status.path.into_bytes();
            match status.status {
                Untracked => quote! {
                    git_testament::GitModification::Untracked(&[#(#path),*])
                },
                Added => quote! {
                    git_testament::GitModification::Added(&[#(#path),*])
                },
                Modified => quote! {
                    git_testament::GitModification::Modified(&[#(#path),*])
                },
                Deleted => quote! {
                    git_testament::GitModification::Removed(&[#(#path),*])
                },
            }
        })
        .collect();

    (quote! {
        #[allow(clippy::needless_update)]
        static #name: git_testament::GitTestament<'static> = git_testament::GitTestament {
            commit: #commit,
            modifications: &[#(#statuses),*],
            branch_name: #branch_name,
            .. git_testament::EMPTY_TESTAMENT
        };
    })
    .into()
}