girt 0.18.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
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
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
use crate::refs::RefName;
use crate::{ObjectId, PackCompression, PackWriteLimits, ReadLimits};

/// Policy for replacing an existing destination. Server restrictions still apply.
///
/// Force is checked twice: [`PreparedPush::new`](super::PreparedPush::new) refuses a
/// non-fast-forward branch update under [`FastForwardOnly`](Self::FastForwardOnly) before anything
/// is sent, and the receiver applies its own policy, such as `receive.denyNonFastForwards`. Even
/// [`Allow`](Self::Allow) keeps the exact expected-value condition, so it is a
/// `--force-with-lease`, never a blind `--force`.
///
/// # Examples
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::PackLimits;
/// use girt::push::{ForcePolicy, PreparedPush, PushCommand, PushFailure, PushLimits};
/// use girt::refs::RefName;
/// # let (_directory, upstream, repo, main, _) = include!("../doctest/remote.rs");
/// # let commit = include!("../doctest/commit.rs");
/// # girt::workflow::fetch(&repo, "origin", &girt::workflow::FetchOptions::new(["+refs/heads/*:refs/remotes/origin/*"]), &girt::workflow::Environment::default(), &mut girt::workflow::NoCallbacks)?;
/// // `repo` has fetched `main`; `rewritten` does not descend from it.
/// let rewritten = commit(&repo, &[], "Rewritten history")?;
/// let objects = repo.objects_with(PackLimits::default())?;
/// let cancel = AtomicBool::new(false);
/// let name = RefName::new("refs/heads/main")?;
///
/// let rewind = PushCommand::new(name.clone(), Some(main), rewritten);
/// let refused = PreparedPush::new(&objects, vec![rewind.clone()], &[], PushLimits::default(), &cancel);
/// assert!(matches!(refused, Err(PushFailure::WouldForce(_))));
///
/// let forced = rewind.with_force(ForcePolicy::Allow);
/// assert!(PreparedPush::new(&objects, vec![forced], &[], PushLimits::default(), &cancel).is_ok());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum ForcePolicy {
    /// Branch updates require the old commit to be an ancestor of the new commit. Existing tags
    /// can only retain the same ID. Other namespaces use only the exact old-value condition.
    /// Creation is allowed; branches must point directly to commits.
    #[default]
    FastForwardOnly,
    /// Explicitly permit non-fast-forward branch changes or tag replacement, still conditional
    /// on the exact expected old value. This never bypasses server hooks or configuration.
    Allow,
}

/// One full destination name and an exact compare-and-swap expectation.
///
/// A command says "if `name` is `expected`, make it `new`". Use `expected: None` to create a
/// reference that must not exist yet, and the null ID as `new` to delete one. Get `expected` from
/// the value you last saw on the remote, such as your remote-tracking reference; never guess it.
///
/// The condition guards changes. If the reference already has the value `new`, nothing needs to
/// change and the receiver reports `Ok` without comparing `expected`, so an `Ok` does not prove
/// that the expectation matched.
///
/// # Examples
///
/// ```
/// use girt::push::{ForcePolicy, PushCommand};
/// use girt::refs::RefName;
/// use girt::{ObjectFormat, ObjectId, ObjectKind};
///
/// let old = ObjectFormat::Sha1.hash_object(ObjectKind::Commit, b"old");
/// let new = ObjectFormat::Sha1.hash_object(ObjectKind::Commit, b"new");
///
/// // Create `topic`; rejected if it already exists with a different value.
/// let create = PushCommand::new(RefName::new("refs/heads/topic")?, None, new);
///
/// // Fast-forward `main` from `old` to `new`.
/// let update = PushCommand::new(RefName::new("refs/heads/main")?, Some(old), new);
///
/// // Delete `stale`, but only if it is still at `old`.
/// let delete = PushCommand::new(
///     RefName::new("refs/heads/stale")?,
///     Some(old),
///     ObjectId::null(ObjectFormat::Sha1),
/// )
/// .with_force(ForcePolicy::Allow);
///
/// assert!(delete.deletes() && !create.deletes() && !update.deletes());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PushCommand {
    /// Validated full destination name under `refs/`; `HEAD` is not a push destination.
    pub name: RefName,
    /// `None` requires absence. `Some` requires this exact nonzero value, even with force enabled.
    pub expected: Option<ObjectId>,
    /// Desired tip. The null ID in the repository's format deletes an existing ref. Tags and
    /// non-branch namespaces may point to any supported object kind.
    pub new: ObjectId,
    /// Explicit replacement policy; use the default to protect existing history and tags.
    pub force: ForcePolicy,
}

impl PushCommand {
    /// Creates a conditional update protected by [`ForcePolicy::FastForwardOnly`].
    ///
    /// An `expected` value of `None` requires the reference to be absent; `Some` requires
    /// that exact nonzero object ID, even when force is subsequently enabled.
    pub fn new(name: RefName, expected: Option<ObjectId>, new: ObjectId) -> Self {
        Self {
            name,
            expected,
            new,
            force: ForcePolicy::default(),
        }
    }

    /// Sets [`Self::name`].
    #[must_use]
    pub fn with_name(mut self, name: RefName) -> Self {
        self.name = name;
        self
    }

    /// Sets [`Self::expected`].
    #[must_use]
    pub fn with_expected(mut self, expected: Option<ObjectId>) -> Self {
        self.expected = expected;
        self
    }

    /// Sets [`Self::new`].
    #[must_use]
    pub fn with_new(mut self, new: ObjectId) -> Self {
        self.new = new;
        self
    }

    /// Sets [`Self::force`].
    #[must_use]
    pub fn with_force(mut self, force: ForcePolicy) -> Self {
        self.force = force;
        self
    }
}
impl PushCommand {
    /// Returns whether this command deletes its destination.
    pub fn deletes(&self) -> bool {
        self.new.is_null()
    }
}

/// Input, work and output bounds for preparation and a single receive-pack session.
///
/// Push limits bound work on both sides: preparing a push walks and decodes the history it sends
/// and builds the pack, and sending parses the receiver's advertisement and status. Preparation
/// copies eligible compressed entries after a CRC check rather than rehashing them, so the
/// receiver, not the pusher, establishes those objects' identities; see
/// [`PreparedPush::new`](super::PreparedPush::new) for the source storage assumptions. Use
/// `default()` when either side is untrusted and `trusted()` otherwise, as
/// [`crate::workflow::push`] does. See [Limits and trust](crate::transport#limits-and-trust).
///
/// # Examples
///
/// ```
/// use girt::PackCompression;
/// use girt::push::PushLimits;
///
/// // Accept larger pushes than the default, without delta search for faster preparation.
/// let limits = PushLimits::trusted().with_compression(PackCompression::Ordinary);
/// assert_eq!(limits.max_commands, PushLimits::trusted().max_commands);
/// ```
///
/// Packs are spooled to temporary files. Retained memory includes selected structured payloads,
/// object/edge metadata, protocol bytes and the caller's object snapshot. Packed blob payloads
/// are not retained by selection; writing holds one reconstructed object and, for fresh delta
/// search, up to `window * max_object_bytes` internal candidate bytes plus one external candidate.
/// Structured parsing copies payloads.
/// Per-read decoding work is bounded separately and may recur for each selected object.
/// These are not hard heap or wall-clock limits; allocator overhead and server memory are excluded.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct PushLimits {
    /// Advertisement bytes including pkt-line framing (default 4 MiB).
    pub max_advertisement_bytes: usize,
    /// Advertised reference and `.have` entries (default 100,000).
    pub max_refs: usize,
    /// Explicit command count (default 100,000).
    pub max_commands: usize,
    /// Total encoded command and push-option bytes, including flushes (default 16 MiB).
    pub max_command_bytes: usize,
    /// Status bytes including framing (default 4 MiB). Native receive hooks share this bound
    /// for their combined retained stdout/stderr across the whole operation. Excess native hook
    /// output is discarded while draining continues; truncation does not abort hooks.
    pub max_status_bytes: usize,
    /// Edge occurrences from objects selected for sending, including duplicates (default 4
    /// million). Reading known receiver history has a separate allowance of the same size.
    pub max_edges: usize,
    /// Cumulative commit visits and parent edge occurrences across fast-forward proofs
    /// (default 4 million), including duplicates. Both the new and old tips' ancestry count.
    pub max_ancestry_steps: usize,
    /// Per-object storage decoding bounds. Payload reads are additionally capped by remaining
    /// aggregate pack input bytes so preparation cannot retain more than that payload budget.
    pub read: ReadLimits,
    /// Selected count/payload and generated artifact bounds. The index is generated into a sink;
    /// its bound still applies. Count/payload bounds cover every object read as a candidate for
    /// sending, including commits later found in receiver history.
    pub pack: PackWriteLimits,
    /// Compression policy for fresh and reconstructed objects; bounded delta search by default.
    /// Blob and tree candidates may come from verified advertised-root history; their
    /// reconstructed values count against `pack.max_input_bytes`. Insufficient candidate
    /// budget falls back to whole objects. A corrupt offered candidate refuses preparation even
    /// if another candidate could produce a valid delta; candidate integrity is checked strictly.
    /// Existing compressed entries and eligible deltas are reused under either policy.
    /// Transfer deltas use REF_DELTA, requiring no optional OFS capability. Search and copied
    /// chains obey `read.max_delta_depth` and the delta policy's depth bound when enabled.
    /// Offering receiver candidates additionally caps output depth at one so internal bases
    /// always resolve before receiver-assisted external lookup.
    pub compression: PackCompression,
    /// Bounds for opening a native local destination repository's object store, to check its
    /// history and install the pushed pack (default
    /// [`PackLimits::default`](crate::PackLimits::default), which admits 64 MiB of pack index).
    ///
    /// Git-scale destinations exceed the default: a Linux kernel clone has a 300 MB index. Use
    /// [`PackLimits::trusted`](crate::PackLimits::trusted), as [`Self::trusted`] does, for
    /// repositories the caller trusts. Network pushes open no destination store and ignore this.
    pub destination_packs: crate::PackLimits,
}

impl PushLimits {
    /// Sets [`Self::max_advertisement_bytes`].
    #[must_use]
    pub fn with_max_advertisement_bytes(mut self, max_advertisement_bytes: usize) -> Self {
        self.max_advertisement_bytes = max_advertisement_bytes;
        self
    }

    /// Sets [`Self::max_refs`].
    #[must_use]
    pub fn with_max_refs(mut self, max_refs: usize) -> Self {
        self.max_refs = max_refs;
        self
    }

    /// Sets [`Self::max_commands`].
    #[must_use]
    pub fn with_max_commands(mut self, max_commands: usize) -> Self {
        self.max_commands = max_commands;
        self
    }

    /// Sets [`Self::max_command_bytes`].
    #[must_use]
    pub fn with_max_command_bytes(mut self, max_command_bytes: usize) -> Self {
        self.max_command_bytes = max_command_bytes;
        self
    }

    /// Sets [`Self::max_status_bytes`].
    #[must_use]
    pub fn with_max_status_bytes(mut self, max_status_bytes: usize) -> Self {
        self.max_status_bytes = max_status_bytes;
        self
    }

    /// Sets [`Self::max_edges`].
    #[must_use]
    pub fn with_max_edges(mut self, max_edges: usize) -> Self {
        self.max_edges = max_edges;
        self
    }

    /// Sets [`Self::max_ancestry_steps`].
    #[must_use]
    pub fn with_max_ancestry_steps(mut self, max_ancestry_steps: usize) -> Self {
        self.max_ancestry_steps = max_ancestry_steps;
        self
    }

    /// Sets [`Self::read`].
    #[must_use]
    pub fn with_read(mut self, read: ReadLimits) -> Self {
        self.read = read;
        self
    }

    /// Sets [`Self::pack`].
    #[must_use]
    pub fn with_pack(mut self, pack: PackWriteLimits) -> Self {
        self.pack = pack;
        self
    }

    /// Sets [`Self::compression`].
    #[must_use]
    pub fn with_compression(mut self, compression: PackCompression) -> Self {
        self.compression = compression;
        self
    }

    /// Sets [`Self::destination_packs`].
    #[must_use]
    pub fn with_destination_packs(mut self, destination_packs: crate::PackLimits) -> Self {
        self.destination_packs = destination_packs;
        self
    }
}

impl Default for PushLimits {
    fn default() -> Self {
        Self {
            max_advertisement_bytes: 4 * 1024 * 1024,
            max_refs: 100_000,
            max_commands: 100_000,
            max_command_bytes: 16 * 1024 * 1024,
            max_status_bytes: 4 * 1024 * 1024,
            max_edges: 4_000_000,
            max_ancestry_steps: 4_000_000,
            read: ReadLimits::default(),
            pack: PackWriteLimits::default(),
            compression: PackCompression::Delta(crate::DeltaOptions::DEFAULT),
            destination_packs: crate::PackLimits::default(),
        }
    }
}

impl PushLimits {
    /// Returns limits for a transfer the caller trusts as Git trusts the user's own remotes.
    ///
    /// Git bounds none of these values. The defaults suit untrusted input; these are large enough
    /// that only available memory bounds the work, while keeping headroom for internal arithmetic.
    ///
    /// The prepared pack is held in a temporary file before sending.
    pub const fn trusted() -> Self {
        const UNBOUNDED: usize = usize::MAX / 4;
        Self {
            max_advertisement_bytes: UNBOUNDED,
            max_refs: UNBOUNDED,
            max_commands: UNBOUNDED,
            max_command_bytes: UNBOUNDED,
            max_status_bytes: UNBOUNDED,
            max_edges: UNBOUNDED,
            max_ancestry_steps: UNBOUNDED,
            read: ReadLimits::trusted(),
            pack: PackWriteLimits::trusted(),
            compression: PackCompression::Delta(crate::DeltaOptions::DEFAULT),
            destination_packs: crate::PackLimits::trusted(),
        }
    }
}

/// Server acknowledgement, preserving rejection text without assuming UTF-8.
///
/// # Examples
///
/// Print results the way `git push` does. Rejection reasons come from the receiver, so treat them
/// as untrusted text:
///
/// ```
/// use girt::push::{PushReport, Status};
///
/// fn summarize(report: &PushReport) -> Vec<String> {
///     report
///         .refs
///         .iter()
///         .map(|result| {
///             let name = String::from_utf8_lossy(result.command.name.as_bytes());
///             match &result.status {
///                 Some(Status::Ok) => format!(" * {name}"),
///                 Some(Status::Rejected(reason)) => {
///                     // Escape control characters so a reason can't rewrite the terminal.
///                     let reason = String::from_utf8_lossy(reason);
///                     format!(" ! [remote rejected] {name} ({})", reason.escape_debug())
///                 }
///                 // No acknowledgement: the outcome is unknown, not a rejection.
///                 _ => format!(" ? {name} (unknown)"),
///             }
///         })
///         .collect()
/// }
/// # let _ = summarize;
/// ```
#[derive(Debug, Clone, Eq, PartialEq)]
#[non_exhaustive]
pub enum Status {
    /// The server reported success.
    Ok,
    /// The server reported failure with this byte-preserving explanation.
    Rejected(Vec<u8>),
}

/// Provenance of a rejected reference update.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum RejectionOrigin {
    /// The native destination's locked reference did not match the command's expected old value.
    ExpectedValue,
    /// A receiver rejected the command for policy, storage, or another reason. Wire status
    /// reports use this variant even when their reason text mentions a stale value: the client
    /// cannot independently prove the receiver's exact comparison.
    Receiver,
}

/// Evidence for one submitted command, in caller command order.
///
/// Read `status` first: `Some(Status::Ok)` is an acknowledgement, `Some(Status::Rejected(_))` a
/// refusal with the server's reason, and `None` an unknown outcome, never a refusal. When it is
/// `None` and `attempted` is true, the update may have happened; inspect the remote.
///
/// # Examples
///
/// See [`PushReport`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RefStatus {
    /// The submitted destination, old/new IDs and force policy.
    pub command: PushCommand,
    /// `None` means no valid acknowledgement was received. It does not mean rejection.
    pub status: Option<Status>,
    /// Rejection provenance, present only when `status` is [`Status::Rejected`].
    pub rejection_origin: Option<RejectionOrigin>,
    /// Whether any bytes of this command may have reached the peer, or native publication
    /// reached this command. `false` proves this command was not attempted by this push. A true
    /// value without an acknowledgement is uncertain and requires remote inspection.
    pub attempted: bool,
    /// Native reference and reflog effects, when publication was attempted. Wire servers do not
    /// expose this detail. A published reference can accompany a failed reflog append.
    pub effects: Option<crate::refs::RefEditOutcome>,
    /// Actual destination(s) reported by `report-status-v2` for a successful proc-receive
    /// command. Empty for ordinary updates or v1 servers. Each entry is server evidence, not a
    /// local reference mutation.
    pub rewrites: Vec<RefRewrite>,
    /// Whether all v2 rewrite options for this acknowledgement were terminated by the next
    /// status or report flush. False after an interrupted `ok` packet: the command was
    /// acknowledged, but its actual rewritten destination may still be unknown.
    pub rewrite_complete: bool,
}

/// One server-reported replacement for a successful pseudo-reference command.
#[derive(Debug, Clone, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct RefRewrite {
    /// Actual destination. `None` means the submitted name was used.
    pub name: Option<RefName>,
    /// Actual previous ID, when reported by the server.
    pub old: Option<ObjectId>,
    /// Actual new ID, when reported by the server.
    pub new: Option<ObjectId>,
    /// Whether the server marked the update as forced.
    pub forced: bool,
}

/// Server evidence, including individual results for a non-atomic multi-ref push.
///
/// # Examples
///
/// One command succeeds and one is rejected because the remote value changed:
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::PackLimits;
/// use girt::push::{
///     ForcePolicy, LocalPushRequest, PreparedPush, PushCommand, PushLimits, RejectionOrigin,
///     Status, send_local,
/// };
/// use girt::refs::RefName;
/// use girt::transport::TransportControl;
/// use girt::{ObjectFormat, ObjectKind};
///
/// # let (_directory, upstream, repo, main, _) = include!("../doctest/remote.rs");
/// # let commit = include!("../doctest/commit.rs");
/// # girt::workflow::fetch(&repo, "origin", &girt::workflow::FetchOptions::new(["+refs/heads/*:refs/remotes/origin/*"]), &girt::workflow::Environment::default(), &mut girt::workflow::NoCallbacks)?;
/// let next = commit(&repo, &[main], "Next commit")?;
/// let outdated = ObjectFormat::Sha1.hash_object(ObjectKind::Commit, b"an outdated view");
///
/// let commands = vec![
///     PushCommand::new(RefName::new("refs/heads/main")?, Some(main), next),
///     // Preparation cannot prove ancestry from an old value it doesn't have, so allow force;
///     // the receiver still checks the expected value.
///     PushCommand::new(RefName::new("refs/heads/release")?, Some(outdated), next)
///         .with_force(ForcePolicy::Allow),
/// ];
///
/// let cancel = AtomicBool::new(false);
/// let objects = repo.objects_with(PackLimits::default())?;
/// let prepared = PreparedPush::new(&objects, commands, &[main], PushLimits::default(), &cancel)?;
/// let request = LocalPushRequest::new(&prepared);
/// let report = send_local(upstream.git_dir(), request, TransportControl::new(&cancel), |_| {})?;
///
/// assert!(!report.all_succeeded());
/// assert_eq!(report.unpack, Some(Status::Ok));
/// assert_eq!(report.refs[0].status, Some(Status::Ok));
/// assert!(matches!(report.refs[1].status, Some(Status::Rejected(_))));
/// assert_eq!(report.refs[1].rejection_origin, Some(RejectionOrigin::ExpectedValue));
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// A successful `send` returns a complete report even when unpacking or every reference failed.
/// An uncertain error retains whatever valid status prefix was received. An `ok` is the server's
/// acknowledgement, not a promise of durability or that another writer has not since moved the ref.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PushReport {
    /// Pack acceptance, or `None` when no unpack result was received (also for an empty push).
    pub unpack: Option<Status>,
    /// Per-ref results in request order. Empty for an empty push.
    pub refs: Vec<RefStatus>,
    /// Bounded channel-2 progress frames when sideband reporting was requested. Bytes are
    /// untrusted and need not be UTF-8. Frames received before failure remain in the report.
    pub progress: Vec<Vec<u8>>,
    /// Native receive warnings that did not reject a reference.
    pub warnings: Vec<Vec<u8>>,
    /// Optional native received-pack integrity report, completed before installation or hooks.
    pub integrity: Option<crate::fsck::TransferReport>,
}
impl PushReport {
    pub(crate) fn pending(commands: &[PushCommand]) -> Self {
        Self {
            unpack: None,
            progress: vec![],
            warnings: vec![],
            integrity: None,
            refs: commands
                .iter()
                .cloned()
                .map(|command| RefStatus {
                    command,
                    status: None,
                    rejection_origin: None,
                    attempted: false,
                    effects: None,
                    rewrites: vec![],
                    rewrite_complete: false,
                })
                .collect(),
        }
    }

    pub(super) fn mark_attempted(&mut self, request: &[u8], written: usize) {
        let mut offset = 0usize;
        for reference in &mut self.refs {
            if written > offset {
                reference.attempted = true;
            }
            let header = &request[offset..offset + 4];
            let length =
                usize::from_str_radix(std::str::from_utf8(header).expect("encoded packet"), 16)
                    .expect("encoded packet length");
            offset += length;
        }
    }

    /// Whether every requested update was acknowledged and unpacking succeeded. An empty push
    /// is successful. Inspect individual results even when this returns false.
    pub fn all_succeeded(&self) -> bool {
        (self.refs.is_empty() || self.unpack == Some(Status::Ok))
            && self
                .refs
                .iter()
                .all(|r| r.status == Some(Status::Ok) && r.rewrite_complete)
    }
}

/// Session failure classified by whether commands might have reached the server.
///
/// This classification decides recovery. After [`NotSent`](Self::NotSent), the push attempted no
/// reference updates on the receiver, so retrying it is safe once the cause is fixed. A native
/// local push may still have stored objects, or run receive hooks, in the destination. After
/// [`Uncertain`](Self::Uncertain), some updates may have been applied: use its partial `report`,
/// then read the remote's references before deciding what to send again. Per-reference
/// rejections are not errors; they are reported in [`PushReport`].
///
/// # Examples
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::PackLimits;
/// use girt::push::{
///     LocalPushRequest, PreparedPush, PushError, PushFailure, PushLimits, send_local,
/// };
/// use girt::transport::TransportControl;
/// # let (directory, _, repo, _, _) = include!("../doctest/remote.rs");
///
/// let cancel = AtomicBool::new(false);
/// let objects = repo.objects_with(PackLimits::default())?;
/// let prepared = PreparedPush::new(&objects, Vec::new(), &[], PushLimits::default(), &cancel)?;
///
/// let missing = directory.path().join("missing.git");
/// let result = send_local(
///     &missing,
///     LocalPushRequest::new(&prepared),
///     TransportControl::new(&cancel),
///     |_| {},
/// );
///
/// assert!(matches!(
///     result,
///     Err(PushError::NotSent(PushFailure::Destination(_)))
/// ));
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PushError {
    /// No update commands were attempted; this operation did not mutate destination refs.
    #[error("push not sent")]
    NotSent(#[source] PushFailure),
    /// Command transmission began. Some or all updates may have happened; do not blindly retry.
    /// Retained acknowledgements are evidence, while missing results require remote inspection.
    #[error("push outcome uncertain")]
    Uncertain {
        /// Transport, protocol, cancellation, resource or process failure.
        #[source]
        cause: PushFailure,
        /// Valid status prefix received before failure; missing entries remain unknown.
        report: Box<PushReport>,
    },
}

/// Preparation or session failure cause. Remote per-ref rejections are [`Status`] values instead.
///
/// [`PreparedPush::new`](super::PreparedPush::new) returns this directly; sending wraps it in
/// [`PushError`], whose variant says whether commands may have reached the receiver. The causes
/// group by what to do next:
///
/// - **Fix the commands.** [`Command`](Self::Command) (duplicate destination, zero expected ID,
///   name outside `refs/`), [`WouldForce`](Self::WouldForce) (a non-fast-forward update or tag
///   replacement needs [`ForcePolicy::Allow`]), [`ObjectFormat`](Self::ObjectFormat) (a command or
///   receiver root uses the other object format) and [`Identity`](Self::Identity) (an invalid
///   reflog identity).
/// - **The local repository can't supply the history.** [`Missing`](Self::Missing),
///   [`Kind`](Self::Kind), [`Read`](Self::Read), [`Commit`](Self::Commit), [`Tree`](Self::Tree) and
///   [`Tag`](Self::Tag): the commands point at objects that are absent, of the wrong kind or
///   invalid. Fetch the missing history or correct the commands. [`Pack`](Self::Pack) is a failure
///   building the pack, such as temporary-file I/O or a pack budget; see its nested error.
/// - **The receiver's state changed.** [`KnowledgeChanged`](Self::KnowledgeChanged): a receiver
///   root used to shrink the pack is no longer advertised. Prepare again from a fresh
///   advertisement, as [`crate::workflow::push`] does automatically once.
/// - **Limits, interruption and transport.** [`Limit`](Self::Limit) (raise that [`PushLimits`]
///   field for a trusted receiver), [`Cancelled`](Self::Cancelled), [`Deadline`](Self::Deadline),
///   [`Io`](Self::Io), `Http`, `Ssh` and [`Process`](Self::Process). Retrying may succeed, but only
///   after checking the receiver if the error is [`PushError::Uncertain`]. `Http` requires the
///   `http` feature; `Ssh` requires the `ssh` feature on macOS or Linux.
/// - **The receiver refused or misbehaved.** [`Remote`](Self::Remote) (an error message from the
///   receiver) and [`Protocol`](Self::Protocol).
/// - **Something isn't supported.** [`Unsupported`](Self::Unsupported) covers a receiver missing a
///   required capability such as `report-status` or `delete-refs`, a shallow source repository,
///   and, for a local destination, a format, shallow state, configuration value or hook girt does
///   not support. The message names which; change the request or destination, or use another
///   implementation.
/// - **A local destination failed.** [`Destination`](Self::Destination) (not a repository),
///   [`Install`](Self::Install), [`Reference`](Self::Reference) and
///   [`QuarantineCleanup`](Self::QuarantineCleanup). Inspect the destination before retrying;
///   `QuarantineCleanup` holds the private directory that could not be removed.
///
/// # Examples
///
/// Preparation refuses two commands for one destination before reading any history:
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::PackLimits;
/// use girt::push::{PreparedPush, PushCommand, PushFailure, PushLimits};
/// use girt::refs::RefName;
///
/// # let (_directory, _, repo, _, _) = include!("../doctest/remote.rs");
/// # let commit = include!("../doctest/commit.rs");
/// let first = commit(&repo, &[], "First")?;
/// let second = commit(&repo, &[], "Second")?;
///
/// let main = RefName::new("refs/heads/main")?;
/// let commands = vec![
///     PushCommand::new(main.clone(), None, first),
///     PushCommand::new(main, None, second),
/// ];
///
/// let objects = repo.objects_with(PackLimits::default())?;
/// let result = PreparedPush::new(
///     &objects,
///     commands,
///     &[],
///     PushLimits::default(),
///     &AtomicBool::new(false),
/// );
///
/// assert!(matches!(result, Err(PushFailure::Command(_))));
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PushFailure {
    /// Private receive storage could not be removed; inspect the retained operation failure and
    /// report before retrying. Primary publication may already have completed.
    #[error("receive quarantine cleanup failed")]
    QuarantineCleanup {
        /// Private directory whose cleanup was refused or failed; inspect it before removing data.
        path: std::path::PathBuf,
        /// Filesystem cleanup failure.
        #[source]
        source: std::io::Error,
        /// Earlier operation failure, if one occurred before cleanup. This may itself be
        /// [`PushError::Uncertain`] with published refs; inspect its report as well.
        failure: Option<Box<PushError>>,
    },

    /// Native receive integrity policy refused the pack before hooks or installation.
    #[error("receive integrity policy")]
    Integrity(#[source] Box<crate::fsck::TransferError>),
    /// Native local destination could not be opened.
    #[error("local destination")]
    Destination(#[source] Box<crate::OpenError>),
    /// Native object installation failed before reference publication.
    #[error("local object installation")]
    Install(#[source] Box<crate::fetch::FetchError>),
    /// Native reference publication failed after mutation may have begun.
    #[error("local reference publication")]
    Reference(#[source] Box<crate::refs::ReferenceError>),
    /// Explicit reflog identity is invalid for a new record.
    #[error("invalid push reflog identity")]
    Identity,
    /// A supplied identity uses a different object format from the source repository.
    #[error(transparent)]
    ObjectFormat(#[from] crate::ObjectFormatError),
    /// Sanitized OpenSSH transport or service failure.
    #[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
    #[error(transparent)]
    Ssh(crate::transport::ssh::SshError),
    /// Sanitized smart HTTP exchange failure.
    #[cfg(feature = "http")]
    #[error(transparent)]
    Http(crate::transport::http::HttpError),
    /// I/O failure; protocol interruption is returned without retrying.
    #[error("push I/O")]
    Io(#[source] std::io::Error),
    /// Malformed or unexpected receive-pack framing or state.
    #[error("invalid receive-pack response: {0}")]
    Protocol(&'static str),
    /// Unsupported protocol, format, command namespace or required capability.
    #[error("unsupported push feature: {0}")]
    Unsupported(&'static str),
    /// Peer ERR packet, not a per-reference rejection report.
    #[error("receive-pack error: {0:?}")]
    Remote(Vec<u8>),
    /// Resource bound exhausted.
    #[error("push limit exceeded: {0}")]
    Limit(&'static str),
    /// Cancellation observed between operations.
    #[error("push cancelled")]
    Cancelled,
    /// The owned transport reached its caller-supplied deadline.
    #[error("transport deadline expired")]
    Deadline,
    /// A root used for exclusion is no longer advertised. No commands were sent.
    #[error("receiver history root is not advertised: {0}")]
    KnowledgeChanged(ObjectId),
    /// Duplicate destination, zero ID, or other invalid command.
    #[error("invalid push command: {0}")]
    Command(&'static str),
    /// Non-fast-forward branch update or tag replacement without explicit force.
    #[error("replacement requires explicit force for {0:?}")]
    WouldForce(RefName),
    /// Missing selected tip or reachable object.
    #[error("missing reachable object {0}")]
    Missing(ObjectId),
    /// Branch tip or reachable edge has the wrong object type.
    #[error("wrong reachable object kind for {0}")]
    Kind(ObjectId),
    /// Bounded object storage read failed.
    #[error("push object {id}")]
    Read {
        /// Reachable object being read.
        id: ObjectId,
        /// Concrete storage failure.
        #[source]
        source: crate::ObjectReadError,
    },
    /// Pack construction failed before sending commands.
    #[error("push pack")]
    Pack(#[from] crate::PackWriteError),
    /// Reachable commit payload invalid or unsupported.
    #[error("push commit {id}")]
    Commit {
        /// Object whose payload failed validation.
        id: ObjectId,
        /// Concrete payload validation failure.
        #[source]
        source: crate::CommitError,
    },
    /// Reachable tree payload invalid or unsupported.
    #[error("push tree {id}")]
    Tree {
        /// Object whose payload failed validation.
        id: ObjectId,
        /// Concrete payload validation failure.
        #[source]
        source: crate::TreeError,
    },
    /// Reachable tag payload invalid or unsupported.
    #[error("push tag {id}")]
    Tag {
        /// Object whose payload failed validation.
        id: ObjectId,
        /// Concrete payload validation failure.
        #[source]
        source: crate::TagError,
    },
    /// Local receive-pack exited unsuccessfully, even if some results were acknowledged.
    #[error("local receive-pack exited unsuccessfully: {0}")]
    Process(std::process::ExitStatus),
}
impl PushFailure {
    pub(super) fn destination(error: crate::OpenError) -> Self {
        Self::Destination(Box::new(error))
    }

    pub(super) fn install(error: crate::fetch::FetchError) -> Self {
        Self::Install(Box::new(error))
    }

    pub(super) fn reference(error: crate::refs::ReferenceError) -> Self {
        Self::Reference(Box::new(error))
    }
}
impl From<crate::packet::Error> for PushFailure {
    fn from(error: crate::packet::Error) -> Self {
        match error {
            crate::packet::Error::Io(e) => Self::from(e),
            crate::packet::Error::Protocol(e) => Self::Protocol(e),
            crate::packet::Error::Limit(e) => Self::Limit(e),
            crate::packet::Error::Cancelled => Self::Cancelled,
            crate::packet::Error::Remote(e) => Self::Remote(e),
        }
    }
}

impl From<std::io::Error> for PushFailure {
    fn from(error: std::io::Error) -> Self {
        match crate::transport::interruption(&error) {
            Some(crate::transport::Interruption::Cancelled) => Self::Cancelled,
            Some(crate::transport::Interruption::Deadline) => Self::Deadline,
            None => Self::Io(error),
        }
    }
}

#[cfg(feature = "http")]
impl From<crate::transport::http::HttpError> for PushFailure {
    fn from(error: crate::transport::http::HttpError) -> Self {
        match error {
            crate::transport::http::HttpError::Cancelled => Self::Cancelled,
            crate::transport::http::HttpError::Deadline => Self::Deadline,
            error => Self::Http(error),
        }
    }
}

#[cfg(all(feature = "ssh", any(target_os = "macos", target_os = "linux")))]
impl From<crate::transport::ssh::SshError> for PushFailure {
    fn from(error: crate::transport::ssh::SshError) -> Self {
        match error {
            crate::transport::ssh::SshError::Cancelled => Self::Cancelled,
            crate::transport::ssh::SshError::Deadline => Self::Deadline,
            error => Self::Ssh(error),
        }
    }
}

impl From<crate::edges::Error> for PushFailure {
    fn from(error: crate::edges::Error) -> Self {
        match error {
            crate::edges::Error::Commit { id, source } => Self::Commit { id, source },
            crate::edges::Error::Tree { id, source } => Self::Tree { id, source },
            crate::edges::Error::Tag { id, source } => Self::Tag { id, source },
        }
    }
}