ver-shim-build 0.2.1

Deprecated: renamed to ver-stub-build
Documentation
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
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
//! Build script helper for injecting version data into binaries.
//!
//! This crate provides utilities for use in `build.rs` scripts to inject
//! git version information into artifact dependency binaries:
//!
//! - Git SHA (`git rev-parse HEAD`)
//! - Git describe (`git describe --always --dirty`)
//! - Git branch (`git rev-parse --abbrev-ref HEAD`)
//!
//! # Requirements
//!
//! This crate requires Cargo's unstable [artifact dependencies] feature (bindeps).
//! You must use nightly Cargo and enable it in `.cargo/config.toml`:
//!
//! ```toml
//! [unstable]
//! bindeps = true
//! ```
//!
//! [artifact dependencies]: https://doc.rust-lang.org/cargo/reference/unstable.html#artifact-dependencies
//!
//! # Example
//!
//! In your `build.rs`:
//! ```ignore
//! use ver_shim_build::LinkSection;
//!
//! fn main() {
//!     // Patch an artifact dependency binary (uses CARGO_BIN_FILE_* env vars)
//!     LinkSection::new()
//!         .with_all_git()
//!         .patch_into_bin_dep("my-dep", "my-bin")
//!         .write_to_target_profile_dir();
//!
//!     // Or patch a binary at a specific path
//!     LinkSection::new()
//!         .with_all_git()
//!         .patch_into("/path/to/binary")
//!         .write_to_target_profile_dir();
//!
//!     // Or with a custom output name
//!     LinkSection::new()
//!         .with_all_git()
//!         .patch_into_bin_dep("my-dep", "my-bin")
//!         .with_filename("my-custom-name")
//!         .write_to_target_profile_dir();
//!
//!     // Or just write the section data file (for use with cargo-objcopy)
//!     LinkSection::new()
//!         .with_all_git()
//!         .write_to_out_dir();
//! }
//! ```

/// Cargo build script helper functions.
mod cargo_helpers;

/// LLVM tools wrapper for section manipulation.
mod llvm_tools;

/// Helper to find LLVM tools, based on code in cargo-binutils.
mod rustc;

/// Update section command for patching artifact dependency binaries.
mod update_section;

pub use llvm_tools::LlvmTools;
pub use update_section::UpdateSectionCommand;

use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use ver_shim::{BUFFER_SIZE, Member, header_size};

use cargo_helpers::{cargo_rerun_if, cargo_warning};

/// Builder for configuring which git information to include in version sections.
///
/// Use this to select which git info to collect, then either:
/// - Call `write_to()` or `write_to_out_dir()` to just write the section data file
/// - Call `patch_into()` to get an `UpdateSectionCommand` for patching a binary
#[derive(Default)]
#[must_use]
pub struct LinkSection {
    include_git_sha: bool,
    include_git_describe: bool,
    include_git_branch: bool,
    include_git_commit_timestamp: bool,
    include_git_commit_date: bool,
    include_git_commit_msg: bool,
    include_build_timestamp: bool,
    include_build_date: bool,
    fail_on_error: bool,
    custom: Option<String>,
    buffer_size: Option<usize>,
}

impl LinkSection {
    /// Creates a new empty `LinkSection`
    pub fn new() -> Self {
        Self::default()
    }

    /// Includes the git SHA (`git rev-parse HEAD`) in the section data.
    pub fn with_git_sha(mut self) -> Self {
        self.include_git_sha = true;
        self
    }

    /// Includes the git describe output (`git describe --always --dirty`) in the section data.
    pub fn with_git_describe(mut self) -> Self {
        self.include_git_describe = true;
        self
    }

    /// Includes the git branch name (`git rev-parse --abbrev-ref HEAD`) in the section data.
    pub fn with_git_branch(mut self) -> Self {
        self.include_git_branch = true;
        self
    }

    /// Includes the git commit timestamp (RFC 3339 format) in the section data.
    pub fn with_git_commit_timestamp(mut self) -> Self {
        self.include_git_commit_timestamp = true;
        self
    }

    /// Includes the git commit date (YYYY-MM-DD format) in the section data.
    pub fn with_git_commit_date(mut self) -> Self {
        self.include_git_commit_date = true;
        self
    }

    /// Includes the git commit message (first line, max 100 chars) in the section data.
    pub fn with_git_commit_msg(mut self) -> Self {
        self.include_git_commit_msg = true;
        self
    }

    /// Includes all git information in the section data.
    pub fn with_all_git(mut self) -> Self {
        self.include_git_sha = true;
        self.include_git_describe = true;
        self.include_git_branch = true;
        self.include_git_commit_timestamp = true;
        self.include_git_commit_date = true;
        self.include_git_commit_msg = true;
        self
    }

    /// Includes the build timestamp (RFC 3339 format, UTC) in the section data.
    pub fn with_build_timestamp(mut self) -> Self {
        self.include_build_timestamp = true;
        self
    }

    /// Includes the build date (YYYY-MM-DD format, UTC) in the section data.
    pub fn with_build_date(mut self) -> Self {
        self.include_build_date = true;
        self
    }

    /// Includes all build time information (timestamp and date) in the section data.
    pub fn with_all_build_time(mut self) -> Self {
        self.include_build_timestamp = true;
        self.include_build_date = true;
        self
    }

    /// Enables fail-on-error mode.
    ///
    /// By default, if git commands fail (e.g., `git` not found, not in a git repository,
    /// building from a source tarball without `.git`), a `cargo:warning` is emitted and
    /// the corresponding data is skipped. This allows builds to succeed even without git.
    ///
    /// When `fail_on_error()` is called, git failures will instead cause a panic,
    /// failing the build.
    pub fn fail_on_error(mut self) -> Self {
        self.fail_on_error = true;
        self
    }

    /// Sets a custom application-specific string to embed in the binary.
    ///
    /// This can be any string your application wants to store. The total size of all
    /// data (including git info, timestamps, and custom string) must fit within the
    /// buffer size (default 512 bytes). If you need more space, set the
    /// `VER_SHIM_BUFFER_SIZE` environment variable when building.
    ///
    /// As with any build script, you must emit `cargo:rerun-if-...` directives as
    /// needed if you read files or environment variables to build your custom string.
    ///
    /// Access this at runtime with `ver_shim::custom()`.
    pub fn with_custom(mut self, s: impl Into<String>) -> Self {
        self.custom = Some(s.into());
        self
    }

    /// Sets the buffer size for the section data.
    ///
    /// This should match the buffer size used when building the target binary.
    /// If not set, falls back to:
    /// 1. `VER_SHIM_BUFFER_SIZE` environment variable (at runtime)
    /// 2. The `BUFFER_SIZE` constant from ver-shim (default 512)
    pub fn with_buffer_size(mut self, size: usize) -> Self {
        self.buffer_size = Some(size);
        self
    }

    /// Gets the effective buffer size to use.
    fn effective_buffer_size(&self) -> usize {
        self.buffer_size
            .or_else(|| {
                std::env::var("VER_SHIM_BUFFER_SIZE")
                    .ok()
                    .and_then(|s| s.parse().ok())
            })
            .unwrap_or(BUFFER_SIZE)
    }

    /// Builds the section data as bytes.
    ///
    /// This collects all enabled version info and builds the binary section data.
    /// Does not write to any file.
    pub fn build_section_bytes(self) -> Vec<u8> {
        self.check_enabled();

        // Emit rerun-if-changed directives for git state (only if git data requested)
        if self.any_git_enabled() {
            emit_git_rerun_if_changed();
        }

        // Collect the data for each member
        let mut member_data: [Option<String>; Member::COUNT] = Default::default();

        if self.include_git_sha
            && let Some(git_sha) = get_git_sha(self.fail_on_error)
        {
            eprintln!("ver-shim-build: git SHA = {}", git_sha);
            member_data[Member::GitSha as usize] = Some(git_sha);
        }

        if self.include_git_describe
            && let Some(git_describe) = get_git_describe(self.fail_on_error)
        {
            eprintln!("ver-shim-build: git describe = {}", git_describe);
            member_data[Member::GitDescribe as usize] = Some(git_describe);
        }

        if self.include_git_branch
            && let Some(git_branch) = get_git_branch(self.fail_on_error)
        {
            eprintln!("ver-shim-build: git branch = {}", git_branch);
            member_data[Member::GitBranch as usize] = Some(git_branch);
        }

        if (self.include_git_commit_timestamp || self.include_git_commit_date)
            && let Some(timestamp) = get_git_commit_timestamp(self.fail_on_error)
        {
            if self.include_git_commit_timestamp {
                let rfc3339 = timestamp.to_rfc3339();
                eprintln!("ver-shim-build: git commit timestamp = {}", rfc3339);
                member_data[Member::GitCommitTimestamp as usize] = Some(rfc3339);
            }
            if self.include_git_commit_date {
                let date = timestamp.date_naive().to_string();
                eprintln!("ver-shim-build: git commit date = {}", date);
                member_data[Member::GitCommitDate as usize] = Some(date);
            }
        }

        if self.include_git_commit_msg
            && let Some(msg) = get_git_commit_msg(self.fail_on_error)
        {
            eprintln!("ver-shim-build: git commit msg = {}", msg);
            member_data[Member::GitCommitMsg as usize] = Some(msg);
        }

        if self.any_build_time_enabled() {
            // Emit rerun-if-env-changed for reproducible build options
            cargo_rerun_if("env-changed=VER_SHIM_IDEMPOTENT");
            cargo_rerun_if("env-changed=VER_SHIM_BUILD_TIME");

            // VER_SHIM_IDEMPOTENT takes precedence: if set, never include build time
            if std::env::var("VER_SHIM_IDEMPOTENT").is_ok() {
                eprintln!("ver-shim-build: VER_SHIM_IDEMPOTENT is set, skipping build timestamp/date");
            } else {
                let build_time = get_build_time();
                if self.include_build_timestamp {
                    let rfc3339 = build_time.to_rfc3339();
                    eprintln!("ver-shim-build: build timestamp = {}", rfc3339);
                    member_data[Member::BuildTimestamp as usize] = Some(rfc3339);
                }
                if self.include_build_date {
                    let date = build_time.date_naive().to_string();
                    eprintln!("ver-shim-build: build date = {}", date);
                    member_data[Member::BuildDate as usize] = Some(date);
                }
            }
        }

        if let Some(ref custom) = self.custom {
            eprintln!("ver-shim-build: custom = {}", custom);
            member_data[Member::Custom as usize] = Some(custom.clone());
        }

        // Build the section buffer
        let buffer_size = self.effective_buffer_size();
        build_section_buffer(&member_data, buffer_size)
    }
    /// Writes the section data file to the specified path.
    ///
    /// If the path is a directory, writes to `{path}/ver_shim_data`.
    /// Otherwise writes directly to the path.
    ///
    /// This is useful for `cargo objcopy` workflows where you want to manually
    /// run objcopy with the generated section file.
    ///
    /// Returns the path to the written file.
    pub fn write_to(self, path: impl AsRef<Path>) -> PathBuf {
        self.write_section_to_path(path.as_ref())
    }

    /// Writes the section data file to `OUT_DIR/ver_shim_data`.
    ///
    /// This is a convenience method for use in build scripts.
    ///
    /// Returns the path to the written file.
    pub fn write_to_out_dir(self) -> PathBuf {
        let out_dir = cargo_helpers::out_dir();
        self.write_section_to_path(&out_dir)
    }

    /// Writes the section data file to the `target/` directory.
    /// Returns the path to the written file (e.g., `target/ver_shim_data`).
    ///
    /// This is useful for `cargo objcopy` workflows where you want to run:
    /// ```bash
    /// cargo objcopy --release --bin my_bin -- --update-section .ver_shim_data=target/ver_shim_data my_bin.bin
    /// ```
    ///
    /// The target directory is determined by checking `CARGO_TARGET_DIR` first,
    /// then inferring from `OUT_DIR`. The result should typically be `target/ver_shim_data`.
    ///
    /// When cross-compiling, it might end up in `target/<triple>/ver_shim_data`, due to
    /// how the inference works.
    ///
    /// To adjust this, you can set `CARGO_TARGET_DIR` in `.cargo/config.toml`:
    /// ```toml
    /// [env]
    /// CARGO_TARGET_DIR = { value = "target", relative = true }
    /// ```
    pub fn write_to_target_dir(self) -> PathBuf {
        let target_dir = cargo_helpers::target_dir();
        self.write_section_to_path(&target_dir)
    }

    /// Transitions to an `UpdateSectionCommand` for patching a binary at the given path.
    ///
    /// # Arguments
    /// * `binary_path` - Path to the binary to patch
    pub fn patch_into(self, binary_path: impl AsRef<Path>) -> UpdateSectionCommand {
        UpdateSectionCommand {
            link_section: self,
            bin_path: binary_path.as_ref().to_path_buf(),
            new_name: None,
        }
    }

    /// Transitions to an `UpdateSectionCommand` for patching an artifact dependency binary.
    ///
    /// This is a convenience method for use with Cargo's artifact dependencies feature.
    /// It finds the binary using the `CARGO_BIN_FILE_<DEP>_<NAME>` environment variables
    /// that Cargo sets for artifact dependencies.
    ///
    /// # Arguments
    /// * `dep_name` - The name of the dependency as specified in Cargo.toml
    /// * `bin_name` - The name of the binary within the dependency
    pub fn patch_into_bin_dep(self, dep_name: &str, bin_name: &str) -> UpdateSectionCommand {
        let bin_path = cargo_helpers::find_artifact_binary(dep_name, bin_name);
        self.patch_into(bin_path)
    }

    fn any_git_enabled(&self) -> bool {
        self.include_git_sha
            || self.include_git_describe
            || self.include_git_branch
            || self.include_git_commit_timestamp
            || self.include_git_commit_date
            || self.include_git_commit_msg
    }

    fn any_build_time_enabled(&self) -> bool {
        self.include_build_timestamp || self.include_build_date
    }

    fn check_enabled(&self) {
        if !self.any_git_enabled() && !self.any_build_time_enabled() && self.custom.is_none() {
            panic!(
                "ver-shim-build: no version info enabled. Call with_git_sha(), with_git_describe(), \
                 with_git_branch(), with_git_commit_timestamp(), with_git_commit_date(), \
                 with_git_commit_msg(), with_all_git(), with_build_timestamp(), with_build_date(), \
                 or with_custom() before writing."
            );
        }
    }

    pub(crate) fn write_section_to_path(self, path: &Path) -> PathBuf {
        let buffer = self.build_section_bytes();

        // Write to file - if path is a directory, append ver_shim_data
        let output_path = if path.is_dir() {
            path.join("ver_shim_data")
        } else {
            path.to_path_buf()
        };
        fs::write(&output_path, &buffer).expect("ver-shim-build: failed to write section file");

        output_path
    }
}

/// Builds the section buffer from member data.
///
/// Format:
/// - First byte: number of members (Member::COUNT) for forward compatibility
/// - Next `Member::COUNT * 2` bytes: header with end offsets (u16, little-endian, relative to header)
/// - Remaining bytes: concatenated string data
///
/// Header size = 1 + Member::COUNT * 2
///
/// For member N:
/// - start = header_size + end[N-1] if N > 0, else header_size
/// - end = header_size + end[N]
/// - If start == end, the member is not present.
///
/// Using relative offsets means a zero-initialized buffer reads as "all members absent".
/// The num_members byte enables forward compatibility: old sections can be read by new code.
fn build_section_buffer(member_data: &[Option<String>; Member::COUNT], buffer_size: usize) -> Vec<u8> {
    let mut buffer = vec![0u8; buffer_size];
    let header_sz = header_size(Member::COUNT);

    // First byte: number of members
    buffer[0] = Member::COUNT as u8;

    // Data starts after the header; track position relative to header_size
    let mut relative_offset: usize = 0;

    for (idx, data) in member_data.iter().enumerate() {
        if let Some(s) = data {
            let bytes = s.as_bytes();
            let absolute_start = header_sz + relative_offset;
            let absolute_end = absolute_start + bytes.len();

            if absolute_end > buffer_size {
                panic!(
                    "ver-shim-build: section data too large ({} bytes, max {}). \
                     Use with_buffer_size() or set VER_SHIM_BUFFER_SIZE env var to increase.",
                    absolute_end, buffer_size
                );
            }

            // Write the data
            buffer[absolute_start..absolute_end].copy_from_slice(bytes);

            relative_offset += bytes.len();
        }

        // Write the end offset for this member (relative to header_size)
        // If member is not present, end == previous end, so start == end indicates "not present"
        // Offset positions start at byte 1 (after the num_members byte)
        let header_offset = 1 + idx * 2;
        buffer[header_offset..header_offset + 2]
            .copy_from_slice(&(relative_offset as u16).to_le_bytes());
    }

    buffer
}

// ============================================================================
// Helper functions
// ============================================================================

/// Emits cargo rerun-if-changed directives for git state files.
/// This ensures the build script reruns when the git HEAD or refs change.
/// Matches vergen's behavior: watches .git/HEAD and .git/<ref_path>.
///
/// See: https://doc.rust-lang.org/cargo/reference/build-scripts.html#rerun-if-changed
fn emit_git_rerun_if_changed() {
    // Find the git directory
    let git_dir = match find_git_dir() {
        Some(dir) => dir,
        None => return,
    };

    // Always watch .git/HEAD
    let head_path = git_dir.join("HEAD");
    if head_path.exists() {
        cargo_rerun_if(&format!("changed={}", head_path.display()));

        // If HEAD points to a ref, also watch that ref file
        if let Ok(head_contents) = fs::read_to_string(&head_path) {
            let head_contents = head_contents.trim();
            if let Some(ref_path) = head_contents.strip_prefix("ref: ") {
                let ref_file = git_dir.join(ref_path);
                if ref_file.exists() {
                    cargo_rerun_if(&format!("changed={}", ref_file.display()));
                }
            }
        }
    }
}

/// Finds the .git directory by walking up from the current directory.
fn find_git_dir() -> Option<PathBuf> {
    let mut dir = std::env::current_dir().ok()?;
    loop {
        let git_dir = dir.join(".git");
        if git_dir.is_dir() {
            return Some(git_dir);
        }
        if !dir.pop() {
            return None;
        }
    }
}

/// Gets the current git SHA using `git rev-parse HEAD`.
fn get_git_sha(fail_on_error: bool) -> Option<String> {
    run_git_command(&["rev-parse", "HEAD"], fail_on_error)
}

/// Gets the git describe output using `git describe --always --dirty`.
fn get_git_describe(fail_on_error: bool) -> Option<String> {
    run_git_command(&["describe", "--always", "--dirty"], fail_on_error)
}

/// Gets the current git branch using `git rev-parse --abbrev-ref HEAD`.
fn get_git_branch(fail_on_error: bool) -> Option<String> {
    run_git_command(&["rev-parse", "--abbrev-ref", "HEAD"], fail_on_error)
}

/// Gets the git commit timestamp as a chrono DateTime.
fn get_git_commit_timestamp(fail_on_error: bool) -> Option<DateTime<FixedOffset>> {
    // Get the author date in ISO 8601 strict format
    let timestamp_str = run_git_command(&["log", "-1", "--format=%aI"], fail_on_error)?;
    match DateTime::parse_from_rfc3339(&timestamp_str) {
        Ok(dt) => Some(dt),
        Err(e) => {
            let msg = format!(
                "ver-shim-build: failed to parse git timestamp '{}': {}",
                timestamp_str, e
            );
            if fail_on_error {
                panic!("{}", msg);
            } else {
                cargo_warning(&msg);
                None
            }
        }
    }
}

/// Gets the first line of the git commit message, truncated to 100 chars.
fn get_git_commit_msg(fail_on_error: bool) -> Option<String> {
    let msg = run_git_command(&["log", "-1", "--format=%s"], fail_on_error)?;
    // Truncate to 100 chars to leave room in the buffer
    Some(if msg.len() > 100 {
        let mut end = 100;
        while !msg.is_char_boundary(end) && end > 0 {
            end -= 1;
        }
        msg[..end].to_string()
    } else {
        msg
    })
}

/// Gets the build time, either from VER_SHIM_BUILD_TIME env var or Utc::now().
///
/// If VER_SHIM_BUILD_TIME is set, it tries to parse it as:
/// 1. An integer (unix timestamp in seconds)
/// 2. An RFC 3339 datetime string
///
/// This supports reproducible builds by allowing a fixed build time.
fn get_build_time() -> DateTime<Utc> {
    if let Ok(val) = std::env::var("VER_SHIM_BUILD_TIME") {
        // Try parsing as unix timestamp (integer) first
        if let Ok(ts) = val.parse::<i64>() {
            let dt = Utc.timestamp_opt(ts, 0).single().unwrap_or_else(|| {
                panic!(
                    "ver-shim-build: VER_SHIM_BUILD_TIME '{}' is not a valid unix timestamp",
                    val
                )
            });
            eprintln!(
                "ver-shim-build: using VER_SHIM_BUILD_TIME={} (unix timestamp), overriding Utc::now()",
                val
            );
            return dt;
        }

        // Try parsing as RFC 3339
        if let Ok(dt) = DateTime::parse_from_rfc3339(&val) {
            eprintln!(
                "ver-shim-build: using VER_SHIM_BUILD_TIME={} (RFC 3339), overriding Utc::now()",
                val
            );
            return dt.with_timezone(&Utc);
        }

        panic!(
            "ver-shim-build: VER_SHIM_BUILD_TIME '{}' is not a valid unix timestamp or RFC 3339 datetime",
            val
        );
    }

    Utc::now()
}

/// Runs a git command and returns stdout as a trimmed string.
///
/// If `fail_on_error` is true, panics on failure. Otherwise, emits a cargo warning
/// and returns None, allowing builds to succeed without git.
fn run_git_command(args: &[&str], fail_on_error: bool) -> Option<String> {
    let cmd = format!("git {}", args.join(" "));
    let output = match Command::new("git").args(args).output() {
        Ok(output) => output,
        Err(e) => {
            let msg = format!("ver-shim-build: failed to execute '{}': {}", cmd, e);
            if fail_on_error {
                panic!("{}", msg);
            } else {
                cargo_warning(&msg);
                return None;
            }
        }
    };

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let msg = format!(
            "ver-shim-build: '{}' failed with status {}: {}",
            cmd,
            output.status,
            stderr.trim()
        );
        if fail_on_error {
            panic!("{}", msg);
        } else {
            cargo_warning(&msg);
            return None;
        }
    }

    match String::from_utf8(output.stdout) {
        Ok(s) => Some(s.trim().to_string()),
        Err(_) => {
            let msg = format!("ver-shim-build: '{}' output is not valid UTF-8", cmd);
            if fail_on_error {
                panic!("{}", msg);
            } else {
                cargo_warning(&msg);
                None
            }
        }
    }
}