• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kitplummer / goa / 22193283550

19 Feb 2026 05:47PM UTC coverage: 50.682%. First build
22193283550

Pull #149

github

web-flow
Merge 86bd46324 into c4b36a132
Pull Request #149: feat: structured tracing, retry with backoff, test fixture decoupling

186 of 254 new or added lines in 6 files covered. (73.23%)

855 of 1687 relevant lines covered (50.68%)

4.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.89
/src/git.rs
1
/*
2
 * libgit2 "pull" example - shows how to pull remote data into a local branch.
3
 *
4
 * Written by the libgit2 contributors
5
 *
6
 * To the extent possible under law, the author(s) have dedicated all copyright
7
 * and related and neighboring rights to this software to the public domain
8
 * worldwide. This software is distributed without any warranty.
9
 *
10
 * You should have received a copy of the CC0 Public Domain Dedication along
11
 * with this software. If not, see
12
 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13
 */
14

15
use chrono::{DateTime, Utc};
16
use git2::{
17
    AutotagOption, Commit, Diff, DiffStatsFormat, FetchOptions, Object, ObjectType,
18
    RemoteCallbacks, RemoteUpdateFlags, Repository,
19
};
20
use std::collections::HashMap;
21
use std::io::Write;
22
use std::str;
23
use tracing::{error, warn};
24

25
/// Metadata extracted from a git commit, to be passed to child processes as env vars.
26
/// This avoids global env var mutation which is not thread-safe.
27
#[derive(Debug, Clone, Default)]
28
pub struct CommitMetadata {
29
    pub id: String,
30
    pub author: String,
31
    pub message: String,
32
    pub time: String,
33
}
34

35
/// Sanitize a string for safe use as an environment variable value.
36
/// Strips control characters (null bytes, tabs, carriage returns) and
37
/// truncates to a maximum length to prevent abuse via crafted commit metadata.
38
pub fn sanitize_env_value(value: &str) -> String {
26✔
39
    const MAX_ENV_VALUE_LEN: usize = 4096;
40
    value
26✔
41
        .chars()
26✔
42
        .filter(|c| !c.is_control() || *c == '\n')
4,411✔
43
        .take(MAX_ENV_VALUE_LEN)
26✔
44
        .collect()
26✔
45
}
26✔
46

47
impl CommitMetadata {
48
    /// Convert to a HashMap suitable for passing to child process environment.
49
    /// Values are sanitized to strip control characters and enforce length limits.
50
    pub fn to_env_vars(&self) -> HashMap<String, String> {
3✔
51
        let mut vars = HashMap::new();
3✔
52
        vars.insert("GOA_LAST_COMMIT_ID".to_string(), sanitize_env_value(&self.id));
3✔
53
        vars.insert("GOA_LAST_COMMIT_AUTHOR".to_string(), sanitize_env_value(&self.author));
3✔
54
        vars.insert("GOA_LAST_COMMIT_MESSAGE".to_string(), sanitize_env_value(&self.message));
3✔
55
        vars.insert("GOA_LAST_COMMIT_TIME".to_string(), sanitize_env_value(&self.time));
3✔
56
        vars
3✔
57
    }
3✔
58
}
59

60
/// Result of a diff check, containing both the annotated commit and the diff itself.
61
pub struct DiffResult<'a> {
62
    pub commit: git2::AnnotatedCommit<'a>,
63
    pub diff: git2::Diff<'a>,
64
}
65

66
pub fn is_diff<'a>(
1✔
67
    repo: &'a git2::Repository,
1✔
68
    remote_name: &str,
1✔
69
    branch_name: &str,
1✔
70
    verbosity: u8,
1✔
71
) -> Result<DiffResult<'a>, git2::Error> {
1✔
72
    let mut cb = RemoteCallbacks::new();
1✔
73
    let mut remote = repo
1✔
74
        .find_remote(remote_name)
1✔
75
        .or_else(|_| repo.remote_anonymous(remote_name))?;
1✔
76

77
    cb.sideband_progress(|data| {
1✔
78
        if verbosity >= 2 {
×
79
            let dt = Utc::now();
×
80
            if let Ok(msg) = std::str::from_utf8(data) {
×
81
                print!("goa [{}]: remote: {}", dt, msg);
×
82
            }
×
83
        }
×
84
        let _ = std::io::stdout().flush();
×
85
        true
×
86
    });
×
87

88
    let mut fo = FetchOptions::new();
1✔
89
    fo.remote_callbacks(cb);
1✔
90
    remote.download(&[] as &[&str], Some(&mut fo))?;
1✔
91

92
    // Disconnect the underlying connection to prevent from idling.
93
    remote.disconnect()?;
1✔
94

95
    // Update the references in the remote's namespace to point to the right
96
    // commits. This may be needed even if there was no packfile to download,
97
    // which can happen e.g. when the branches have been changed but all the
98
    // needed objects are available locally.
99
    remote.update_tips(
1✔
100
        None,
1✔
101
        RemoteUpdateFlags::UPDATE_FETCHHEAD,
102
        AutotagOption::Unspecified,
1✔
103
        None,
1✔
104
    )?;
×
105

106
    let l = String::from(branch_name);
1✔
107
    let r = format!("{}/{}", remote_name, branch_name);
1✔
108
    let tl = tree_to_treeish(repo, Some(&l))?;
1✔
109
    let tr = tree_to_treeish(repo, Some(&r))?;
×
110

111
    let head = repo.head()?;
×
112
    let oid = head
×
113
        .target()
×
114
        .ok_or_else(|| git2::Error::from_str("HEAD has no target"))?;
×
115
    let commit = repo.find_commit(oid)?;
×
116

117
    let _branch = repo.branch(branch_name, &commit, false);
×
118

119
    let obj = repo.revparse_single(&("refs/heads/".to_owned() + branch_name))?;
×
120

121
    repo.checkout_tree(&obj, None)?;
×
122

123
    repo.set_head(&("refs/heads/".to_owned() + branch_name))?;
×
124

125
    let diff = match (tl, tr) {
×
126
        (Some(local), Some(origin)) => {
×
127
            repo.diff_tree_to_tree(local.as_tree(), origin.as_tree(), None)?
×
128
        }
129
        (_, _) => return Err(git2::Error::from_str("Could not resolve local or remote tree")),
×
130
    };
131

132
    if diff.deltas().len() > 0 {
×
133
        if verbosity >= 2 {
×
134
            if let Err(e) = display_stats(&diff) {
×
NEW
135
                warn!("unable to print diff stats: {}", e);
×
136
            }
×
137
        }
×
138
        let fetch_head = repo.find_reference("FETCH_HEAD")?;
×
139
        let commit = repo.reference_to_annotated_commit(&fetch_head)?;
×
140
        Ok(DiffResult { commit, diff })
×
141
    } else {
142
        let msg = "no diffs, back to sleep.";
×
143
        Err(git2::Error::from_str(msg))
×
144
    }
145
}
1✔
146

147
/// Get commit metadata for the last commit on a branch.
148
/// Returns CommitMetadata to be passed to child processes.
149
pub fn get_last_commit_metadata(
×
150
    repo: &git2::Repository,
×
151
    branch_name: &str,
×
152
    verbosity: u8,
×
153
) -> Result<CommitMetadata, git2::Error> {
×
154
    let commit = find_last_commit_on_branch(repo, branch_name)?;
×
155
    Ok(extract_commit_metadata(&commit, verbosity))
×
156
}
×
157

158
pub fn tree_to_treeish<'a>(
1✔
159
    repo: &'a Repository,
1✔
160
    arg: Option<&String>,
1✔
161
) -> Result<Option<Object<'a>>, git2::Error> {
1✔
162
    let arg = match arg {
1✔
163
        Some(s) => s,
1✔
164
        None => return Ok(None),
×
165
    };
166
    let obj = repo.revparse_single(arg).map_err(|e| {
1✔
167
        git2::Error::from_str(&format!("branch '{}' not found: {}", arg, e))
1✔
168
    })?;
1✔
169
    let tree = obj.peel(ObjectType::Tree)?;
×
170
    Ok(Some(tree))
×
171
}
1✔
172

173
fn display_stats(diff: &Diff) -> Result<(), git2::Error> {
×
174
    let stats = diff.stats()?;
×
175
    let format = DiffStatsFormat::FULL;
×
176
    let buf = stats.to_buf(format, 80)?;
×
177
    let dt = Utc::now();
×
178
    if let Ok(s) = std::str::from_utf8(&buf) {
×
179
        print!("goa [{}]: {}", dt, s);
×
180
    }
×
181
    Ok(())
×
182
}
×
183

184
fn find_last_commit_on_branch<'a>(
×
185
    repo: &'a Repository,
×
186
    branch_name: &str,
×
187
) -> Result<Commit<'a>, git2::Error> {
×
188
    let (object, reference) = repo.revparse_ext(branch_name)?;
×
189

190
    repo.checkout_tree(&object, None)?;
×
191

192
    match reference {
×
193
        // gref is an actual reference like branches or tags
194
        Some(gref) => {
×
195
            let name = gref
×
196
                .name()
×
197
                .ok_or_else(|| git2::Error::from_str("Reference has no name"))?;
×
198
            repo.set_head(name)?;
×
199
        }
200
        // this is a commit, not a reference
201
        None => repo.set_head_detached(object.id())?,
×
202
    }
203

204
    let obj = repo.head()?.resolve()?.peel(ObjectType::Commit)?;
×
205
    obj.into_commit()
×
206
        .map_err(|_| git2::Error::from_str("Couldn't find commit"))
×
207
}
×
208

209
fn find_last_commit(repo: &Repository) -> Result<Commit<'_>, git2::Error> {
×
210
    let obj = repo.head()?.resolve()?.peel(ObjectType::Commit)?;
×
211
    obj.into_commit()
×
212
        .map_err(|_| git2::Error::from_str("Couldn't find commit"))
×
213
}
×
214

215
/// Extract metadata from a commit. Prints commit info if verbosity > 0.
216
/// Returns CommitMetadata instead of setting global env vars (thread-safe).
217
fn extract_commit_metadata(commit: &Commit, verbosity: u8) -> CommitMetadata {
×
218
    let timestamp = commit.time().seconds();
×
219
    let tm = DateTime::from_timestamp(timestamp, 0).unwrap_or_else(Utc::now);
×
220

221
    if verbosity > 0 {
×
222
        let dt = Utc::now();
×
223
        println!(
×
224
            "goa [{}]: commit {}\nAuthor: {}\nDate:   {}\n\n    {}",
×
225
            dt,
×
226
            commit.id(),
×
227
            commit.author(),
×
228
            tm,
×
229
            commit.message().unwrap_or("no commit message")
×
230
        );
×
231
    }
×
232

233
    CommitMetadata {
×
234
        id: commit.id().to_string(),
×
235
        author: commit.author().to_string(),
×
236
        message: commit.message().unwrap_or("").to_string(),
×
237
        time: tm.to_string(),
×
238
    }
×
239
}
×
240

241
fn fast_forward(
×
242
    repo: &Repository,
×
243
    lb: &mut git2::Reference,
×
244
    rc: &git2::AnnotatedCommit,
×
245
) -> Result<(), git2::Error> {
×
246
    let name = match lb.name() {
×
247
        Some(s) => s.to_string(),
×
248
        None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
×
249
    };
250
    let msg = format!("Fast-Forward: Setting {} to id: {}", name, rc.id());
×
251
    lb.set_target(rc.id(), &msg)?;
×
252
    repo.set_head(&name)?;
×
253
    repo.checkout_head(Some(
×
254
        git2::build::CheckoutBuilder::default()
×
255
            // For some reason the force is required to make the working directory actually get updated
×
256
            // I suspect we should be adding some logic to handle dirty working directory states
×
257
            // but this is just an example so maybe not.
×
258
            .force(),
×
259
    ))?;
×
260
    Ok(())
×
261
}
×
262

263
fn normal_merge(
×
264
    repo: &Repository,
×
265
    local: &git2::AnnotatedCommit,
×
266
    remote: &git2::AnnotatedCommit,
×
267
) -> Result<(), git2::Error> {
×
268
    let local_tree = repo.find_commit(local.id())?.tree()?;
×
269
    let remote_tree = repo.find_commit(remote.id())?.tree()?;
×
270
    let ancestor = repo
×
271
        .find_commit(repo.merge_base(local.id(), remote.id())?)?
×
272
        .tree()?;
×
273
    let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
×
274

275
    if idx.has_conflicts() {
×
NEW
276
        error!("merge conflicts detected");
×
277
        repo.checkout_index(Some(&mut idx), None)?;
×
278
        return Ok(());
×
279
    }
×
280
    let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
×
281
    // now create the merge commit
282
    let msg = format!("Merge: {} into {}", remote.id(), local.id());
×
283
    let sig = repo.signature()?;
×
284
    let local_commit = repo.find_commit(local.id())?;
×
285
    let remote_commit = repo.find_commit(remote.id())?;
×
286
    // Do our merge commit and set current branch head to that commit.
287
    let _merge_commit = repo.commit(
×
288
        Some("HEAD"),
×
289
        &sig,
×
290
        &sig,
×
291
        &msg,
×
292
        &result_tree,
×
293
        &[&local_commit, &remote_commit],
×
294
    )?;
×
295
    // Set working tree to match head.
296
    repo.checkout_head(None)?;
×
297
    Ok(())
×
298
}
×
299

300
/// Perform a merge and return the commit metadata for the resulting commit.
301
pub fn do_merge<'a>(
×
302
    repo: &'a Repository,
×
303
    remote_branch: &str,
×
304
    fetch_commit: git2::AnnotatedCommit<'a>,
×
305
    verbosity: u8,
×
306
) -> Result<CommitMetadata, git2::Error> {
×
307
    // 1. do a merge analysis
308
    let analysis = repo.merge_analysis(&[&fetch_commit])?;
×
309

310
    // 2. Do the appopriate merge
311
    if analysis.0.is_fast_forward() {
×
312
        // do a fast forward
313
        let refname = format!("refs/heads/{}", remote_branch);
×
314
        match repo.find_reference(&refname) {
×
315
            Ok(mut r) => {
×
316
                fast_forward(repo, &mut r, &fetch_commit)?;
×
317
            }
318
            Err(_) => {
319
                // The branch doesn't exist so just set the reference to the
320
                // commit directly. Usually this is because you are pulling
321
                // into an empty repository.
322
                repo.reference(
×
323
                    &refname,
×
324
                    fetch_commit.id(),
×
325
                    true,
326
                    &format!("Setting {} to {}", remote_branch, fetch_commit.id()),
×
327
                )?;
×
328
                repo.set_head(&refname)?;
×
329
                repo.checkout_head(Some(
×
330
                    git2::build::CheckoutBuilder::default()
×
331
                        .allow_conflicts(true)
×
332
                        .conflict_style_merge(true)
×
333
                        .force(),
×
334
                ))?;
×
335
            }
336
        };
337
        let commit = find_last_commit(repo)?;
×
338
        Ok(extract_commit_metadata(&commit, verbosity))
×
339
    } else if analysis.0.is_normal() {
×
340
        // do a normal merge
341
        let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
×
342
        normal_merge(repo, &head_commit, &fetch_commit)?;
×
343
        let commit = find_last_commit(repo)?;
×
344
        Ok(extract_commit_metadata(&commit, verbosity))
×
345
    } else {
NEW
346
        warn!("merge analysis: nothing to do");
×
347
        Ok(CommitMetadata::default())
×
348
    }
349
}
×
350

351
#[cfg(test)]
352
mod tests {
353
    use super::*;
354

355
    #[test]
356
    fn test_commit_metadata_default() {
1✔
357
        let metadata = CommitMetadata::default();
1✔
358
        assert_eq!(metadata.id, "");
1✔
359
        assert_eq!(metadata.author, "");
1✔
360
        assert_eq!(metadata.message, "");
1✔
361
        assert_eq!(metadata.time, "");
1✔
362
    }
1✔
363

364
    #[test]
365
    fn test_commit_metadata_to_env_vars() {
1✔
366
        let metadata = CommitMetadata {
1✔
367
            id: "abc123def456".to_string(),
1✔
368
            author: "Test Author <test@example.com>".to_string(),
1✔
369
            message: "Fix bug in feature".to_string(),
1✔
370
            time: "2024-01-15 10:30:00 UTC".to_string(),
1✔
371
        };
1✔
372

373
        let vars = metadata.to_env_vars();
1✔
374

375
        assert_eq!(vars.len(), 4);
1✔
376
        assert_eq!(
1✔
377
            vars.get("GOA_LAST_COMMIT_ID"),
1✔
378
            Some(&"abc123def456".to_string())
1✔
379
        );
380
        assert_eq!(
1✔
381
            vars.get("GOA_LAST_COMMIT_AUTHOR"),
1✔
382
            Some(&"Test Author <test@example.com>".to_string())
1✔
383
        );
384
        assert_eq!(
1✔
385
            vars.get("GOA_LAST_COMMIT_MESSAGE"),
1✔
386
            Some(&"Fix bug in feature".to_string())
1✔
387
        );
388
        assert_eq!(
1✔
389
            vars.get("GOA_LAST_COMMIT_TIME"),
1✔
390
            Some(&"2024-01-15 10:30:00 UTC".to_string())
1✔
391
        );
392
    }
1✔
393

394
    #[test]
395
    fn test_commit_metadata_clone() {
1✔
396
        let original = CommitMetadata {
1✔
397
            id: "abc123".to_string(),
1✔
398
            author: "Author".to_string(),
1✔
399
            message: "Message".to_string(),
1✔
400
            time: "Time".to_string(),
1✔
401
        };
1✔
402

403
        let cloned = original.clone();
1✔
404

405
        assert_eq!(cloned.id, original.id);
1✔
406
        assert_eq!(cloned.author, original.author);
1✔
407
        assert_eq!(cloned.message, original.message);
1✔
408
        assert_eq!(cloned.time, original.time);
1✔
409
    }
1✔
410

411
    #[test]
412
    fn test_commit_metadata_empty_values() {
1✔
413
        let metadata = CommitMetadata {
1✔
414
            id: "".to_string(),
1✔
415
            author: "".to_string(),
1✔
416
            message: "".to_string(),
1✔
417
            time: "".to_string(),
1✔
418
        };
1✔
419

420
        let vars = metadata.to_env_vars();
1✔
421

422
        // Even empty values should be present in the env vars
423
        assert_eq!(vars.len(), 4);
1✔
424
        assert_eq!(vars.get("GOA_LAST_COMMIT_ID"), Some(&"".to_string()));
1✔
425
        assert_eq!(vars.get("GOA_LAST_COMMIT_AUTHOR"), Some(&"".to_string()));
1✔
426
        assert_eq!(vars.get("GOA_LAST_COMMIT_MESSAGE"), Some(&"".to_string()));
1✔
427
        assert_eq!(vars.get("GOA_LAST_COMMIT_TIME"), Some(&"".to_string()));
1✔
428
    }
1✔
429

430
    #[test]
431
    fn test_commit_metadata_special_chars() {
1✔
432
        let metadata = CommitMetadata {
1✔
433
            id: "abc123".to_string(),
1✔
434
            author: "Author Name <author@example.com>".to_string(),
1✔
435
            message: "Fix: handle \"special\" chars & newlines\nLine 2".to_string(),
1✔
436
            time: "2024-01-15T10:30:00+00:00".to_string(),
1✔
437
        };
1✔
438

439
        let vars = metadata.to_env_vars();
1✔
440

441
        // Newlines are preserved, quotes and ampersands pass through
442
        assert_eq!(
1✔
443
            vars.get("GOA_LAST_COMMIT_MESSAGE"),
1✔
444
            Some(&"Fix: handle \"special\" chars & newlines\nLine 2".to_string())
1✔
445
        );
446
    }
1✔
447

448
    #[test]
449
    fn test_sanitize_env_value_strips_control_chars() {
1✔
450
        // Null bytes, tabs, carriage returns should be stripped
451
        assert_eq!(sanitize_env_value("hello\0world"), "helloworld");
1✔
452
        assert_eq!(sanitize_env_value("hello\tworld"), "helloworld");
1✔
453
        assert_eq!(sanitize_env_value("hello\r\nworld"), "hello\nworld");
1✔
454
        // Newlines are preserved
455
        assert_eq!(sanitize_env_value("line1\nline2"), "line1\nline2");
1✔
456
        // Normal text passes through
457
        assert_eq!(sanitize_env_value("normal text"), "normal text");
1✔
458
    }
1✔
459

460
    #[test]
461
    fn test_sanitize_env_value_truncates() {
1✔
462
        let long_string = "a".repeat(5000);
1✔
463
        let sanitized = sanitize_env_value(&long_string);
1✔
464
        assert_eq!(sanitized.len(), 4096);
1✔
465
    }
1✔
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc