girt 0.17.0

An incremental, idiomatic Rust implementation of Git
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
//! Pure configured refspec syntax, independent of mapping and transport policy.

use super::Direction;
use crate::refs::RefName;

/// The operation described by a configured refspec.
///
/// # Examples
///
/// ```
/// use girt::remote::{ConfiguredRefspec, ConfiguredRefspecKind as Kind, Direction};
///
/// let kind = |direction, text: &[u8]| ConfiguredRefspec::parse(direction, text).map(|s| s.kind());
/// assert_eq!(
///     kind(Direction::Fetch, b"+refs/heads/*:refs/remotes/origin/*")?,
///     Kind::Mapping
/// );
/// assert_eq!(kind(Direction::Fetch, b"main")?, Kind::Selection);
/// assert_eq!(
///     kind(Direction::Fetch, b"^refs/heads/wip/*")?,
///     Kind::Exclusion
/// );
/// assert_eq!(kind(Direction::Push, b":")?, Kind::AllMatching);
/// assert_eq!(kind(Direction::Push, b":refs/heads/old")?, Kind::Deletion);
/// # Ok::<(), girt::remote::ConfiguredRefspecError>(())
/// ```
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConfiguredRefspecKind {
    /// Fetch a source without naming a destination.
    Selection,
    /// Exclude a source or source pattern in either direction.
    Exclusion,
    /// Map a source or source pattern to a destination.
    Mapping,
    /// Push branches having matching names at both ends.
    AllMatching,
    /// Delete a push destination.
    Deletion,
}

/// A byte-preserving description of configured fetch or push syntax.
///
/// Use this type to display, validate or translate the refspecs a user configured, as jj does to
/// turn fetch refspecs into bookmark patterns. Use [`super::Refspecs`] to plan a transfer.
///
/// Unlike [`super::Refspec`], this type accepts shorthand names, positive object IDs and matching
/// push syntax. An explicit, non-pattern push source is an opaque revision expression. Parsing does
/// not resolve names, expand patterns, authorize updates, or establish that an operation can be
/// executed. Use the stricter mapping API when those are its requirements.
///
/// Empty fetch values select `HEAD`; they do not reset a collection. This parser handles one value
/// at a time and performs no collection sorting or deduplication.
///
/// # Examples
///
/// Translate configured fetch refspecs into something an application understands, refusing the
/// forms it doesn't, as jj does when it turns them into bookmark patterns:
///
/// ```
/// use girt::remote::{ConfiguredRefspec, ConfiguredRefspecKind, Direction};
///
/// fn describe(value: &[u8]) -> Result<String, &'static str> {
///     let spec = ConfiguredRefspec::parse(Direction::Fetch, value).map_err(|_| "invalid")?;
///     let text =
///         |bytes: Option<&[u8]>| String::from_utf8_lossy(bytes.unwrap_or_default()).into_owned();
///
///     match spec.kind() {
///         ConfiguredRefspecKind::Mapping => Ok(format!(
///             "{}{} -> {}",
///             if spec.force() { "+" } else { "" },
///             text(spec.source()),
///             text(spec.destination()),
///         )),
///         ConfiguredRefspecKind::Exclusion => Ok(format!("not {}", text(spec.source()))),
///         ConfiguredRefspecKind::Selection => Err("fetches without recording a reference"),
///         _ => Err("not a fetch mapping"),
///     }
/// }
///
/// assert_eq!(
///     describe(b"+refs/heads/*:refs/remotes/origin/*"),
///     Ok("+refs/heads/* -> refs/remotes/origin/*".to_owned()),
/// );
/// assert_eq!(
///     describe(b"^refs/heads/wip/*"),
///     Ok("not refs/heads/wip/*".to_owned())
/// );
///
/// // `@` is shorthand for HEAD; the descriptor normalizes it.
/// assert_eq!(
///     ConfiguredRefspec::parse(Direction::Fetch, b"@")?.to_bytes(),
///     b"HEAD"
/// );
/// # Ok::<(), girt::remote::ConfiguredRefspecError>(())
/// ```
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ConfiguredRefspec {
    kind: ConfiguredRefspecKind,
    source: Option<Vec<u8>>,
    destination: Option<Vec<u8>>,
    force: bool,
}

/// Invalid configured refspec syntax.
///
/// The value in `remote.<name>.fetch` or `remote.<name>.push` is not a refspec Git would accept.
/// Show the user which remote and key it came from (see
/// [`ConfiguredRemoteError::Refspec`](super::ConfiguredRemoteError::Refspec)) so they can
/// correct the configuration; nothing else can repair it.
///
/// - [`Name`](Self::Name): a side is not a valid reference name or pattern.
/// - [`Pattern`](Self::Pattern): a side has more than one `*`, or only one side has one.
/// - [`Exclusion`](Self::Exclusion): a `^` refspec has a destination or names an object ID.
/// - [`EmptyPush`](Self::EmptyPush) and [`EmptyDestination`](Self::EmptyDestination): a push value
///   is empty or ends in `:`.
///
/// # Examples
///
/// ```
/// use girt::remote::{ConfiguredRefspec, ConfiguredRefspecError, Direction};
///
/// let parse = ConfiguredRefspec::parse;
/// assert_eq!(
///     parse(Direction::Fetch, b"refs/heads/*:refs/remotes/origin/main"),
///     Err(ConfiguredRefspecError::Pattern)
/// );
/// assert_eq!(
///     parse(Direction::Fetch, b"^refs/heads/wip:refs/remotes/origin/wip"),
///     Err(ConfiguredRefspecError::Exclusion)
/// );
/// assert_eq!(
///     parse(Direction::Push, b""),
///     Err(ConfiguredRefspecError::EmptyPush)
/// );
/// ```
#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfiguredRefspecError {
    /// A source or destination violates reference-name syntax.
    #[error("invalid configured refspec name")]
    Name,
    /// An exclusion has no source, has a destination, or names a full object ID.
    #[error("invalid configured refspec exclusion")]
    Exclusion,
    /// A pattern has multiple wildcards or lacks a corresponding destination pattern.
    #[error("unbalanced configured refspec pattern")]
    Pattern,
    /// A push value is empty.
    #[error("empty configured push refspec")]
    EmptyPush,
    /// A push source explicitly names an empty destination.
    #[error("empty configured push destination")]
    EmptyDestination,
}

impl ConfiguredRefspec {
    /// Parses one configured value without I/O or UTF-8 conversion.
    ///
    /// An implicit configuration value must be passed as an empty slice. Fetch selections discard
    /// a redundant force marker or empty destination; push deletions discard a force marker.
    /// Negative refspecs are accepted for both directions, but cannot be forced or name a full
    /// 40- or 64-digit hexadecimal object ID. Standalone `tag <name>` command-line shorthand is not
    /// accepted. An `@` source normalizes to `HEAD`. Explicit push destinations allow opaque
    /// non-pattern revision sources; their syntax and resolvability are not checked here. A mapping
    /// pattern requires exactly one `*` on each side; an exclusion permits one source wildcard.
    ///
    /// # Errors
    ///
    /// Returns [`ConfiguredRefspecError`] for malformed names, exclusions or patterns, and for an
    /// empty push value. A successfully parsed descriptor may still require unsupported policy
    /// or name resolution in the caller.
    ///
    /// ```
    /// use girt::remote::{ConfiguredRefspec, ConfiguredRefspecKind, Direction};
    /// let spec = ConfiguredRefspec::parse(Direction::Fetch, b"+main:")?;
    /// assert_eq!(spec.kind(), ConfiguredRefspecKind::Selection);
    /// assert_eq!(spec.to_bytes(), b"main");
    /// # Ok::<(), girt::remote::ConfiguredRefspecError>(())
    /// ```
    pub fn parse(direction: Direction, bytes: &[u8]) -> Result<Self, ConfiguredRefspecError> {
        use ConfiguredRefspecKind as Kind;
        if let Some(source) = bytes.strip_prefix(b"^") {
            if source.is_empty()
                || source.contains(&b':')
                || (matches!(source.len(), 40 | 64) && source.iter().all(u8::is_ascii_hexdigit))
            {
                return Err(ConfiguredRefspecError::Exclusion);
            }
            let source = normalize_source(source);
            validate_name(source)?;
            return Ok(Self {
                kind: Kind::Exclusion,
                source: Some(source.to_vec()),
                destination: None,
                force: false,
            });
        }
        let (force, body) = bytes
            .strip_prefix(b"+")
            .map_or((false, bytes), |b| (true, b));
        let (source, destination) = match body.iter().rposition(|b| *b == b':') {
            Some(colon) => (&body[..colon], Some(&body[colon + 1..])),
            None => (body, None),
        };
        if direction == Direction::Push && !source.is_empty() && destination == Some(b"".as_slice())
        {
            return Err(ConfiguredRefspecError::EmptyDestination);
        }
        let explicit_destination = destination.is_some();
        let source = normalize_source(source);
        let destination = destination.filter(|name| !name.is_empty());
        if direction == Direction::Push && bytes.is_empty() {
            return Err(ConfiguredRefspecError::EmptyPush);
        }
        let (kind, source, destination, force) = match (direction, source, destination) {
            (Direction::Fetch, source, destination) => {
                let source = if source.is_empty() { b"HEAD" } else { source };
                let kind = if destination.is_some() {
                    Kind::Mapping
                } else {
                    Kind::Selection
                };
                (
                    kind,
                    Some(source),
                    destination,
                    force && destination.is_some(),
                )
            }
            (Direction::Push, [], None) => (Kind::AllMatching, None, None, force),
            (Direction::Push, [], Some(destination)) => {
                (Kind::Deletion, None, Some(destination), false)
            }
            (Direction::Push, source, destination) => (
                Kind::Mapping,
                Some(source),
                Some(destination.unwrap_or(source)),
                force,
            ),
        };
        // An explicit non-pattern push source is an opaque revision expression. Its eventual
        // resolution belongs to the caller; implicit destinations and patterns remain names.
        let source_pattern = match source {
            Some(source)
                if direction == Direction::Push
                    && explicit_destination
                    && !source.contains(&b'*') =>
            {
                false
            }
            Some(source) => validate_name(source)?,
            None => false,
        };
        let destination_pattern = destination.map(validate_name).transpose()?.unwrap_or(false);
        if source_pattern != destination_pattern {
            return Err(ConfiguredRefspecError::Pattern);
        }
        Ok(Self {
            kind,
            source: source.map(<[u8]>::to_vec),
            destination: destination.map(<[u8]>::to_vec),
            force,
        })
    }

    /// Returns the operation described by this value.
    pub fn kind(&self) -> ConfiguredRefspecKind {
        self.kind
    }

    /// Borrows the source bytes, if this operation has a source.
    pub fn source(&self) -> Option<&[u8]> {
        self.source.as_deref()
    }

    /// Borrows the destination bytes, if this operation has a destination.
    pub fn destination(&self) -> Option<&[u8]> {
        self.destination.as_deref()
    }

    /// Returns whether the mapping or matching push permits forced updates.
    pub fn force(&self) -> bool {
        self.force
    }

    /// Serializes the normalized descriptor without UTF-8 conversion.
    ///
    /// Fetch defaults become `HEAD`; selections omit redundant `+` and `:`; push mappings always
    /// include a destination; deletions omit redundant force. Parsing these bytes in the original
    /// direction reproduces an equal descriptor.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut bytes = Vec::new();
        if self.force {
            bytes.push(b'+');
        }
        if self.kind == ConfiguredRefspecKind::Exclusion {
            bytes.push(b'^');
        }
        if let Some(source) = &self.source {
            bytes.extend_from_slice(source);
        }
        if matches!(
            self.kind,
            ConfiguredRefspecKind::Mapping
                | ConfiguredRefspecKind::Deletion
                | ConfiguredRefspecKind::AllMatching
        ) {
            bytes.push(b':');
        }
        if let Some(destination) = &self.destination {
            bytes.extend_from_slice(destination);
        }
        bytes
    }
}

fn normalize_source(bytes: &[u8]) -> &[u8] {
    if bytes == b"@" { b"HEAD" } else { bytes }
}

/// Validate shorthand names with the same component rules as full names.
fn validate_name(bytes: &[u8]) -> Result<bool, ConfiguredRefspecError> {
    if bytes == b"@" {
        return Err(ConfiguredRefspecError::Name);
    }
    let wildcards = bytes.iter().filter(|b| **b == b'*').count();
    if wildcards > 1 {
        return Err(ConfiguredRefspecError::Pattern);
    }
    let mut full = b"refs/".to_vec();
    full.extend(bytes.iter().map(|b| if *b == b'*' { b'x' } else { *b }));
    RefName::new(full).map_err(|_| ConfiguredRefspecError::Name)?;
    Ok(wildcards == 1)
}

#[cfg(test)]
mod tests {
    use ConfiguredRefspecKind::{AllMatching, Deletion, Exclusion, Mapping, Selection};
    use Direction::{Fetch, Push};
    use rstest::rstest;

    use super::*;

    // Original fixtures observed through the public configured-remote API. These do not execute
    // mappings, and deliberately include syntax outside the executable Refspec subset.
    #[rstest]
    #[case::fetch_empty(Fetch, b"", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
    #[case::fetch_colon(Fetch, b":", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
    #[case::fetch_plus(Fetch, b"+", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
    #[case::fetch_short(Fetch, b"main", b"main", Selection, Some(b"main".as_slice()), None, false)]
    #[case::fetch_empty_destination(Fetch, b"+main:", b"main", Selection, Some(b"main".as_slice()), None, false)]
    #[case::fetch_default_mapping(Fetch, b"+:origin/main", b"+HEAD:origin/main", Mapping, Some(b"HEAD".as_slice()), Some(b"origin/main".as_slice()), true)]
    #[case::fetch_at(Fetch, b"@", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
    #[case::push_at(Push, b"@", b"HEAD:HEAD", Mapping, Some(b"HEAD".as_slice()), Some(b"HEAD".as_slice()), false)]
    #[case::push_short(Push, b"main", b"main:main", Mapping, Some(b"main".as_slice()), Some(b"main".as_slice()), false)]
    #[case::push_matching(Push, b":", b":", AllMatching, None, None, false)]
    #[case::push_matching_force(Push, b"+", b"+:", AllMatching, None, None, true)]
    #[case::push_delete(Push, b"+:main", b":main", Deletion, None, Some(b"main".as_slice()), false)]
    #[case::push_revision(Push, b"+HEAD~1:main", b"+HEAD~1:main", Mapping, Some(b"HEAD~1".as_slice()), Some(b"main".as_slice()), true)]
    #[case::push_colon_revision(Push, b"HEAD:file:main", b"HEAD:file:main", Mapping, Some(b"HEAD:file".as_slice()), Some(b"main".as_slice()), false)]
    #[case::fetch_negative(Fetch, b"^main", b"^main", Exclusion, Some(b"main".as_slice()), None, false)]
    #[case::push_negative(Push, b"^main", b"^main", Exclusion, Some(b"main".as_slice()), None, false)]
    #[case::negative_plus(Fetch, b"^+refs/a", b"^+refs/a", Exclusion, Some(b"+refs/a".as_slice()), None, false)]
    #[case::negative_at(Push, b"^@", b"^HEAD", Exclusion, Some(b"HEAD".as_slice()), None, false)]
    fn describes_and_normalizes(
        #[case] direction: Direction,
        #[case] input: &[u8],
        #[case] canonical: &[u8],
        #[case] kind: ConfiguredRefspecKind,
        #[case] source: Option<&[u8]>,
        #[case] destination: Option<&[u8]>,
        #[case] force: bool,
    ) {
        let spec = ConfiguredRefspec::parse(direction, input).unwrap();
        assert_eq!(spec.kind(), kind);
        assert_eq!(spec.source(), source);
        assert_eq!(spec.destination(), destination);
        assert_eq!(spec.force(), force);
        assert_eq!(spec.to_bytes(), canonical);
        assert_eq!(
            ConfiguredRefspec::parse(direction, canonical).unwrap(),
            spec
        );
    }

    #[rstest]
    #[case::mapping(b"+refs/a:refs/b")]
    #[case::partial_pattern(b"a*:b*")]
    #[case::whole_pattern(b"*:refs/*")]
    #[case::negative_pattern(b"^refs/*")]
    #[case::negative_abbreviated_hash(b"^1111111")]
    #[case::negative_nonhex_40(b"^z111111111111111111111111111111111111111")]
    #[case::sha1_mapping(b"+1111111111111111111111111111111111111111:refs/b")]
    #[case::sha256_mapping(
        b"1111111111111111111111111111111111111111111111111111111111111111:refs/b"
    )]
    #[case::non_utf8(b"+refs/\xff:refs/\xfe")]
    #[case::negative_non_utf8(b"^refs/\xff")]
    fn roundtrips_both_directions(
        #[values(Fetch, Push)] direction: Direction,
        #[case] input: &[u8],
    ) {
        let spec = ConfiguredRefspec::parse(direction, input).unwrap();
        assert_eq!(spec.to_bytes(), input);
        assert_eq!(
            ConfiguredRefspec::parse(direction, &spec.to_bytes()).unwrap(),
            spec
        );
    }

    #[rstest]
    #[case::empty_negative(b"^")]
    #[case::negative_destination(b"^main:other")]
    #[case::negative_empty_destination(b"^main:")]
    #[case::negative_sha1(b"^1111111111111111111111111111111111111111")]
    #[case::negative_sha256(b"^1111111111111111111111111111111111111111111111111111111111111111")]
    #[case::forced_negative(b"+^refs/a")]
    #[case::multiple_patterns(b"a**:b**")]
    #[case::unbalanced_source(b"a*:b")]
    #[case::unbalanced_destination(b"a:b*")]
    #[case::multiple_negative_patterns(b"^a**")]
    #[case::invalid_pattern_name(b"a~*:b*")]
    #[case::destination_at(b"a:@")]
    #[case::destination_control(b"a:b\0")]
    #[case::destination_dotdot(b"a:b..c")]
    #[case::destination_lock(b"a:b.lock")]
    #[case::destination_slashes(b"a:b//c")]
    #[case::tag_shorthand(b"tag v1")]
    fn rejects_malformed_both_directions(
        #[values(Fetch, Push)] direction: Direction,
        #[case] input: &[u8],
    ) {
        assert!(ConfiguredRefspec::parse(direction, input).is_err());
    }

    #[rstest]
    #[case::selection_pattern(Fetch, b"refs/*")]
    #[case::selection_pattern_empty_destination(Fetch, b"refs/*:")]
    #[case::fetch_revision(Fetch, b"HEAD~1:main")]
    #[case::fetch_multiple_colons(Fetch, b"a:b:c")]
    #[case::push_empty(Push, b"")]
    #[case::push_empty_destination(Push, b"main:")]
    #[case::push_revision_without_destination(Push, b"HEAD~1")]
    fn rejects_direction_specific_syntax(#[case] direction: Direction, #[case] input: &[u8]) {
        assert!(ConfiguredRefspec::parse(direction, input).is_err());
    }

    #[rstest]
    #[case::sha1(b"1111111111111111111111111111111111111111")]
    #[case::sha256(b"1111111111111111111111111111111111111111111111111111111111111111")]
    fn fetch_selects_positive_object_ids(#[case] source: &[u8]) {
        let spec = ConfiguredRefspec::parse(Fetch, source).unwrap();
        assert_eq!(spec.kind(), Selection);
        assert_eq!(spec.source(), Some(source));
        assert_eq!(spec.to_bytes(), source);
    }

    #[test]
    fn push_implicit_pattern_maps_to_itself() {
        let spec = ConfiguredRefspec::parse(Push, b"+refs/*").unwrap();
        assert_eq!(spec.to_bytes(), b"+refs/*:refs/*");
        assert_eq!(
            ConfiguredRefspec::parse(Push, &spec.to_bytes()).unwrap(),
            spec
        );
    }

    #[test]
    fn parsing_occurrences_does_not_reset_sort_or_deduplicate() {
        let inputs: &[&[u8]] = &[b"z", b"", b"a", b"z"];
        let specs = inputs
            .iter()
            .map(|s| ConfiguredRefspec::parse(Fetch, s).unwrap().to_bytes())
            .collect::<Vec<_>>();
        assert_eq!(
            specs,
            [
                b"z".to_vec(),
                b"HEAD".to_vec(),
                b"a".to_vec(),
                b"z".to_vec()
            ]
        );
    }
}