Skip to main content

sley_remote/
push.rs

1//! Callable push orchestration for HTTP(S) and local (`file://`/path) remotes.
2//!
3//! [`push`] sequences the moved transport plumbing ([`crate::http`],
4//! [`crate::local`]) and the protocol codecs ([`sley_protocol`]) into the full
5//! push flow: it advertises the remote's refs, plans the receive-pack commands
6//! for the requested refspecs, rejects non-fast-forward updates (unless forced),
7//! builds the packfile of the objects the remote is missing, sends the
8//! receive-pack request, and parses the report-status. Everything is taken as
9//! explicit parameters — `git_dir`, `common_git_dir`, the [`ObjectFormat`], the
10//! repository [`GitConfig`], the already-resolved destination, the push refspecs,
11//! a [`PushOptions`], and the seam objects ([`CredentialProvider`],
12//! [`ProgressSink`]) — so it never reads process-global state, parses arguments,
13//! or prints. The structured result ([`PushOutcome`]) carries the executed
14//! receive-pack commands and the remote's report-status for the caller to format
15//! into git's "To <remote>" summary and to drive any set-upstream config write.
16//!
17//! SSH push still lives in the CLI; only HTTP and local move here. The
18//! push-planning helpers are shared (the CLI's SSH path calls the same `pub`
19//! functions) so there is a single implementation.
20
21use std::fs;
22use std::io::{Cursor, Read};
23use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use sley_config::GitConfig;
27use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
28use sley_object::ObjectType;
29use sley_odb::{
30    FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
31    collect_reachable_object_ids,
32};
33#[cfg(feature = "http")]
34use sley_protocol::{
35    GitService, parse_receive_pack_features, smart_http_rpc_request_content_type,
36    smart_http_rpc_result_content_type,
37};
38use sley_protocol::{
39    PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackCommandStatusV2,
40    ReceivePackCommandStatusV2Options, ReceivePackFeatures, ReceivePackPushRequest,
41    ReceivePackPushRequestHeader, ReceivePackPushRequestOptions, ReceivePackReportStatus,
42    ReceivePackReportStatusV2, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
43    RefSpec, parse_refspec, plan_push_commands, read_and_demux_sideband_stream,
44    read_receive_pack_report_status, read_receive_pack_report_status_v2,
45};
46use crate::proc_receive::ProcReceiveReport;
47
48use crate::pack::push_pack_roots;
49use crate::pack::{PushPackRequest, write_push_packfile};
50#[cfg(feature = "http")]
51use crate::pack::write_receive_pack_body;
52use crate::proc_receive::{ReceivePackCommandState, parse_proc_receive_refs, proc_receive_ref_matches};
53
54use crate::receive_pack_server::{
55    ReceivePackServerOptions, ReceivePackServerReport, ReceivePackServerRequest, serve_receive_pack,
56};
57use sley_refs::{FileRefStore, Ref, RefTarget};
58use sley_transport::RemoteUrl;
59#[cfg(feature = "http")]
60use sley_transport::{HttpClient, HttpResponse, http_smart_rpc_url};
61
62use crate::{CredentialProvider, ProgressSink};
63
64/// How a push delivers refs and objects to the remote.
65///
66/// The caller resolves the remote (URL rewriting, `pushurl` selection,
67/// repository discovery — all process-state dependent) and hands `push` a
68/// concrete transport.
69pub enum PushDestination {
70    /// A smart-HTTP(S) remote at the given already-resolved URL.
71    Http(RemoteUrl),
72    /// An SSH remote at the given already-resolved URL. Pushed by spawning `ssh`
73    /// (the credential seam is unused — the `ssh` program owns authentication).
74    Ssh(RemoteUrl),
75    /// A native anonymous `git://` remote at the given already-resolved URL.
76    Git(RemoteUrl),
77    /// A local repository served in-process from `git_dir`.
78    Local {
79        /// The remote repository's `$GIT_DIR`.
80        git_dir: PathBuf,
81        /// The remote repository's common `$GIT_DIR` (object format source).
82        common_git_dir: PathBuf,
83    },
84}
85
86/// Whether push pack generation may use thin-pack deltas against remote objects.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub enum PushThinMode {
89    /// Git's default: request/use thin packs unless the remote advertises
90    /// `no-thin`.
91    #[default]
92    Auto,
93    /// Explicit `--thin`; still respects a remote `no-thin` advertisement.
94    Always,
95    /// Explicit `--no-thin`.
96    Never,
97}
98
99impl PushThinMode {
100    pub(crate) fn wants_thin(self) -> bool {
101        !matches!(self, Self::Never)
102    }
103}
104
105/// Controls for a [`push`] run, mirroring the `git push` flags the CLI parses
106/// that affect the wire/planning behavior the library owns.
107///
108/// `set-upstream` (`-u`) is intentionally absent: it only writes
109/// `branch.<name>.remote`/`merge` config, which is a caller concern (the library
110/// returns the executed commands in [`PushOutcome::commands`] so the caller can
111/// drive that write). Atomic / push-options are likewise absent because the
112/// CLI's HTTP and local push paths accept but do not act on them today.
113#[derive(Debug, Clone, Copy, Default)]
114pub struct PushOptions {
115    /// Suppress the per-command side-effect of negotiating the `quiet`
116    /// receive-pack capability (matching `git push --quiet`). Output suppression
117    /// itself is a caller concern — the library always returns the outcome.
118    pub quiet: bool,
119    /// Force every update, bypassing the non-fast-forward check. Per-refspec `+`
120    /// forces are honored independently of this flag.
121    pub force: bool,
122    /// Thin-pack behavior for transports that send a real receive-pack body.
123    pub thin: PushThinMode,
124}
125
126/// One caller-authored receive-pack command.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct PushCommand {
129    /// The object id to install at `dst`, or `None` for a delete.
130    pub src: Option<ObjectId>,
131    /// Full destination ref name.
132    pub dst: String,
133    /// The expected remote old object id. `None` lowers to the zero oid, which
134    /// receive-pack treats as create-only for updates and unconditional for
135    /// deletes.
136    pub expected_old: Option<ObjectId>,
137    /// Bypass the non-fast-forward check for this command. This mirrors a
138    /// refspec-local leading `+`; [`PushOptions::force`] still forces every
139    /// command in the plan.
140    pub force: bool,
141}
142
143/// A typed push action that preserves the caller's exact old/new/delete intent.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum PushAction {
146    Create {
147        dst: String,
148        new: ObjectId,
149    },
150    Update {
151        dst: String,
152        old: ObjectId,
153        new: ObjectId,
154    },
155    Delete {
156        dst: String,
157        old: Option<ObjectId>,
158    },
159}
160
161impl From<PushAction> for PushCommand {
162    fn from(value: PushAction) -> Self {
163        match value {
164            PushAction::Create { dst, new } => Self {
165                src: Some(new),
166                dst,
167                expected_old: None,
168                force: false,
169            },
170            PushAction::Update { dst, old, new } => Self {
171                src: Some(new),
172                dst,
173                expected_old: Some(old),
174                force: false,
175            },
176            PushAction::Delete { dst, old } => Self {
177                src: None,
178                dst,
179                expected_old: old,
180                force: false,
181            },
182        }
183    }
184}
185
186/// A caller-authored push plan. This is distinct from [`PushPlan`], which is a
187/// negotiated, executable transport token returned by [`plan_push`].
188#[derive(Debug, Clone)]
189pub struct PushActionPlan {
190    pub commands: Vec<PushCommand>,
191    pub pack_objects: Vec<ObjectId>,
192    pub options: PushOptions,
193}
194
195impl PushActionPlan {
196    pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
197        Self {
198            commands: actions.into_iter().map(PushCommand::from).collect(),
199            pack_objects: Vec::new(),
200            options,
201        }
202    }
203
204    pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
205        Self {
206            commands,
207            pack_objects: Vec::new(),
208            options,
209        }
210    }
211
212    pub fn from_commands_and_infer_pack_roots(
213        commands: Vec<PushCommand>,
214        options: PushOptions,
215    ) -> Self {
216        let mut pack_objects = Vec::new();
217        for command in &commands {
218            let Some(src) = command.src.as_ref() else {
219                continue;
220            };
221            if !pack_objects.contains(src) {
222                pack_objects.push(*src);
223            }
224        }
225        Self {
226            commands,
227            pack_objects,
228            options,
229        }
230    }
231}
232
233/// Receive-pack report-status in either wire format.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum ReceivePackPushReport {
236    V1(ReceivePackReportStatus),
237    V2(ReceivePackReportStatusV2),
238}
239
240impl ReceivePackPushReport {
241    pub fn unpack_failed(&self) -> Option<&str> {
242        match self {
243            Self::V1(report) => match &report.unpack {
244                ReceivePackUnpackStatus::Ok => None,
245                ReceivePackUnpackStatus::Error(message) => Some(message.as_str()),
246            },
247            Self::V2(report) => match &report.unpack {
248                ReceivePackUnpackStatus::Ok => None,
249                ReceivePackUnpackStatus::Error(message) => Some(message.as_str()),
250            },
251        }
252    }
253}
254
255/// The structured result of a [`push`].
256#[derive(Debug, Clone, Default)]
257pub struct PushOutcome {
258    /// The receive-pack commands that were executed, in planning order. Each
259    /// carries the ref name and its old/new object id; the caller formats these
260    /// into git's "To <remote>" summary and uses them to drive set-upstream.
261    /// Empty when nothing matched the refspecs (a no-op push).
262    pub commands: Vec<ReceivePackCommand>,
263    /// The remote's report-status, when one was requested and received.
264    pub report: Option<ReceivePackPushReport>,
265    /// Hook/progress bytes from a sideband receive-pack response.
266    pub remote_progress: Vec<u8>,
267}
268
269/// Per-ref outcome of a push, mirroring git's `enum ref_status` so the CLI can
270/// reproduce `transport_print_push_status` byte-for-byte. `Ok` covers create,
271/// update, forced update, and delete (disambiguated by the old/new ids on the
272/// owning [`PushReportRef`]); the remaining variants are the rejection reasons.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum PushRefStatus {
275    /// The update was (or would be, under `--dry-run`) applied.
276    Ok,
277    /// The ref was already at the requested value; nothing to do.
278    UpToDate,
279    /// Local-side rejection: a non-forced non-fast-forward branch update.
280    RejectNonFastForward,
281    /// Local-side rejection: the remote tip is not present locally.
282    RejectFetchFirst,
283    /// `--force-with-lease`/`--force-if-includes` expectation was not met.
284    RejectStale,
285    /// `--force-if-includes`: tracking ref was updated but not integrated.
286    RejectRemoteUpdated,
287    /// Non-forced tag update where the remote tag already exists.
288    RejectAlreadyExists,
289    /// The receive-pack side reported `ng <ref> <message>`.
290    RemoteReject(String),
291    /// Part of an `--atomic` push that failed because a sibling ref was rejected.
292    AtomicPushFailed,
293}
294
295/// One ref's line in git's push status report. Carries everything
296/// `print_one_push_report` needs: the source ("from") ref, the destination
297/// ("to") ref, the old/new object ids, whether the update was forced, whether it
298/// is a deletion, and the classified [`PushRefStatus`].
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct PushReportRef {
301    /// The local source ref name (git's `ref->peer_ref->name`), e.g.
302    /// `refs/heads/main`. `None` for a deletion (git prints `:dst`).
303    pub src: Option<String>,
304    /// The destination ref name (git's `ref->name`), e.g. `refs/heads/main`.
305    pub dst: String,
306    /// The remote's old object id for `dst` (zero for a create).
307    pub old_id: ObjectId,
308    /// The object id installed at `dst` (zero for a delete).
309    pub new_id: ObjectId,
310    /// True when the update overwrote a non-fast-forward (git's `forced_update`).
311    pub forced: bool,
312    /// The classified outcome.
313    pub status: PushRefStatus,
314    /// Proc-receive `report-status-v2` rewrites for this ref (one line per report).
315    pub reports: Vec<ProcReceiveReport>,
316}
317
318impl PushReportRef {
319    /// Receive-pack commands for remote-tracking updates, honouring proc-receive
320    /// rewrites when present.
321    pub fn tracking_commands(&self) -> Vec<ReceivePackCommand> {
322        if self.reports.is_empty() {
323            return vec![ReceivePackCommand {
324                old_id: self.old_id,
325                new_id: self.new_id,
326                name: self.dst.clone(),
327            }];
328        }
329        self.reports
330            .iter()
331            .map(|report| ReceivePackCommand {
332                old_id: report.old_oid.unwrap_or(self.old_id),
333                new_id: report.new_oid.unwrap_or(self.new_id),
334                name: report
335                    .refname
336                    .clone()
337                    .unwrap_or_else(|| self.dst.clone()),
338            })
339            .collect()
340    }
341
342    /// Whether this ref is a deletion (new id is the zero oid).
343    pub fn is_deletion(&self) -> bool {
344        self.new_id.is_null()
345    }
346
347    /// Whether this ref's status counts as a push error (git's `push_had_errors`:
348    /// anything that is not `Ok`/`UpToDate`/none).
349    pub fn had_error(&self) -> bool {
350        !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
351    }
352}
353
354/// The full result of a push as git's transport layer models it: every ref's
355/// classified status, ready to be rendered into the "To <url>" report and used
356/// to decide the process exit code and the `pull-before-push` advice.
357#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct PushStatusReport {
359    /// Every requested ref, in planning order.
360    pub refs: Vec<PushReportRef>,
361}
362
363impl PushStatusReport {
364    /// True when any ref was rejected (git's overall push error flag).
365    pub fn had_errors(&self) -> bool {
366        self.refs.iter().any(PushReportRef::had_error)
367    }
368
369    /// True when at least one ref was actually updated (git's
370    /// `transport_refs_pushed`): used to print "Everything up-to-date".
371    pub fn refs_pushed(&self) -> bool {
372        self.refs.iter().any(|reference| {
373            reference.old_id != reference.new_id && matches!(reference.status, PushRefStatus::Ok)
374        })
375    }
376}
377
378/// Fully resolved inputs for a [`push`] run.
379#[derive(Clone, Copy)]
380pub struct PushRequest<'a> {
381    /// Local repository `$GIT_DIR`.
382    pub git_dir: &'a Path,
383    /// Local repository common `$GIT_DIR`, used for object access.
384    pub common_git_dir: &'a Path,
385    /// Local repository object format.
386    pub format: ObjectFormat,
387    /// Local repository config snapshot.
388    pub config: &'a GitConfig,
389    /// Remote name or source string, used for diagnostics.
390    pub remote: &'a str,
391    /// Already-resolved push destination.
392    pub destination: &'a PushDestination,
393    /// Refspecs requested by the caller.
394    pub refspecs: &'a [String],
395    /// Push behavior flags.
396    pub options: &'a PushOptions,
397}
398
399/// Fully resolved inputs for a caller-authored exact push plan.
400#[derive(Clone, Copy)]
401pub struct PushActionRequest<'a> {
402    /// Local repository `$GIT_DIR`.
403    pub git_dir: &'a Path,
404    /// Local repository common `$GIT_DIR`, used for object access.
405    pub common_git_dir: &'a Path,
406    /// Local repository object format.
407    pub format: ObjectFormat,
408    /// Local repository config snapshot.
409    pub config: &'a GitConfig,
410    /// Remote name or source string, used for diagnostics.
411    pub remote: &'a str,
412    /// Already-resolved push destination.
413    pub destination: &'a PushDestination,
414    /// Caller-authored exact push plan.
415    pub plan: &'a PushActionPlan,
416}
417
418/// Mutable seams used while pushing.
419pub struct PushServices<'a> {
420    /// Credential source for authenticated transports.
421    pub credentials: &'a mut dyn CredentialProvider,
422    /// Progress sink reserved for future push progress.
423    pub progress: &'a mut dyn ProgressSink,
424}
425
426/// A push after ref negotiation and command planning, but before any ref update
427/// is sent or applied.
428pub struct PushPlan {
429    /// The receive-pack commands that will be executed if the caller proceeds.
430    pub commands: Vec<ReceivePackCommand>,
431    execution: PushExecution,
432}
433
434enum PushExecution {
435    Noop,
436    #[cfg(feature = "http")]
437    Http {
438        http_batch: crate::http::HttpOperationBatch,
439        remote_url: RemoteUrl,
440        features: ReceivePackFeatures,
441        advertisements: Vec<RefAdvertisement>,
442        pack_objects: Vec<ObjectId>,
443    },
444    Ssh(crate::ssh::SshPushPlan),
445    Git(crate::git::GitPushPlan),
446    Local {
447        remote_git_dir: PathBuf,
448        remote_common_git_dir: PathBuf,
449        remote_refs: Vec<RefAdvertisement>,
450        command_forces: Vec<(ReceivePackCommand, bool)>,
451        pack_objects: Vec<ObjectId>,
452    },
453}
454
455/// Push `refspecs` to a resolved `destination` from the repository at `git_dir`.
456///
457/// Performs the work the CLI's `push_http_repository`/`push_local_repository`
458/// did: advertises the remote's refs, plans the receive-pack commands for
459/// `refspecs`, rejects non-fast-forward branch updates (unless forced), builds
460/// the pack of objects the remote lacks, sends the receive-pack request, parses
461/// and validates the report-status, and returns the executed commands. `remote`
462/// is the remote/argument the caller resolved `destination` from (used only for
463/// error messages here).
464///
465/// Returns the structured [`PushOutcome`]; never prints or returns
466/// `GitError::Exit`. A still-`None` report in the outcome means the remote did
467/// not advertise `report-status`. Set-upstream config and the "To <remote>"
468/// summary are the caller's job, driven from [`PushOutcome::commands`].
469pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
470    let plan = plan_push(request, &mut services)?;
471    execute_push_plan(request, &mut services, plan)
472}
473
474/// Push a caller-authored exact plan, preserving its old/new/delete command ids.
475pub fn push_actions(
476    request: PushActionRequest<'_>,
477    mut services: PushServices<'_>,
478) -> Result<PushOutcome> {
479    let plan = plan_push_actions(request, &mut services)?;
480    execute_push_action_plan(request, &mut services, plan)
481}
482
483/// Negotiate with the remote and compute the receive-pack command list without
484/// sending a pack or applying a ref update.
485pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
486    // `config` and `progress` are part of the seam (mirroring `fetch`) but the
487    // current push flow drives credentials from the caller-built provider and
488    // returns its summary in `PushOutcome` rather than streaming progress, so
489    // progress is not consumed yet. Kept named for the public API and future use.
490    let _ = &mut services.progress;
491    crate::protocol::check_transport_allowed(
492        scheme_for_push_destination(request.destination),
493        Some(request.config),
494        None,
495    )
496    .map_err(crate::protocol::transport_policy_git_error)?;
497    match request.destination {
498        #[cfg(feature = "http")]
499        PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
500            git_dir: request.git_dir,
501            common_git_dir: request.common_git_dir,
502            format: request.format,
503            config: request.config,
504            remote_url,
505            refspecs: request.refspecs,
506            options: request.options,
507            credentials: services.credentials,
508        }),
509        #[cfg(not(feature = "http"))]
510        PushDestination::Http(_) => Err(GitError::Unsupported(
511            "HTTP transport is not enabled in this build".into(),
512        )),
513        PushDestination::Ssh(remote_url) => {
514            let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
515                git_dir: request.git_dir,
516                common_git_dir: request.common_git_dir,
517                format: request.format,
518                remote: remote_url,
519                refspecs: request.refspecs,
520                force: request.options.force,
521            })?;
522            let commands = plan.commands.clone();
523            let execution = if commands.is_empty() {
524                PushExecution::Noop
525            } else {
526                PushExecution::Ssh(plan)
527            };
528            Ok(PushPlan {
529                commands,
530                execution,
531            })
532        }
533        PushDestination::Git(remote_url) => {
534            let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
535                git_dir: request.git_dir,
536                common_git_dir: request.common_git_dir,
537                format: request.format,
538                remote: remote_url,
539                config: Some(request.config),
540                refspecs: request.refspecs,
541                force: request.options.force,
542            })?;
543            let commands = plan.commands.clone();
544            let execution = if commands.is_empty() {
545                PushExecution::Noop
546            } else {
547                PushExecution::Git(plan)
548            };
549            Ok(PushPlan {
550                commands,
551                execution,
552            })
553        }
554        PushDestination::Local {
555            git_dir: remote_git_dir,
556            common_git_dir: remote_common_git_dir,
557        } => plan_push_local(PushLocalRequest {
558            git_dir: request.git_dir,
559            common_git_dir: request.common_git_dir,
560            format: request.format,
561            remote: request.remote,
562            remote_git_dir,
563            remote_common_git_dir,
564            refspecs: request.refspecs,
565            options: request.options,
566        }),
567    }
568}
569
570/// Negotiate with the remote and bind a caller-authored exact push plan to a
571/// transport execution token.
572pub fn plan_push_actions(
573    request: PushActionRequest<'_>,
574    services: &mut PushServices<'_>,
575) -> Result<PushPlan> {
576    let _ = &mut services.progress;
577    crate::protocol::check_transport_allowed(
578        scheme_for_push_destination(request.destination),
579        Some(request.config),
580        None,
581    )
582    .map_err(crate::protocol::transport_policy_git_error)?;
583    let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
584    let command_forces = commands
585        .iter()
586        .cloned()
587        .zip(request.plan.commands.iter())
588        .map(|(command, planned)| (command, request.plan.options.force || planned.force))
589        .collect::<Vec<_>>();
590    match request.destination {
591        #[cfg(feature = "http")]
592        PushDestination::Http(remote_url) => {
593            let http_batch = crate::http::HttpOperationBatch::new();
594            let discovered = crate::http::http_service_advertisements(
595                http_batch.client(),
596                remote_url,
597                request.format,
598                GitService::ReceivePack,
599                services.credentials,
600                Some(request.config),
601            )?;
602            let advertisement_set = discovered.set;
603            let features = advertised_receive_pack_features(&advertisement_set.refs)?;
604            verify_remote_object_format(&features, request.format)?;
605            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
606            reject_non_fast_forward_pushes(
607                request.common_git_dir,
608                &local_db,
609                request.format,
610                &command_forces,
611            )?;
612            let execution = if commands.is_empty() {
613                PushExecution::Noop
614            } else {
615                PushExecution::Http {
616                    http_batch,
617                    remote_url: remote_url.clone(),
618                    features,
619                    advertisements: advertisement_set.refs,
620                    pack_objects: request.plan.pack_objects.clone(),
621                }
622            };
623            Ok(PushPlan {
624                commands,
625                execution,
626            })
627        }
628        #[cfg(not(feature = "http"))]
629        PushDestination::Http(_) => Err(GitError::Unsupported(
630            "HTTP transport is not enabled in this build".into(),
631        )),
632        PushDestination::Ssh(remote_url) => {
633            let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
634                common_git_dir: request.common_git_dir,
635                format: request.format,
636                remote: remote_url,
637                command_forces: command_forces.clone(),
638                pack_objects: request.plan.pack_objects.clone(),
639            })?;
640            let commands = plan.commands.clone();
641            let execution = if commands.is_empty() {
642                PushExecution::Noop
643            } else {
644                PushExecution::Ssh(plan)
645            };
646            Ok(PushPlan {
647                commands,
648                execution,
649            })
650        }
651        PushDestination::Git(remote_url) => {
652            let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
653                common_git_dir: request.common_git_dir,
654                format: request.format,
655                remote: remote_url,
656                config: Some(request.config),
657                command_forces: command_forces.clone(),
658                pack_objects: request.plan.pack_objects.clone(),
659            })?;
660            let commands = plan.commands.clone();
661            let execution = if commands.is_empty() {
662                PushExecution::Noop
663            } else {
664                PushExecution::Git(plan)
665            };
666            Ok(PushPlan {
667                commands,
668                execution,
669            })
670        }
671        PushDestination::Local {
672            git_dir: remote_git_dir,
673            common_git_dir: remote_common_git_dir,
674        } => {
675            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
676            if remote_format != request.format {
677                return Err(GitError::InvalidObjectId(format!(
678                    "remote repository uses {}, local repository uses {}",
679                    remote_format.name(),
680                    request.format.name()
681                )));
682            }
683            let remote_refs =
684                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
685            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
686            reject_non_fast_forward_pushes(
687                request.common_git_dir,
688                &local_db,
689                request.format,
690                &command_forces,
691            )?;
692            let execution = if commands.is_empty() {
693                PushExecution::Noop
694            } else {
695                PushExecution::Local {
696                    remote_git_dir: remote_git_dir.to_path_buf(),
697                    remote_common_git_dir: remote_common_git_dir.to_path_buf(),
698                    remote_refs,
699                    command_forces,
700                    pack_objects: request.plan.pack_objects.clone(),
701                }
702            };
703            Ok(PushPlan {
704                commands,
705                execution,
706            })
707        }
708    }
709}
710
711fn scheme_for_push_destination(destination: &PushDestination) -> &'static str {
712    match destination {
713        PushDestination::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
714        PushDestination::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
715        PushDestination::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
716        PushDestination::Local { .. } => "file",
717    }
718}
719
720/// Execute a previously planned push.
721pub fn execute_push_plan(
722    request: PushRequest<'_>,
723    services: &mut PushServices<'_>,
724    plan: PushPlan,
725) -> Result<PushOutcome> {
726    let _ = (request.config, request.remote);
727    let _ = &mut services.progress;
728    if plan.commands.is_empty() {
729        return Ok(PushOutcome::default());
730    }
731    match plan.execution {
732        PushExecution::Noop => Ok(PushOutcome::default()),
733        #[cfg(feature = "http")]
734        PushExecution::Http {
735            http_batch,
736            remote_url,
737            features,
738            advertisements,
739            pack_objects,
740        } => execute_push_http(
741            request,
742            services.credentials,
743            http_batch,
744            plan.commands,
745            remote_url,
746            features,
747            advertisements,
748            pack_objects,
749        ),
750        PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
751        PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
752        PushExecution::Local {
753            remote_git_dir,
754            remote_common_git_dir,
755            remote_refs,
756            command_forces,
757            pack_objects,
758        } => execute_push_local(
759            request,
760            plan.commands,
761            remote_git_dir,
762            remote_common_git_dir,
763            remote_refs,
764            command_forces,
765            pack_objects,
766        ),
767    }
768}
769
770/// Execute a previously negotiated exact push plan.
771pub fn execute_push_action_plan(
772    request: PushActionRequest<'_>,
773    services: &mut PushServices<'_>,
774    plan: PushPlan,
775) -> Result<PushOutcome> {
776    let refspecs: &[String] = &[];
777    execute_push_plan(
778        PushRequest {
779            git_dir: request.git_dir,
780            common_git_dir: request.common_git_dir,
781            format: request.format,
782            config: request.config,
783            remote: request.remote,
784            destination: request.destination,
785            refspecs,
786            options: &request.plan.options,
787        },
788        services,
789        plan,
790    )
791}
792
793/// Push to a smart-HTTP(S) remote: advertise via receive-pack info/refs, plan,
794/// build the pack, POST the receive-pack RPC, and validate the report-status.
795#[cfg(feature = "http")]
796struct PushHttpRequest<'a> {
797    git_dir: &'a Path,
798    common_git_dir: &'a Path,
799    format: ObjectFormat,
800    config: &'a GitConfig,
801    remote_url: &'a RemoteUrl,
802    refspecs: &'a [String],
803    options: &'a PushOptions,
804    credentials: &'a mut dyn CredentialProvider,
805}
806
807#[cfg(feature = "http")]
808fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
809    let PushHttpRequest {
810        git_dir,
811        common_git_dir,
812        format,
813        config,
814        remote_url,
815        refspecs,
816        options,
817        credentials,
818    } = request;
819    let http_batch = crate::http::HttpOperationBatch::new();
820    let discovered = crate::http::http_service_advertisements(
821        http_batch.client(),
822        remote_url,
823        format,
824        GitService::ReceivePack,
825        credentials,
826        Some(config),
827    )?;
828    let advertisement_set = discovered.set;
829    let features = advertised_receive_pack_features(&advertisement_set.refs)?;
830    verify_remote_object_format(&features, format)?;
831
832    let local_store = FileRefStore::new(git_dir, format);
833    let mut local_refs = local_push_source_refs(&local_store, format)?;
834    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
835    let command_forces = plan_push_command_forces(
836        format,
837        &local_refs,
838        &advertisement_set.refs,
839        refspecs,
840        options.force,
841    )?;
842    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
843    reject_non_fast_forward_pushes(common_git_dir, &local_db, format, &command_forces)?;
844    let commands = commands_from_forces(&command_forces);
845    let execution = if commands.is_empty() {
846        PushExecution::Noop
847    } else {
848        PushExecution::Http {
849            http_batch,
850            remote_url: remote_url.clone(),
851            features,
852            advertisements: advertisement_set.refs,
853            pack_objects: Vec::new(),
854        }
855    };
856    Ok(PushPlan {
857        commands,
858        execution,
859    })
860}
861
862#[cfg(feature = "http")]
863fn execute_push_http(
864    request: PushRequest<'_>,
865    credentials: &mut dyn CredentialProvider,
866    http_batch: crate::http::HttpOperationBatch,
867    commands: Vec<ReceivePackCommand>,
868    remote_url: RemoteUrl,
869    features: ReceivePackFeatures,
870    advertisements: Vec<RefAdvertisement>,
871    pack_objects: Vec<ObjectId>,
872) -> Result<PushOutcome> {
873    let client = http_batch.client();
874    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
875    let pack_request = PushPackRequest {
876        local_db: &local_db,
877        format: request.format,
878        commands: &commands,
879        pack_objects: &pack_objects,
880        remote_advertisements: &advertisements,
881        features: &features,
882        options: receive_pack_push_options(
883            &features,
884            request.format,
885            request.options.quiet,
886            false,
887            &[],
888        ),
889        thin: request.options.thin.wants_thin(),
890    };
891    let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
892    let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
893    let post_buffer = http_post_buffer(request.config);
894    let git_protocol = crate::http::http_git_protocol_header_value_for_service(
895        Some(request.config),
896        GitService::ReceivePack,
897    )?;
898    let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
899        let headers = crate::http::http_request_headers(auth, git_protocol.as_deref());
900        send_receive_pack_body(
901            client,
902            &url,
903            &content_type,
904            &headers,
905            &pack_request,
906            post_buffer,
907        )
908    })?;
909    crate::http::http_check_status(&response, &url)?;
910    crate::http::http_validate_content_type(
911        &response,
912        &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
913    )?;
914
915    let mut remote_progress = Vec::new();
916    let report = if features.report_status || features.report_status_v2 {
917        let report = read_receive_pack_push_report_with_progress(
918            request.format,
919            &mut response.body,
920            features.report_status_v2,
921            features.side_band_64k,
922            Some(&mut remote_progress),
923        )?;
924        validate_receive_pack_unpack(&report)?;
925        Some(report)
926    } else {
927        let mut sink = Vec::new();
928        response.body.read_to_end(&mut sink)?;
929        None
930    };
931    Ok(PushOutcome {
932        commands,
933        report,
934        remote_progress,
935    })
936}
937
938/// git's `http.postBuffer` (default 1 MiB): a receive-pack request body that
939/// fits within this many bytes is sent buffered with `Content-Length`; a larger
940/// body is streamed with chunked transfer-encoding. Matching git here keeps the
941/// common (small) push retry-safe under auth challenges while bounding memory
942/// for large pushes.
943#[cfg(feature = "http")]
944fn http_post_buffer(config: &GitConfig) -> usize {
945    const DEFAULT_POST_BUFFER: usize = 1 << 20;
946    config
947        .get("http", None, "postBuffer")
948        .and_then(parse_post_buffer)
949        .filter(|bytes| *bytes > 0)
950        .unwrap_or(DEFAULT_POST_BUFFER)
951}
952
953/// Parse a git size value (`http.postBuffer`): a decimal byte count with an
954/// optional `k`/`m`/`g` binary-unit suffix.
955#[cfg(feature = "http")]
956fn parse_post_buffer(raw: &str) -> Option<usize> {
957    let raw = raw.trim();
958    let (digits, multiplier) = match raw.as_bytes().last() {
959        Some(b'k' | b'K') => (&raw[..raw.len() - 1], 1024usize),
960        Some(b'm' | b'M') => (&raw[..raw.len() - 1], 1024 * 1024),
961        Some(b'g' | b'G') => (&raw[..raw.len() - 1], 1024 * 1024 * 1024),
962        _ => (raw, 1),
963    };
964    digits
965        .trim()
966        .parse::<usize>()
967        .ok()
968        .and_then(|value| value.checked_mul(multiplier))
969}
970
971/// Send the receive-pack request body, choosing buffered (`Content-Length`) vs
972/// streamed (chunked) delivery by `post_buffer`. The body is generated on a
973/// scoped thread that pipes into the HTTP client, so a large pack is never held
974/// fully in memory. A genuine generation failure is surfaced in preference to a
975/// downstream transport error on a truncated body.
976#[cfg(feature = "http")]
977fn send_receive_pack_body(
978    client: &dyn HttpClient,
979    url: &str,
980    content_type: &str,
981    headers: &[(&str, &str)],
982    pack_request: &PushPackRequest<'_>,
983    post_buffer: usize,
984) -> Result<HttpResponse> {
985    std::thread::scope(|scope| {
986        let (mut reader, writer) = std::io::pipe().map_err(|err| GitError::Io(err.to_string()))?;
987        let generator = scope.spawn(move || -> Result<()> {
988            // `writer` is dropped at the end of this closure, signalling EOF to
989            // the reader even on the error path.
990            let mut writer = writer;
991            write_receive_pack_body(pack_request, &mut writer)
992        });
993
994        // Probe up to `post_buffer + 1` bytes to decide buffered vs chunked
995        // without first materialising the whole body.
996        let mut probe = Vec::new();
997        read_up_to(&mut reader, post_buffer.saturating_add(1), &mut probe)?;
998
999        if probe.len() <= post_buffer {
1000            // Whole body fits the probe: the generator has reached EOF. Surface
1001            // any generation error before sending, then send with Content-Length
1002            // (re-runnable under auth retry).
1003            join_pack_generator(generator)?;
1004            client.post(url, content_type, headers, &probe)
1005        } else {
1006            // Large body: stream the probe followed by the rest of the pipe with
1007            // chunked encoding. Scope `body` so the pipe reader is dropped before
1008            // joining — a transport that stops early then unblocks the generator
1009            // via a broken pipe instead of deadlocking the join.
1010            let response = {
1011                let mut body = std::io::Cursor::new(probe).chain(reader);
1012                client.post_reader(url, content_type, headers, &mut body)
1013            };
1014            let generation = join_pack_generator(generator);
1015            match response {
1016                // An HTTP response (including 401) drives the caller's status and
1017                // auth-retry handling; the body was consumed, so prefer it.
1018                Ok(response) => Ok(response),
1019                Err(transport) => match generation {
1020                    Err(generation) => Err(generation),
1021                    Ok(()) => Err(transport),
1022                },
1023            }
1024        }
1025    })
1026}
1027
1028/// Join the receive-pack body generator thread, flattening a panic into an I/O
1029/// error and propagating the generator's own `Result`.
1030#[cfg(feature = "http")]
1031fn join_pack_generator(handle: std::thread::ScopedJoinHandle<'_, Result<()>>) -> Result<()> {
1032    match handle.join() {
1033        Ok(result) => result,
1034        Err(_) => Err(GitError::Io(
1035            "receive-pack body generator thread panicked".to_string(),
1036        )),
1037    }
1038}
1039
1040/// Read from `reader` into `out` until `cap` bytes are buffered or EOF.
1041#[cfg(feature = "http")]
1042fn read_up_to(reader: &mut impl Read, cap: usize, out: &mut Vec<u8>) -> Result<()> {
1043    let mut chunk = [0u8; 8192];
1044    while out.len() < cap {
1045        let want = (cap - out.len()).min(chunk.len());
1046        let read = reader
1047            .read(&mut chunk[..want])
1048            .map_err(|err| GitError::Io(err.to_string()))?;
1049        if read == 0 {
1050            break;
1051        }
1052        out.extend_from_slice(&chunk[..read]);
1053    }
1054    Ok(())
1055}
1056
1057/// Push to a local repository served in-process: advertise from the remote
1058/// `git_dir`, plan, build the pack against the remote's reachable objects, and
1059/// apply the receive-pack request directly.
1060struct PushLocalRequest<'a> {
1061    git_dir: &'a Path,
1062    common_git_dir: &'a Path,
1063    format: ObjectFormat,
1064    remote: &'a str,
1065    remote_git_dir: &'a Path,
1066    remote_common_git_dir: &'a Path,
1067    refspecs: &'a [String],
1068    options: &'a PushOptions,
1069}
1070
1071fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
1072    let PushLocalRequest {
1073        git_dir,
1074        common_git_dir,
1075        format,
1076        remote,
1077        remote_git_dir,
1078        remote_common_git_dir,
1079        refspecs,
1080        options,
1081    } = request;
1082    let _ = remote;
1083    let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
1084    if remote_format != format {
1085        return Err(GitError::InvalidObjectId(format!(
1086            "remote repository uses {}, local repository uses {}",
1087            remote_format.name(),
1088            format.name()
1089        )));
1090    }
1091
1092    let local_store = FileRefStore::new(git_dir, format);
1093    let mut local_refs = local_push_source_refs(&local_store, format)?;
1094    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
1095    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
1096    let command_forces =
1097        plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
1098    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
1099    reject_non_fast_forward_pushes(common_git_dir, &local_db, format, &command_forces)?;
1100    let commands = commands_from_forces(&command_forces);
1101    let execution = if commands.is_empty() {
1102        PushExecution::Noop
1103    } else {
1104        PushExecution::Local {
1105            remote_git_dir: remote_git_dir.to_path_buf(),
1106            remote_common_git_dir: remote_common_git_dir.to_path_buf(),
1107            remote_refs,
1108            command_forces,
1109            pack_objects: Vec::new(),
1110        }
1111    };
1112    Ok(PushPlan {
1113        commands,
1114        execution,
1115    })
1116}
1117
1118fn execute_push_local(
1119    request: PushRequest<'_>,
1120    commands: Vec<ReceivePackCommand>,
1121    remote_git_dir: PathBuf,
1122    remote_common_git_dir: PathBuf,
1123    remote_refs: Vec<RefAdvertisement>,
1124    _command_forces: Vec<(ReceivePackCommand, bool)>,
1125    pack_objects: Vec<ObjectId>,
1126) -> Result<PushOutcome> {
1127    let remote_excluded_tips =
1128        remote_excluded_tip_roots(&remote_git_dir, request.format, &remote_refs)?;
1129    let starts = push_pack_roots(&commands, &pack_objects);
1130    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
1131    let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
1132    let remote_excluded =
1133        collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
1134
1135    // git's `transfer.fsckObjects`: the receiving side fscks every object the
1136    // push introduces and rejects the push when one fails (most importantly a
1137    // malicious `.gitmodules` url). The local fast path copies objects directly
1138    // rather than through `index-pack --strict`, so run the same gate here.
1139    if remote_transfer_fsck_objects(&remote_common_git_dir) {
1140        fsck_pushed_objects(&local_db, request.format, &starts, &remote_excluded)?;
1141    }
1142    let remote_config =
1143        sley_config::read_repo_config(&remote_git_dir, None).unwrap_or_default();
1144    let report = if push_local_uses_receive_pack_server(
1145        &remote_config,
1146        &remote_git_dir,
1147        &commands,
1148    ) {
1149        local_push_via_receive_pack_server(LocalPushViaReceivePackRequest {
1150            remote_git_dir: &remote_git_dir,
1151            source_common_git_dir: request.common_git_dir,
1152            format: request.format,
1153            commands: &commands,
1154            push_options: &[],
1155            atomic: false,
1156            quiet: request.options.quiet,
1157            remote_advertisements: &remote_refs,
1158            remote_stderr: None,
1159        })?
1160    } else {
1161        let receive_request = ReceivePackPushRequest {
1162            commands: ReceivePackRequest {
1163                shallow: Vec::new(),
1164                commands: commands.clone(),
1165                capabilities: Vec::new(),
1166            },
1167            push_options: None,
1168            packfile: if starts.is_empty() {
1169                Vec::new()
1170            } else {
1171                b"PACK".to_vec()
1172            },
1173        };
1174        ReceivePackPushReport::V1(crate::local::receive_pack_reachable_pack_into_local_repository(
1175            &remote_git_dir,
1176            request.format,
1177            &receive_request,
1178            &local_db,
1179            starts,
1180            remote_excluded,
1181        )?)
1182    };
1183    validate_receive_pack_unpack(&report)?;
1184    Ok(PushOutcome {
1185        commands,
1186        report: Some(report),
1187        remote_progress: Vec::new(),
1188    })
1189}
1190
1191/// Whether the local remote enables `transfer.fsckObjects` (the receiving-side
1192/// fsck gate). Reads only the remote's own config.
1193fn remote_transfer_fsck_objects(remote_common_git_dir: &Path) -> bool {
1194    GitConfig::read(remote_common_git_dir.join("config"))
1195        .ok()
1196        .and_then(|config| config.get_bool("transfer", None, "fsckObjects"))
1197        .unwrap_or(false)
1198}
1199
1200/// Disposable object directory used to expose incoming local-push objects to
1201/// receive-side hooks without making them part of the destination repository.
1202pub struct PushQuarantine {
1203    object_dir: PathBuf,
1204}
1205
1206impl PushQuarantine {
1207    pub fn object_dir(&self) -> &Path {
1208        &self.object_dir
1209    }
1210}
1211
1212impl Drop for PushQuarantine {
1213    fn drop(&mut self) {
1214        let _ = fs::remove_dir_all(&self.object_dir);
1215    }
1216}
1217
1218pub fn stage_local_push_quarantine(
1219    remote_git_dir: &Path,
1220    remote_common_git_dir: &Path,
1221    format: ObjectFormat,
1222    source_db: &FileObjectDatabase,
1223    commands: &[ReceivePackCommand],
1224) -> Result<Option<PushQuarantine>> {
1225    let starts = push_pack_roots(commands, &[]);
1226    if starts.is_empty() {
1227        return Ok(None);
1228    }
1229    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
1230    let remote_excluded_tips = remote_excluded_tip_roots(remote_git_dir, format, &remote_refs)?;
1231    let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, format);
1232    let remote_excluded = collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1233    let object_dir = create_push_quarantine_object_dir(remote_common_git_dir)?;
1234    let quarantine_db = FileObjectDatabase::new(object_dir.clone(), format);
1235    let installed = match build_and_install_reachable_pack(
1236        source_db,
1237        &quarantine_db,
1238        format,
1239        starts,
1240        &remote_excluded,
1241        RawPackInstallOptions {
1242            promisor: false,
1243            ..Default::default()
1244        },
1245    ) {
1246        Ok(installed) => installed,
1247        Err(err) => {
1248            let _ = fs::remove_dir_all(&object_dir);
1249            return Err(err);
1250        }
1251    };
1252    if installed.is_none() {
1253        let _ = fs::remove_dir_all(&object_dir);
1254        return Ok(None);
1255    }
1256    Ok(Some(PushQuarantine { object_dir }))
1257}
1258
1259fn create_push_quarantine_object_dir(remote_common_git_dir: &Path) -> Result<PathBuf> {
1260    let objects_dir = remote_common_git_dir.join("objects");
1261    fs::create_dir_all(&objects_dir)?;
1262    let nanos = SystemTime::now()
1263        .duration_since(UNIX_EPOCH)
1264        .map(|duration| duration.as_nanos())
1265        .unwrap_or(0);
1266    for attempt in 0..100 {
1267        let object_dir = objects_dir.join(format!(
1268            "tmp_objdir-incoming-{}-{nanos}-{attempt}",
1269            std::process::id()
1270        ));
1271        match fs::create_dir(&object_dir) {
1272            Ok(()) => {
1273                fs::create_dir_all(object_dir.join("pack"))?;
1274                fs::create_dir_all(object_dir.join("info"))?;
1275                return Ok(object_dir);
1276            }
1277            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1278            Err(err) => return Err(GitError::Io(err.to_string())),
1279        }
1280    }
1281    Err(GitError::Io(
1282        "could not create push quarantine object directory".into(),
1283    ))
1284}
1285
1286fn remote_excluded_tip_roots(
1287    remote_git_dir: &Path,
1288    format: ObjectFormat,
1289    remote_refs: &[RefAdvertisement],
1290) -> Result<Vec<ObjectId>> {
1291    let mut tips = remote_refs
1292        .iter()
1293        .map(|reference| reference.oid)
1294        .collect::<Vec<_>>();
1295    append_remote_alternate_ref_tips(remote_git_dir, format, &mut tips)?;
1296    Ok(tips)
1297}
1298
1299fn append_remote_alternate_ref_tips(
1300    remote_git_dir: &Path,
1301    format: ObjectFormat,
1302    tips: &mut Vec<ObjectId>,
1303) -> Result<()> {
1304    let alternates = remote_git_dir.join("objects/info/alternates");
1305    let Ok(text) = fs::read_to_string(alternates) else {
1306        return Ok(());
1307    };
1308    for raw in text.lines() {
1309        let line = raw.trim();
1310        if line.is_empty() || line.starts_with('#') {
1311            continue;
1312        }
1313        let objects_dir = if Path::new(line).is_absolute() {
1314            PathBuf::from(line)
1315        } else {
1316            remote_git_dir.join("objects").join(line)
1317        };
1318        let Some(alternate_git_dir) = objects_dir.parent() else {
1319            continue;
1320        };
1321        let store = FileRefStore::new(alternate_git_dir, format);
1322        let refs = match store.list_refs() {
1323            Ok(refs) => refs,
1324            Err(_) => continue,
1325        };
1326        for reference in refs {
1327            let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
1328                continue;
1329            };
1330            if !tips.contains(&oid) {
1331                tips.push(oid);
1332            }
1333        }
1334    }
1335    Ok(())
1336}
1337
1338/// Run fsck over the objects this push introduces (reachable from `starts`,
1339/// minus what the remote already has). On any error-severity finding, print it
1340/// and reject the push — git's `transfer.fsckObjects` behavior.
1341fn fsck_pushed_objects(
1342    local_db: &FileObjectDatabase,
1343    format: ObjectFormat,
1344    starts: &[ObjectId],
1345    remote_excluded: &std::collections::HashSet<ObjectId>,
1346) -> Result<()> {
1347    if starts.is_empty() {
1348        return Ok(());
1349    }
1350    let new_objects: Vec<ObjectId> =
1351        collect_reachable_object_ids(local_db, format, starts.to_vec())?
1352            .into_iter()
1353            .filter(|oid| !remote_excluded.contains(oid))
1354            .collect();
1355    // The reader is the COMPLETE local db so link-walks never spuriously report
1356    // a "missing object" for something the remote already holds; only genuine
1357    // content errors (e.g. a disallowed .gitmodules url) in the new objects fail.
1358    let report = sley_fsck::fsck_objects(local_db, format, [], new_objects);
1359    if report.is_ok() {
1360        return Ok(());
1361    }
1362    for issue in &report.issues {
1363        if issue.severity == sley_fsck::IssueSeverity::Error {
1364            eprintln!("fatal: {}", issue.message);
1365        }
1366    }
1367    Err(GitError::Exit(128))
1368}
1369
1370/// Fully resolved inputs for a status-reporting push to a local repository.
1371pub struct PushReportRequest<'a> {
1372    /// Local repository `$GIT_DIR`.
1373    pub git_dir: &'a Path,
1374    /// Local repository common `$GIT_DIR`, used for object access.
1375    pub common_git_dir: &'a Path,
1376    /// Local repository object format.
1377    pub format: ObjectFormat,
1378    /// The remote repository's `$GIT_DIR`.
1379    pub remote_git_dir: &'a Path,
1380    /// The remote repository's common `$GIT_DIR`.
1381    pub remote_common_git_dir: &'a Path,
1382    /// Refspecs requested by the caller (already URL/repo resolved).
1383    pub refspecs: &'a [String],
1384    /// Force every update (the `--force` flag).
1385    pub force: bool,
1386    /// `--atomic`: send nothing if any ref would be rejected.
1387    pub atomic: bool,
1388    /// `--dry-run`: classify and report, but do not send or update.
1389    pub dry_run: bool,
1390    /// Per-ref `--force-with-lease` expectations: `(dst, expected_old)`. An
1391    /// `expected_old` of `None` means "the remote ref must not exist".
1392    pub force_with_lease: &'a [(String, Option<ObjectId>)],
1393    /// `--force-with-lease` with no per-ref value: lease every pushed ref against
1394    /// its remote-tracking ref (git's implicit cas). The expected value per dst
1395    /// is supplied via [`Self::force_with_lease`]; this flag only governs whether
1396    /// a lease was requested at all (used for the "no actual ref" diagnostics).
1397    pub force_with_lease_default: bool,
1398    /// `--force-if-includes`: for tracking-based leases, reject when the current
1399    /// remote tip is not included in the local branch's reflog/history.
1400    pub force_if_includes: bool,
1401    /// Receive-pack-side config values supplied by the invoked receive-pack
1402    /// command, e.g. `--receive-pack="git -c receive.denyDeletes=false receive-pack"`.
1403    pub receive_config_overrides: &'a [(String, String)],
1404    /// Push options negotiated with receive-pack (`--push-option` / config).
1405    pub push_options: &'a [String],
1406    /// When set, receive-side hook stderr is captured for `remote:` output.
1407    pub remote_stderr: Option<&'a mut Vec<u8>>,
1408    /// Suppress receive-pack `quiet` capability negotiation.
1409    pub quiet: bool,
1410}
1411
1412/// Push to a local repository, returning git's per-ref status report instead of
1413/// failing on the first rejection. Performs the client-side checks git's
1414/// send-pack does — non-fast-forward and `--force-with-lease` (stale info) — then
1415/// (unless `--dry-run`) sends the surviving commands and folds the receive-pack
1416/// report-status back into each ref. With `--atomic`, a single client-side
1417/// rejection turns every other ref into [`PushRefStatus::AtomicPushFailed`] and
1418/// nothing is sent. The caller renders the report and derives the exit code.
1419pub fn push_local_with_report(
1420    request: PushReportRequest<'_>,
1421    _config: &GitConfig,
1422) -> Result<PushStatusReport> {
1423    let format = request.format;
1424    let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
1425    if remote_format != format {
1426        return Err(GitError::InvalidObjectId(format!(
1427            "remote repository uses {}, local repository uses {}",
1428            remote_format.name(),
1429            format.name()
1430        )));
1431    }
1432    let local_store = FileRefStore::new(request.git_dir, format);
1433    let mut local_refs = local_push_source_refs(&local_store, format)?;
1434    add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
1435    let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
1436    let planned = plan_push_command_sources(
1437        format,
1438        &local_refs,
1439        &remote_refs,
1440        request.refspecs,
1441        request.force,
1442    )?;
1443    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
1444    let remote_config =
1445        sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
1446
1447    // Classify each planned command the way git's send-pack does, collecting
1448    // rejections rather than bailing on the first one.
1449    let mut refs: Vec<PushReportRef> = Vec::new();
1450    for plan in &planned {
1451        let status = classify_push_command(
1452            &local_db,
1453            format,
1454            plan,
1455            &request,
1456            &remote_config,
1457            request.remote_git_dir,
1458        )?;
1459        // git's `forced_update` reflects either an actual rewind or a rejection
1460        // reason (e.g. stale lease) that was overridden by --force.
1461        let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1462        let forced = matches!(status, PushRefStatus::Ok)
1463            && !plan.command.old_id.is_null()
1464            && !plan.command.new_id.is_null()
1465            && (stale_lease_overridden
1466                || if plan.command.name.starts_with("refs/heads/") {
1467                    !is_fast_forward(
1468                        request.common_git_dir,
1469                        &local_db,
1470                        format,
1471                        &plan.command.old_id,
1472                        &plan.command.new_id,
1473                    )?
1474                } else {
1475                    plan.force
1476                });
1477        refs.push(PushReportRef {
1478            src: plan.source.clone(),
1479            dst: plan.command.name.clone(),
1480            old_id: plan.command.old_id,
1481            new_id: plan.command.new_id,
1482            forced,
1483            status,
1484            reports: Vec::new(),
1485        });
1486    }
1487
1488    let any_local_reject = refs.iter().any(|reference| {
1489        matches!(
1490            reference.status,
1491            PushRefStatus::RejectNonFastForward
1492                | PushRefStatus::RejectFetchFirst
1493                | PushRefStatus::RejectStale
1494                | PushRefStatus::RejectRemoteUpdated
1495                | PushRefStatus::RejectAlreadyExists
1496        )
1497    });
1498
1499    // `--atomic`: if any ref was rejected client-side, send nothing and mark all
1500    // would-be-OK refs as atomic-push-failed (git's REF_STATUS_ATOMIC_PUSH_FAILED).
1501    // UpToDate refs are *not* converted — git leaves them reported as up to date.
1502    if request.atomic && any_local_reject {
1503        for reference in &mut refs {
1504            if matches!(reference.status, PushRefStatus::Ok) {
1505                reference.status = PushRefStatus::AtomicPushFailed;
1506            }
1507        }
1508        return Ok(PushStatusReport { refs });
1509    }
1510
1511    if request.dry_run {
1512        return Ok(PushStatusReport { refs });
1513    }
1514
1515    // Send only the commands that survived client-side checks.
1516    let send: Vec<ReceivePackCommand> = refs
1517        .iter()
1518        .filter(|reference| {
1519            matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1520        })
1521        .map(|reference| ReceivePackCommand {
1522            old_id: reference.old_id,
1523            new_id: reference.new_id,
1524            name: reference.dst.clone(),
1525        })
1526        .collect();
1527
1528    if !send.is_empty() {
1529        let remote_excluded_tips =
1530            remote_excluded_tip_roots(request.remote_git_dir, format, &remote_refs)?;
1531        let pack_objects: Vec<ObjectId> = Vec::new();
1532        let starts = push_pack_roots(&send, &pack_objects);
1533        let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1534        let remote_excluded =
1535            collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1536        // git's `transfer.fsckObjects`: fsck the introduced objects on the
1537        // receiving side and reject the push on a content error (a disallowed
1538        // `.gitmodules` url, a malformed object, ...).
1539        if remote_transfer_fsck_objects(request.remote_common_git_dir) {
1540            fsck_pushed_objects(&local_db, format, &starts, &remote_excluded)?;
1541        }
1542        let remote_config =
1543            sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
1544        let report = if push_local_uses_receive_pack_server(
1545            &remote_config,
1546            request.remote_git_dir,
1547            &send,
1548        ) {
1549            local_push_via_receive_pack_server(LocalPushViaReceivePackRequest {
1550                remote_git_dir: request.remote_git_dir,
1551                source_common_git_dir: request.common_git_dir,
1552                format,
1553                commands: &send,
1554                push_options: request.push_options,
1555                atomic: request.atomic,
1556                quiet: request.quiet,
1557                remote_advertisements: &remote_refs,
1558                remote_stderr: request.remote_stderr,
1559            })?
1560        } else {
1561            let receive_request = ReceivePackPushRequest {
1562                commands: ReceivePackRequest {
1563                    shallow: Vec::new(),
1564                    commands: send.clone(),
1565                    capabilities: Vec::new(),
1566                },
1567                push_options: None,
1568                packfile: if starts.is_empty() {
1569                    Vec::new()
1570                } else {
1571                    b"PACK".to_vec()
1572                },
1573            };
1574            ReceivePackPushReport::V1(crate::local::receive_pack_reachable_pack_into_local_repository(
1575                request.remote_git_dir,
1576                format,
1577                &receive_request,
1578                &local_db,
1579                starts,
1580                remote_excluded,
1581            )?)
1582        };
1583        apply_receive_pack_report_to_push_refs(&mut refs, &report);
1584    }
1585
1586    Ok(PushStatusReport { refs })
1587}
1588
1589/// Classify one planned command into git's send-pack pre-flight status: an
1590/// up-to-date no-op, a non-fast-forward rejection, a `--force-with-lease` stale
1591/// rejection, or `Ok` (the command will be sent).
1592fn classify_push_command(
1593    local_db: &FileObjectDatabase,
1594    format: ObjectFormat,
1595    plan: &PlannedPushCommand,
1596    request: &PushReportRequest<'_>,
1597    config: &GitConfig,
1598    remote_git_dir: &Path,
1599) -> Result<PushRefStatus> {
1600    let command = &plan.command;
1601
1602    if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1603        let reason = if command.new_id.is_null() {
1604            "deny deleting a hidden ref"
1605        } else {
1606            "deny updating a hidden ref"
1607        };
1608        return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1609    }
1610
1611    // No change: the remote already has exactly this value (and it is not a
1612    // create-from-nothing of a non-existent ref). git reports UPTODATE.
1613    if command.old_id == command.new_id && !command.new_id.is_null() {
1614        return Ok(PushRefStatus::UpToDate);
1615    }
1616
1617    if command.new_id.is_null() && !command.old_id.is_null() {
1618        if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1619            .unwrap_or(false)
1620        {
1621            return Ok(PushRefStatus::RemoteReject(
1622                "deletion prohibited".to_string(),
1623            ));
1624        }
1625        if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1626            return Ok(PushRefStatus::RemoteReject(
1627                "deletion of the current branch prohibited".to_string(),
1628            ));
1629        }
1630    }
1631
1632    if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1633        let object = local_db.read_object(&command.new_id)?;
1634        if object.object_type != ObjectType::Commit {
1635            return Ok(PushRefStatus::RemoteReject(
1636                "invalid new value provided".to_string(),
1637            ));
1638        }
1639    }
1640
1641    // `--force-with-lease`: the remote's current value must match the lease, or
1642    // the push is rejected as stale info — checked before the non-ff gate and
1643    // independent of `--force`.
1644    if let Some((_, expected)) = request
1645        .force_with_lease
1646        .iter()
1647        .find(|(dst, _)| *dst == command.name)
1648    {
1649        let actual = if command.old_id.is_null() {
1650            None
1651        } else {
1652            Some(command.old_id)
1653        };
1654        if *expected != actual {
1655            if plan.force {
1656                return Ok(PushRefStatus::Ok);
1657            }
1658            return Ok(PushRefStatus::RejectStale);
1659        }
1660        if request.force_if_includes
1661            && !command.old_id.is_null()
1662            && (command.new_id.is_null()
1663                || !is_fast_forward(
1664                    request.common_git_dir,
1665                    local_db,
1666                    format,
1667                    &command.old_id,
1668                    &command.new_id,
1669                )?)
1670            && force_if_includes_rejects(
1671                request.common_git_dir,
1672                local_db,
1673                format,
1674                request.git_dir,
1675                &command.name,
1676                &command.old_id,
1677            )?
1678        {
1679            if plan.force {
1680                return Ok(PushRefStatus::Ok);
1681            }
1682            return Ok(PushRefStatus::RejectRemoteUpdated);
1683        }
1684        // A satisfied lease forces the update.
1685        return Ok(PushRefStatus::Ok);
1686    }
1687
1688    if command.name.starts_with("refs/heads/")
1689        && !command.old_id.is_null()
1690        && !command.new_id.is_null()
1691        && !is_fast_forward(
1692            request.common_git_dir,
1693            local_db,
1694            format,
1695            &command.old_id,
1696            &command.new_id,
1697        )?
1698        && receive_config_bool(
1699            config,
1700            request.receive_config_overrides,
1701            "denynonfastforwards",
1702        )
1703        .unwrap_or(false)
1704    {
1705        return Ok(PushRefStatus::RemoteReject(format!(
1706            "denying non-fast-forward {} (you should pull first)",
1707            command.name
1708        )));
1709    }
1710
1711    // Non-fast-forward branch update: rejected unless forced. Creations,
1712    // deletions, and non-branch refs skip this gate (matching git's send-pack).
1713    if !plan.force
1714        && command.name.starts_with("refs/tags/")
1715        && !command.old_id.is_null()
1716        && !command.new_id.is_null()
1717    {
1718        return Ok(PushRefStatus::RejectAlreadyExists);
1719    }
1720
1721    if !plan.force
1722        && command.name.starts_with("refs/heads/")
1723        && !command.old_id.is_null()
1724        && !command.new_id.is_null()
1725    {
1726        if !local_db.contains(&command.old_id)? {
1727            return Ok(PushRefStatus::RejectFetchFirst);
1728        }
1729        if !is_fast_forward(
1730            request.common_git_dir,
1731            local_db,
1732            format,
1733            &command.old_id,
1734            &command.new_id,
1735        )? {
1736            return Ok(PushRefStatus::RejectNonFastForward);
1737        }
1738    }
1739
1740    if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1741        return Ok(PushRefStatus::RemoteReject(
1742            "branch is currently checked out".to_string(),
1743        ));
1744    }
1745
1746    Ok(PushRefStatus::Ok)
1747}
1748
1749fn receive_ref_is_hidden(
1750    config: &GitConfig,
1751    overrides: &[(String, String)],
1752    refname: &str,
1753) -> bool {
1754    let mut hide_refs = Vec::new();
1755    hide_refs.extend(hidden_ref_values(config, "transfer", None));
1756    hide_refs.extend(hidden_ref_values(config, "receive", None));
1757    hide_refs.extend(
1758        overrides
1759            .iter()
1760            .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1761            .map(|(_, value)| trim_hidden_ref_pattern(value)),
1762    );
1763    ref_is_hidden_by_patterns(refname, &hide_refs)
1764}
1765
1766fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1767    config
1768        .get_all(section, subsection, "hiderefs")
1769        .into_iter()
1770        .flatten()
1771        .map(trim_hidden_ref_pattern)
1772        .collect()
1773}
1774
1775fn trim_hidden_ref_pattern(value: &str) -> String {
1776    value.trim_end_matches('/').to_string()
1777}
1778
1779fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1780    for pattern in patterns.iter().rev() {
1781        let mut pattern = pattern.as_str();
1782        let negated = pattern.strip_prefix('!').is_some();
1783        if negated {
1784            pattern = &pattern[1..];
1785        }
1786        if let Some(rest) = pattern.strip_prefix('^') {
1787            pattern = rest;
1788        }
1789        if hidden_ref_pattern_matches(refname, pattern) {
1790            return !negated;
1791        }
1792    }
1793    false
1794}
1795
1796fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1797    refname
1798        .strip_prefix(pattern)
1799        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1800}
1801
1802fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1803    let command = &plan.command;
1804    let actual = if command.old_id.is_null() {
1805        None
1806    } else {
1807        Some(command.old_id)
1808    };
1809    request
1810        .force_with_lease
1811        .iter()
1812        .find(|(dst, _)| *dst == command.name)
1813        .is_some_and(|(_, expected)| *expected != actual)
1814}
1815
1816fn force_if_includes_rejects(
1817    common_git_dir: &Path,
1818    db: &FileObjectDatabase,
1819    format: ObjectFormat,
1820    git_dir: &Path,
1821    local_ref: &str,
1822    remote_old: &ObjectId,
1823) -> Result<bool> {
1824    let store = FileRefStore::new(git_dir, format);
1825    let mut candidates = Vec::new();
1826    match store.read_ref(local_ref)? {
1827        Some(RefTarget::Direct(oid)) => candidates.push(oid),
1828        Some(RefTarget::Symbolic(target)) => {
1829            if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1830                candidates.push(oid);
1831            }
1832        }
1833        None => return Ok(false),
1834    }
1835    for entry in store.read_reflog(local_ref)? {
1836        if !entry.new_oid.is_null() {
1837            candidates.push(entry.new_oid);
1838        }
1839    }
1840    candidates.sort();
1841    candidates.dedup();
1842    for candidate in candidates {
1843        if candidate == *remote_old {
1844            return Ok(false);
1845        }
1846        if let Ok(ancestors) = sley_rev::ancestor_depths(common_git_dir, format, db, &candidate)
1847            && ancestors.contains_key(remote_old)
1848        {
1849            return Ok(false);
1850        }
1851    }
1852    Ok(true)
1853}
1854
1855fn receive_config_bool(
1856    config: &GitConfig,
1857    overrides: &[(String, String)],
1858    key: &str,
1859) -> Option<bool> {
1860    overrides
1861        .iter()
1862        .rev()
1863        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1864        .and_then(|(_, value)| sley_config::parse_config_bool(value))
1865        .or_else(|| config.get_bool("receive", None, key))
1866}
1867
1868fn receive_denies_current_branch(
1869    format: ObjectFormat,
1870    command: &ReceivePackCommand,
1871    config: &GitConfig,
1872    remote_git_dir: &Path,
1873) -> Result<bool> {
1874    if command.new_id.is_null() {
1875        return Ok(false);
1876    }
1877    if !command.name.starts_with("refs/heads/") {
1878        return Ok(false);
1879    }
1880    let deny = config
1881        .get("receive", None, "denycurrentbranch")
1882        .unwrap_or("refuse");
1883    let denies = matches!(
1884        deny.to_ascii_lowercase().as_str(),
1885        "true" | "yes" | "on" | "1" | "refuse"
1886    );
1887    if !denies {
1888        return Ok(false);
1889    }
1890    if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1891        return Ok(false);
1892    }
1893    let store = FileRefStore::new(remote_git_dir, format);
1894    Ok(matches!(
1895        store.read_ref("HEAD")?,
1896        Some(RefTarget::Symbolic(target)) if target == command.name
1897    ))
1898}
1899
1900fn receive_targets_current_branch(
1901    format: ObjectFormat,
1902    command: &ReceivePackCommand,
1903    remote_git_dir: &Path,
1904) -> Result<bool> {
1905    if !command.name.starts_with("refs/heads/") {
1906        return Ok(false);
1907    }
1908    if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1909        return Ok(false);
1910    }
1911    let store = FileRefStore::new(remote_git_dir, format);
1912    Ok(matches!(
1913        store.read_ref("HEAD")?,
1914        Some(RefTarget::Symbolic(target)) if target == command.name
1915    ))
1916}
1917
1918fn receive_denies_current_branch_delete(
1919    format: ObjectFormat,
1920    command: &ReceivePackCommand,
1921    config: &GitConfig,
1922    request: &PushReportRequest<'_>,
1923    remote_git_dir: &Path,
1924) -> Result<bool> {
1925    if !receive_targets_current_branch(format, command, remote_git_dir)? {
1926        return Ok(false);
1927    }
1928    let deny = request
1929        .receive_config_overrides
1930        .iter()
1931        .rev()
1932        .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1933        .map(|(_, value)| value.as_str())
1934        .or_else(|| config.get("receive", None, "denydeletecurrent"))
1935        .unwrap_or("refuse");
1936    Ok(!matches!(
1937        deny.to_ascii_lowercase().as_str(),
1938        "ignore" | "warn" | "false" | "no" | "off" | "0"
1939    ))
1940}
1941
1942/// Whether `old` is an ancestor of `new` (a fast-forward). A walk from `new`;
1943/// `old` reachable ⇒ fast-forward.
1944pub(crate) fn is_fast_forward(
1945    common_git_dir: &Path,
1946    db: &FileObjectDatabase,
1947    format: ObjectFormat,
1948    old: &ObjectId,
1949    new: &ObjectId,
1950) -> Result<bool> {
1951    let ancestors = sley_rev::ancestor_depths(common_git_dir, format, db, new)?;
1952    Ok(ancestors.contains_key(old))
1953}
1954
1955/// Parse the receive-pack features from the leading ref advertisement (the empty
1956/// default when the remote advertised no refs).
1957#[cfg(feature = "http")]
1958fn advertised_receive_pack_features(
1959    advertisements: &[RefAdvertisement],
1960) -> Result<ReceivePackFeatures> {
1961    advertisements
1962        .first()
1963        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1964        .transpose()
1965        .map(Option::unwrap_or_default)
1966}
1967
1968/// Reject a push whose object format disagrees with the remote's advertised
1969/// `object-format`, and require the advertisement for any non-SHA-1 push.
1970#[cfg(feature = "http")]
1971fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1972    if let Some(remote_format) = features.object_format {
1973        if remote_format != format {
1974            return Err(GitError::InvalidObjectId(format!(
1975                "remote repository uses {}, local repository uses {}",
1976                remote_format.name(),
1977                format.name()
1978            )));
1979        }
1980    } else if format != ObjectFormat::Sha1 {
1981        return Err(GitError::InvalidObjectId(format!(
1982            "remote repository did not advertise object-format for {} push",
1983            format.name()
1984        )));
1985    }
1986    Ok(())
1987}
1988
1989/// The receive-pack push-request options for the negotiated `features`, matching
1990/// git: report-status when advertised, ofs-delta when advertised, `quiet` only
1991/// when both requested and advertised, and the advertised object-format only when
1992/// the local repository's `format` is not SHA-1.
1993pub(crate) fn receive_pack_push_options(
1994    features: &ReceivePackFeatures,
1995    format: ObjectFormat,
1996    quiet: bool,
1997    atomic: bool,
1998    push_options: &[String],
1999) -> ReceivePackPushRequestOptions {
2000    ReceivePackPushRequestOptions {
2001        report_status_v2: features.report_status_v2,
2002        report_status: features.report_status && !features.report_status_v2,
2003        ofs_delta: features.ofs_delta,
2004        side_band_64k: features.side_band_64k,
2005        quiet: quiet && features.quiet,
2006        atomic: atomic && features.atomic,
2007        push_options: push_options.to_vec(),
2008        object_format: features
2009            .object_format
2010            .filter(|_| format != ObjectFormat::Sha1),
2011        ..ReceivePackPushRequestOptions::default()
2012    }
2013}
2014
2015/// Whether a local push should use the full receive-pack server (proc-receive).
2016pub fn push_local_uses_receive_pack_server(
2017    config: &GitConfig,
2018    remote_git_dir: &Path,
2019    commands: &[ReceivePackCommand],
2020) -> bool {
2021    let _ = remote_git_dir;
2022    let patterns = parse_proc_receive_refs(config);
2023    if patterns.is_empty() {
2024        return false;
2025    }
2026    commands
2027        .iter()
2028        .any(|command| proc_receive_ref_matches(&patterns, command))
2029}
2030
2031struct LocalPushViaReceivePackRequest<'a> {
2032    remote_git_dir: &'a Path,
2033    source_common_git_dir: &'a Path,
2034    format: ObjectFormat,
2035    commands: &'a [ReceivePackCommand],
2036    push_options: &'a [String],
2037    atomic: bool,
2038    quiet: bool,
2039    remote_advertisements: &'a [RefAdvertisement],
2040    remote_stderr: Option<&'a mut Vec<u8>>,
2041}
2042
2043fn local_push_via_receive_pack_server(
2044    request: LocalPushViaReceivePackRequest<'_>,
2045) -> Result<ReceivePackPushReport> {
2046    let features = crate::local::receive_pack_features(request.format);
2047    let local_db = FileObjectDatabase::from_git_dir(request.source_common_git_dir, request.format);
2048    let pack_request = PushPackRequest {
2049        local_db: &local_db,
2050        format: request.format,
2051        commands: request.commands,
2052        pack_objects: &[],
2053        remote_advertisements: request.remote_advertisements,
2054        features: &features,
2055        options: receive_pack_push_options(
2056            &features,
2057            request.format,
2058            request.quiet,
2059            request.atomic,
2060            request.push_options,
2061        ),
2062        thin: false,
2063    };
2064    let header = sley_protocol::build_receive_pack_push_request_header(
2065        &features,
2066        request.commands.to_vec(),
2067        pack_request.options.clone(),
2068    )?;
2069    let mut packfile = Vec::new();
2070    write_push_packfile(&pack_request, &mut packfile)?;
2071    let config =
2072        sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
2073    let mut pack_reader = Cursor::new(packfile);
2074    let mut discard_stderr = Vec::new();
2075    let capture_stderr = request.remote_stderr.is_some();
2076    let remote_stderr = request
2077        .remote_stderr
2078        .unwrap_or(&mut discard_stderr);
2079    let outcome = serve_receive_pack(ReceivePackServerRequest {
2080        git_dir: request.remote_git_dir,
2081        format: request.format,
2082        header: &ReceivePackPushRequestHeader {
2083            commands: header.commands,
2084            push_options: header.push_options,
2085        },
2086        pack_reader: &mut pack_reader,
2087        config: &config,
2088        options: ReceivePackServerOptions {
2089            quiet: request.quiet,
2090            remote_stderr: if capture_stderr {
2091                Some(remote_stderr)
2092            } else {
2093                None
2094            },
2095            run_post_hooks: false,
2096        },
2097    })?;
2098    Ok(match outcome.report {
2099        ReceivePackServerReport::V1(status) => ReceivePackPushReport::V1(status),
2100        ReceivePackServerReport::V2(status) => ReceivePackPushReport::V2(status),
2101    })
2102}
2103
2104pub fn run_local_push_post_hooks(
2105    remote_git_dir: &Path,
2106    commands: &[ReceivePackCommand],
2107    push_options: &[String],
2108    remote_stderr: &mut Vec<u8>,
2109    capture_stderr: bool,
2110) -> Result<()> {
2111    let landed: Vec<ReceivePackCommand> = commands
2112        .iter()
2113        .filter(|command| command.old_id != command.new_id)
2114        .cloned()
2115        .collect();
2116    if landed.is_empty() {
2117        return Ok(());
2118    }
2119    crate::receive_hooks::run_post_receive(
2120        remote_git_dir,
2121        &landed
2122            .iter()
2123            .map(|command| ReceivePackCommandState::new(command.clone()))
2124            .collect::<Vec<_>>(),
2125        push_options,
2126        remote_stderr,
2127        capture_stderr,
2128    )?;
2129    crate::receive_hooks::run_post_update(
2130        remote_git_dir,
2131        &landed
2132            .iter()
2133            .map(|command| ReceivePackCommandState::new(command.clone()))
2134            .collect::<Vec<_>>(),
2135        remote_stderr,
2136        capture_stderr,
2137    )?;
2138    crate::receive_hooks::run_push_to_checkout(remote_git_dir, remote_stderr, capture_stderr)?;
2139    Ok(())
2140}
2141
2142/// Plan the receive-pack commands for `refspecs`, pairing each with whether it is
2143/// forced (the global `force` flag or the refspec's own `+`). Each refspec is
2144/// normalized then planned independently so per-refspec force is preserved,
2145/// matching the CLI.
2146pub(crate) fn plan_push_command_forces(
2147    format: ObjectFormat,
2148    local_refs: &[PushSourceRef],
2149    remote_refs: &[RefAdvertisement],
2150    refspecs: &[String],
2151    force: bool,
2152) -> Result<Vec<(ReceivePackCommand, bool)>> {
2153    let parsed_refspecs = refspecs
2154        .iter()
2155        .map(|refspec| {
2156            let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
2157            parse_refspec(&normalized)
2158        })
2159        .collect::<Result<Vec<_>>>()?;
2160    let mut command_forces = Vec::new();
2161    for refspec in &parsed_refspecs {
2162        for command in plan_push_commands(
2163            format,
2164            local_refs,
2165            remote_refs,
2166            std::slice::from_ref(refspec),
2167        )? {
2168            command_forces.push((command, force || refspec.force));
2169        }
2170    }
2171    Ok(command_forces)
2172}
2173
2174/// One planned push command paired with its forcing flag and the local source
2175/// ref it came from (git's `ref->peer_ref`). A delete carries `source: None`.
2176struct PlannedPushCommand {
2177    command: ReceivePackCommand,
2178    force: bool,
2179    source: Option<String>,
2180}
2181
2182/// Like [`plan_push_command_forces`], but also records the local source ref each
2183/// command resolved from so the status report can print the `from -> to` line.
2184/// The source is the normalized refspec source name; a delete (`:dst`) has no
2185/// source. A pattern refspec re-derives each expanded command's source from its
2186/// destination by reversing the wildcard substitution.
2187fn plan_push_command_sources(
2188    format: ObjectFormat,
2189    local_refs: &[PushSourceRef],
2190    remote_refs: &[RefAdvertisement],
2191    refspecs: &[String],
2192    force: bool,
2193) -> Result<Vec<PlannedPushCommand>> {
2194    let mut planned = Vec::new();
2195    for refspec in refspecs {
2196        let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
2197        let parsed = parse_refspec(&normalized)?;
2198        let commands = plan_push_commands(
2199            format,
2200            local_refs,
2201            remote_refs,
2202            std::slice::from_ref(&parsed),
2203        )?;
2204        for command in commands {
2205            let source = push_command_source_name(&parsed, &command);
2206            planned.push(PlannedPushCommand {
2207                command,
2208                force: force || parsed.force,
2209                source,
2210            });
2211        }
2212    }
2213    Ok(planned)
2214}
2215
2216/// Recover the local source ref name for one planned `command` from its owning
2217/// `refspec`. Deletes (no `src`) return `None`. A wildcard pattern reverses the
2218/// substitution: the command's destination minus the pattern's destination
2219/// affix yields the matched stem, which slots into the pattern's source affix.
2220fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
2221    let src = refspec.src.as_deref()?;
2222    if !refspec.pattern {
2223        return Some(src.to_string());
2224    }
2225    let (src_prefix, src_suffix) = src.split_once('*')?;
2226    let dst = refspec.dst.as_deref()?;
2227    let (dst_prefix, dst_suffix) = dst.split_once('*')?;
2228    let stem = command
2229        .name
2230        .strip_prefix(dst_prefix)
2231        .and_then(|rest| rest.strip_suffix(dst_suffix))?;
2232    Some(format!("{src_prefix}{stem}{src_suffix}"))
2233}
2234
2235pub(crate) fn add_revision_push_sources(
2236    git_dir: &Path,
2237    format: ObjectFormat,
2238    refspecs: &[String],
2239    local_refs: &mut Vec<PushSourceRef>,
2240) {
2241    for refspec in refspecs {
2242        let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
2243        let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
2244        if src.is_empty() || src == "HEAD" {
2245            continue;
2246        }
2247        if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
2248            continue;
2249        }
2250        if local_refs.iter().any(|reference| {
2251            reference.name == src
2252                || reference.name == format!("refs/heads/{src}")
2253                || reference.name == format!("refs/tags/{src}")
2254        }) {
2255            continue;
2256        }
2257        if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
2258            && !local_refs.iter().any(|reference| reference.name == src)
2259        {
2260            local_refs.push(PushSourceRef {
2261                name: src.to_string(),
2262                oid,
2263            });
2264        }
2265    }
2266}
2267
2268fn normalize_push_refspec_for_sources(
2269    refspec: &str,
2270    local_refs: &[PushSourceRef],
2271    remote_refs: &[RefAdvertisement],
2272) -> Result<String> {
2273    let (force, refspec) = refspec
2274        .strip_prefix('+')
2275        .map_or((false, refspec), |refspec| (true, refspec));
2276    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2277        let (src, src_kind) = normalize_push_source_refname(src, local_refs);
2278        let dst = if src.is_empty() {
2279            normalize_push_delete_destination_refname(dst, remote_refs)?
2280        } else {
2281            normalize_push_destination_refname(dst, src_kind, remote_refs)?
2282        };
2283        if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
2284            return Err(GitError::Command(format!(
2285                "destination refspec {dst} is not a valid ref"
2286            )));
2287        }
2288        format!("{src}:{dst}")
2289    } else {
2290        let (name, _) = normalize_push_source_refname(refspec, local_refs);
2291        // A colon-less refspec re-uses the source's *resolved* full name as the
2292        // implicit destination (git's `match_explicit`: a NULL dst resolves to
2293        // the matched source ref). That full name is then disambiguated against
2294        // the remote's existing refs, so `git push <remote> frotz` (a tag)
2295        // lands on `refs/tags/frotz` even when the remote also has a same-named
2296        // branch.
2297        let dst = match count_refspec_match_dst(&name, remote_refs) {
2298            DstMatch::Unique(matched) => matched.to_string(),
2299            DstMatch::None => name.clone(),
2300            DstMatch::Ambiguous => {
2301                return Err(GitError::Command(format!(
2302                    "dst refspec {name} matches more than one"
2303                )));
2304            }
2305        };
2306        format!("{name}:{dst}")
2307    };
2308    Ok(if force {
2309        format!("+{normalized}")
2310    } else {
2311        normalized
2312    })
2313}
2314
2315/// git's `refname_match`: true when `full_name` equals `abbrev` expanded by one
2316/// of the `ref_rev_parse_rules`. Returns the matched rule's rank (higher = more
2317/// specific) so the caller can replicate git's strong/weak distinction.
2318fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
2319    const RULES: [&str; 6] = [
2320        "{}",
2321        "refs/{}",
2322        "refs/tags/{}",
2323        "refs/heads/{}",
2324        "refs/remotes/{}",
2325        "refs/remotes/{}/HEAD",
2326    ];
2327    for (idx, rule) in RULES.iter().enumerate() {
2328        let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
2329        if full_name == format!("{prefix}{abbrev}{suffix}") {
2330            return Some(RULES.len() - idx);
2331        }
2332    }
2333    None
2334}
2335
2336/// The outcome of git's `count_refspec_match` for a push destination.
2337enum DstMatch<'a> {
2338    /// Exactly one acceptable match (one strong, or zero strong + one weak).
2339    Unique(&'a str),
2340    /// No remote ref matched — the caller should `guess_ref` or use the literal.
2341    None,
2342    /// More than one match — git dies with "dst refspec … matches more than one".
2343    Ambiguous,
2344}
2345
2346/// git's `count_refspec_match` for a push destination: find the unique existing
2347/// remote ref that `pattern` resolves to, distinguishing strong matches (full
2348/// name, top-level, or a head/tag) from weak ones (a partial match outside
2349/// heads/tags, e.g. `origin/main` → `refs/remotes/origin/main`). One strong
2350/// match wins outright; with no strong match a single weak match is used; more
2351/// than one acceptable match is ambiguous.
2352fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
2353    let patlen = pattern.len();
2354    let mut strong: Option<&str> = None;
2355    let mut strong_count = 0usize;
2356    let mut weak: Option<&str> = None;
2357    let mut weak_count = 0usize;
2358    for advert in remote_refs {
2359        let name = advert.name.as_str();
2360        if refname_match_rank(pattern, name).is_none() {
2361            continue;
2362        }
2363        let namelen = name.len();
2364        let is_weak = namelen != patlen
2365            && patlen + 5 != namelen
2366            && !name.starts_with("refs/heads/")
2367            && !name.starts_with("refs/tags/");
2368        if is_weak {
2369            weak = Some(name);
2370            weak_count += 1;
2371        } else {
2372            strong = Some(name);
2373            strong_count += 1;
2374        }
2375    }
2376    match (strong_count, weak_count, strong, weak) {
2377        (1, _, Some(matched), _) => DstMatch::Unique(matched),
2378        (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
2379        (0, 0, _, _) => DstMatch::None,
2380        _ => DstMatch::Ambiguous,
2381    }
2382}
2383
2384#[derive(Clone, Copy)]
2385enum PushSourceKind {
2386    Branch,
2387    Tag,
2388    /// A source ref that resolves but is neither under `refs/heads/` nor
2389    /// `refs/tags/` (e.g. `HEAD`, a fully-qualified `refs/...` name). git's
2390    /// `guess_ref` still guesses `refs/heads/<dst>` for these.
2391    Other,
2392    /// A source that is NOT a ref at all (a raw object id or a rev-expression
2393    /// like `main^`). git's `guess_ref` resolves nothing for these, so an
2394    /// unqualified destination cannot be guessed and the push is rejected.
2395    Unqualifiable,
2396}
2397
2398fn normalize_push_source_refname(
2399    name: &str,
2400    local_refs: &[PushSourceRef],
2401) -> (String, PushSourceKind) {
2402    // `@` is git's documented alias for `HEAD`; like `HEAD` it resolves to a
2403    // branch, so `guess_ref` can still qualify an unqualified destination.
2404    if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
2405        return (name.to_string(), PushSourceKind::Other);
2406    }
2407    let branch = format!("refs/heads/{name}");
2408    let tag = format!("refs/tags/{name}");
2409    let has_branch = local_refs.iter().any(|reference| reference.name == branch);
2410    let has_tag = local_refs.iter().any(|reference| reference.name == tag);
2411    if has_tag && !has_branch {
2412        (tag, PushSourceKind::Tag)
2413    } else if has_branch {
2414        (branch, PushSourceKind::Branch)
2415    } else if local_refs.iter().any(|reference| reference.name == name) {
2416        // A literal match outside heads/tags/HEAD/refs is a revision source
2417        // injected by `add_revision_push_sources` (an oid or `main^`-style
2418        // expression) — not a ref, so a partial dst cannot be guessed.
2419        (name.to_string(), PushSourceKind::Unqualifiable)
2420    } else {
2421        (branch, PushSourceKind::Branch)
2422    }
2423}
2424
2425fn normalize_push_delete_destination_refname(
2426    name: &str,
2427    remote_refs: &[RefAdvertisement],
2428) -> Result<String> {
2429    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2430        return Ok(name.to_string());
2431    }
2432    match count_refspec_match_dst(name, remote_refs) {
2433        DstMatch::Unique(matched) => Ok(matched.to_string()),
2434        DstMatch::Ambiguous => Err(GitError::Command(format!(
2435            "dst refspec {name} matches more than one"
2436        ))),
2437        DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
2438    }
2439}
2440
2441fn normalize_push_destination_refname(
2442    name: &str,
2443    src_kind: PushSourceKind,
2444    remote_refs: &[RefAdvertisement],
2445) -> Result<String> {
2446    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2447        return Ok(name.to_string());
2448    }
2449    // git's `match_explicit`: a partial destination first resolves against the
2450    // remote's existing refs (so `main:origin/main` lands on the existing
2451    // `refs/remotes/origin/main`); an ambiguous match is fatal; only when
2452    // nothing matches does it fall back to `guess_ref`'s heads/tags choice
2453    // driven by the source ref's kind.
2454    match count_refspec_match_dst(name, remote_refs) {
2455        DstMatch::Unique(matched) => Ok(matched.to_string()),
2456        DstMatch::Ambiguous => Err(GitError::Command(format!(
2457            "dst refspec {name} matches more than one"
2458        ))),
2459        DstMatch::None => match src_kind {
2460            PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
2461            PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
2462            // git's `guess_ref` returns NULL for a non-ref source, so the
2463            // unqualified destination is unresolvable (the "destination is not a
2464            // full refname … you must fully qualify the ref" error).
2465            PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
2466                "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
2467            ))),
2468        },
2469    }
2470}
2471
2472fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
2473    name.strip_prefix("refs/")
2474        .is_some_and(|rest| !rest.contains('/'))
2475}
2476
2477/// The planned commands, dropping the per-command force flags.
2478fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
2479    command_forces
2480        .iter()
2481        .map(|(command, _)| command.clone())
2482        .collect()
2483}
2484
2485fn receive_pack_commands_from_action_plan(
2486    format: ObjectFormat,
2487    plan: &PushActionPlan,
2488) -> Result<Vec<ReceivePackCommand>> {
2489    let zero = ObjectId::null(format);
2490    for oid in &plan.pack_objects {
2491        if oid.format() != format {
2492            return Err(GitError::InvalidObjectId(format!(
2493                "push pack object {oid} has {} object id for {} repository",
2494                oid.format().name(),
2495                format.name()
2496            )));
2497        }
2498    }
2499    plan.commands
2500        .iter()
2501        .map(|command| {
2502            let old_id = command.expected_old.unwrap_or(zero);
2503            let new_id = command.src.unwrap_or(zero);
2504            if old_id.format() != format {
2505                return Err(GitError::InvalidObjectId(format!(
2506                    "push command {} expected old has {} object id for {} repository",
2507                    command.dst,
2508                    old_id.format().name(),
2509                    format.name()
2510                )));
2511            }
2512            if new_id.format() != format {
2513                return Err(GitError::InvalidObjectId(format!(
2514                    "push command {} new id has {} object id for {} repository",
2515                    command.dst,
2516                    new_id.format().name(),
2517                    format.name()
2518                )));
2519            }
2520            Ok(ReceivePackCommand {
2521                old_id,
2522                new_id,
2523                name: command.dst.clone(),
2524            })
2525        })
2526        .collect()
2527}
2528
2529/// Redact embedded credentials from a push URL before showing it in
2530/// user-visible diagnostics.
2531pub fn push_url_for_display(url: &str) -> String {
2532    redact_url_for_display(url)
2533}
2534
2535/// Read a receive-pack report from `reader`, demuxing sideband when negotiated.
2536pub fn read_receive_pack_push_report(
2537    format: ObjectFormat,
2538    reader: &mut impl Read,
2539    use_report_v2: bool,
2540    use_sideband: bool,
2541) -> Result<ReceivePackPushReport> {
2542    read_receive_pack_push_report_with_progress(format, reader, use_report_v2, use_sideband, None)
2543}
2544
2545pub fn read_receive_pack_push_report_with_progress(
2546    format: ObjectFormat,
2547    reader: &mut impl Read,
2548    use_report_v2: bool,
2549    use_sideband: bool,
2550    mut remote_progress: Option<&mut Vec<u8>>,
2551) -> Result<ReceivePackPushReport> {
2552    if use_sideband {
2553        let demuxed = read_and_demux_sideband_stream(reader)?;
2554        for line in demuxed.progress {
2555            if let Some(buf) = remote_progress.as_mut() {
2556                buf.extend_from_slice(&line);
2557                if !line.ends_with(b"\n") {
2558                    buf.push(b'\n');
2559                }
2560            } else {
2561                eprint!("{}", String::from_utf8_lossy(&line));
2562            }
2563        }
2564        let mut payload = demuxed.data.as_slice();
2565        if use_report_v2 {
2566            Ok(ReceivePackPushReport::V2(read_receive_pack_report_status_v2(
2567                format, &mut payload,
2568            )?))
2569        } else {
2570            Ok(ReceivePackPushReport::V1(read_receive_pack_report_status(
2571                &mut payload,
2572            )?))
2573        }
2574    } else if use_report_v2 {
2575        Ok(ReceivePackPushReport::V2(read_receive_pack_report_status_v2(
2576            format, reader,
2577        )?))
2578    } else {
2579        Ok(ReceivePackPushReport::V1(read_receive_pack_report_status(
2580            reader,
2581        )?))
2582    }
2583}
2584
2585/// Fold a receive-pack report onto the per-ref [`PushStatusReport::refs`] entries.
2586pub fn apply_receive_pack_report_to_push_refs(
2587    refs: &mut [PushReportRef],
2588    report: &ReceivePackPushReport,
2589) {
2590    if let Some(message) = report.unpack_failed() {
2591        for reference in refs.iter_mut() {
2592            if matches!(reference.status, PushRefStatus::Ok) {
2593                reference.status =
2594                    PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
2595            }
2596        }
2597        return;
2598    }
2599    match report {
2600        ReceivePackPushReport::V1(status) => {
2601            for command_status in &status.commands {
2602                if let ReceivePackCommandStatus::Ng { name, message } = command_status {
2603                    for reference in refs.iter_mut() {
2604                        if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok)
2605                        {
2606                            reference.status = PushRefStatus::RemoteReject(message.clone());
2607                        }
2608                    }
2609                }
2610            }
2611        }
2612        ReceivePackPushReport::V2(status) => {
2613            let mut hint: Option<usize> = None;
2614            for command in &status.commands {
2615                match command {
2616                    ReceivePackCommandStatusV2::Ng { name, message } => {
2617                        if let Some(idx) = find_push_report_ref_index(refs, name, hint) {
2618                            hint = Some(idx);
2619                            if matches!(refs[idx].status, PushRefStatus::Ok) {
2620                                refs[idx].status = PushRefStatus::RemoteReject(message.clone());
2621                            }
2622                        }
2623                    }
2624                    ReceivePackCommandStatusV2::Ok { name, options } => {
2625                        let Some(idx) = find_push_report_ref_index(refs, name, hint) else {
2626                            continue;
2627                        };
2628                        hint = Some(idx);
2629                        if proc_receive_options_are_nontrivial(options) {
2630                            refs[idx]
2631                                .reports
2632                                .push(proc_receive_report_from_options(options));
2633                        }
2634                    }
2635                }
2636            }
2637        }
2638    }
2639}
2640
2641fn find_push_report_ref_index(
2642    refs: &[PushReportRef],
2643    name: &str,
2644    hint: Option<usize>,
2645) -> Option<usize> {
2646    if let Some(hint) = hint {
2647        if refs.get(hint).is_some_and(|reference| reference.dst == name) {
2648            return Some(hint);
2649        }
2650    }
2651    refs.iter().position(|reference| reference.dst == name)
2652}
2653
2654fn proc_receive_report_from_options(options: &ReceivePackCommandStatusV2Options) -> ProcReceiveReport {
2655    ProcReceiveReport {
2656        refname: options.refname.clone(),
2657        old_oid: options.old_oid.clone(),
2658        new_oid: options.new_oid.clone(),
2659        forced_update: options.forced_update,
2660    }
2661}
2662
2663fn proc_receive_options_are_nontrivial(options: &ReceivePackCommandStatusV2Options) -> bool {
2664    options.refname.is_some()
2665        || options.old_oid.is_some()
2666        || options.new_oid.is_some()
2667        || options.forced_update
2668}
2669
2670/// Validate only the receive-pack unpack line (per-ref `ng` is folded into status).
2671pub fn validate_receive_pack_unpack(report: &ReceivePackPushReport) -> Result<()> {
2672    if let Some(message) = report.unpack_failed() {
2673        return Err(GitError::Command(format!(
2674            "failed to push some refs: unpack failed: {message}"
2675        )));
2676    }
2677    Ok(())
2678}
2679
2680/// Validate a receive-pack report-status, surfacing a failed unpack or any
2681/// rejected ref as an error (matching git's exit-failure message form).
2682pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
2683    if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
2684        return Err(GitError::Command(format!(
2685            "failed to push some refs: unpack failed: {message}"
2686        )));
2687    }
2688    for status in &report.commands {
2689        if let ReceivePackCommandStatus::Ng { name, message } = status {
2690            return Err(GitError::Command(format!(
2691                "failed to push {name}: {message}"
2692            )));
2693        }
2694    }
2695    Ok(())
2696}
2697
2698/// The push-source refs a local repository can match refspecs against: every ref
2699/// resolved to its object id, plus the short `refs/heads/`*and `refs/tags/`*
2700/// aliases, plus `HEAD`. Errors if any ref's object id does not match `format`.
2701pub fn local_push_source_refs(
2702    store: &FileRefStore,
2703    format: ObjectFormat,
2704) -> Result<Vec<PushSourceRef>> {
2705    let mut refs = Vec::new();
2706    for reference in store.list_refs()? {
2707        let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
2708            continue;
2709        };
2710        if oid.format() != format {
2711            return Err(GitError::InvalidObjectId(format!(
2712                "local ref {} has {} object id for {} repository",
2713                reference.name,
2714                oid.format().name(),
2715                format.name()
2716            )));
2717        }
2718        refs.push(PushSourceRef {
2719            name: reference.name.clone(),
2720            oid,
2721        });
2722        if let Some(short) = reference.name.strip_prefix("refs/heads/") {
2723            refs.push(PushSourceRef {
2724                name: short.to_string(),
2725                oid,
2726            });
2727        }
2728        if let Some(short) = reference.name.strip_prefix("refs/tags/") {
2729            refs.push(PushSourceRef {
2730                name: short.to_string(),
2731                oid,
2732            });
2733        }
2734    }
2735    if let Some(target) = store.read_ref("HEAD")? {
2736        let head = Ref {
2737            name: "HEAD".to_string(),
2738            target,
2739        };
2740        if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
2741            && oid.format() == format
2742        {
2743            refs.push(PushSourceRef {
2744                name: "HEAD".to_string(),
2745                oid,
2746            });
2747        }
2748    }
2749    Ok(refs)
2750}
2751
2752/// Normalize a push refspec, expanding short names to `refs/heads/<name>` on both
2753/// sides and supplying the source as the destination when none is given, while
2754/// preserving a leading `+` force marker.
2755pub fn normalize_push_refspec(refspec: &str) -> String {
2756    let (force, refspec) = refspec
2757        .strip_prefix('+')
2758        .map_or((false, refspec), |refspec| (true, refspec));
2759    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2760        let src = normalize_push_refname(src);
2761        let dst = normalize_push_refname(dst);
2762        format!("{src}:{dst}")
2763    } else {
2764        let name = normalize_push_refname(refspec);
2765        format!("{name}:{name}")
2766    };
2767    if force {
2768        format!("+{normalized}")
2769    } else {
2770        normalized
2771    }
2772}
2773
2774/// Expand a short push ref name to `refs/heads/<name>`, leaving empty names,
2775/// `HEAD`, and already-qualified `refs/`* names untouched.
2776pub fn normalize_push_refname(name: &str) -> String {
2777    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2778        name.to_string()
2779    } else {
2780        format!("refs/heads/{name}")
2781    }
2782}
2783
2784/// Reject any non-forced branch update whose old tip is not an ancestor of the
2785/// new tip (a non-fast-forward). Forced updates, non-branch refs, and
2786/// creations/deletions are skipped.
2787pub fn reject_non_fast_forward_pushes(
2788    common_git_dir: &Path,
2789    local_db: &FileObjectDatabase,
2790    format: ObjectFormat,
2791    command_forces: &[(ReceivePackCommand, bool)],
2792) -> Result<()> {
2793    for (command, force) in command_forces {
2794        if *force
2795            || !command.name.starts_with("refs/heads/")
2796            || command.old_id.is_null()
2797            || command.new_id.is_null()
2798        {
2799            continue;
2800        }
2801        let ancestors = sley_rev::ancestor_depths(common_git_dir, format, local_db, &command.new_id)?;
2802        if !ancestors.contains_key(&command.old_id) {
2803            let short = command.name.trim_start_matches("refs/heads/");
2804            return Err(GitError::Command(format!(
2805                "failed to push some refs: non-fast-forward update to {short}"
2806            )));
2807        }
2808    }
2809    Ok(())
2810}
2811
2812/// Resolve a (possibly symbolic) ref target to its object id, following up to
2813/// five levels of symbolic indirection, returning the first symbolic name seen.
2814fn resolve_for_each_ref_target(
2815    store: &FileRefStore,
2816    reference: &Ref,
2817) -> Result<Option<(ObjectId, Option<String>)>> {
2818    let mut target = reference.target.clone();
2819    let mut symref = None;
2820    for _ in 0..5 {
2821        match target {
2822            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2823            RefTarget::Symbolic(name) => {
2824                symref.get_or_insert_with(|| name.clone());
2825                let Some(next) = store.read_ref(&name)? else {
2826                    return Ok(None);
2827                };
2828                target = next;
2829            }
2830        }
2831    }
2832    Ok(None)
2833}
2834
2835#[cfg(test)]
2836mod tests {
2837    use super::*;
2838    use std::fs;
2839    use std::sync::atomic::{AtomicU64, Ordering};
2840
2841    use sley_formats::RepositoryLayout;
2842    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2843    use sley_odb::{FileObjectDatabase, ObjectWriter};
2844    use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2845    use sley_refs::{RefTarget, RefUpdate};
2846
2847    use crate::{NoCredentials, SilentProgress};
2848
2849    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2850
2851    fn temp_repo(name: &str) -> PathBuf {
2852        let dir = std::env::temp_dir().join(format!(
2853            "sley-remote-push-{name}-{}-{}",
2854            std::process::id(),
2855            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2856        ));
2857        let _ = fs::remove_dir_all(&dir);
2858        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2859            .expect("test repository should initialize");
2860        dir.join(".git")
2861    }
2862
2863    fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2864        let format = ObjectFormat::Sha1;
2865        let db = FileObjectDatabase::from_git_dir(git_dir, format);
2866        let tree = db
2867            .write_object(EncodedObject::new(
2868                ObjectType::Tree,
2869                Tree { entries: vec![] }.write(),
2870            ))
2871            .expect("tree should write");
2872        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2873        db.write_object(EncodedObject::new(
2874            ObjectType::Commit,
2875            Commit {
2876                tree,
2877                parents,
2878                author: identity.clone(),
2879                committer: identity,
2880                encoding: None,
2881                message: format!("{message}\n").into_bytes(),
2882            }
2883            .write(),
2884        ))
2885        .expect("commit should write")
2886    }
2887
2888    fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
2889        let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
2890        let mut tx = store.transaction();
2891        tx.update(RefUpdate {
2892            name: name.to_string(),
2893            expected: None,
2894            new: target,
2895            reflog: None,
2896        });
2897        tx.commit().expect("ref should update");
2898    }
2899
2900    fn default_options() -> PushOptions {
2901        PushOptions {
2902            quiet: true,
2903            force: false,
2904            thin: PushThinMode::Auto,
2905        }
2906    }
2907
2908    /// Records the last send and which `HttpClient` method delivered it, so the
2909    /// streaming-vs-buffered gate can be asserted along with byte-for-byte body
2910    /// equality across both paths.
2911    #[derive(Default)]
2912    struct RecordingClient {
2913        last: std::sync::Mutex<Option<(&'static str, Vec<u8>)>>,
2914    }
2915
2916    impl RecordingClient {
2917        fn take(&self) -> (&'static str, Vec<u8>) {
2918            self.last
2919                .lock()
2920                .expect("lock")
2921                .take()
2922                .expect("a send was recorded")
2923        }
2924
2925        fn ok_response() -> Result<HttpResponse> {
2926            Ok(HttpResponse {
2927                status: 200,
2928                content_type: None,
2929                body: Box::new(std::io::empty()),
2930            })
2931        }
2932    }
2933
2934    impl HttpClient for RecordingClient {
2935        fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
2936            Self::ok_response()
2937        }
2938
2939        fn post(
2940            &self,
2941            _url: &str,
2942            _content_type: &str,
2943            _headers: &[(&str, &str)],
2944            body: &[u8],
2945        ) -> Result<HttpResponse> {
2946            *self.last.lock().expect("lock") = Some(("post", body.to_vec()));
2947            Self::ok_response()
2948        }
2949
2950        fn post_reader(
2951            &self,
2952            _url: &str,
2953            _content_type: &str,
2954            _headers: &[(&str, &str)],
2955            body: &mut dyn Read,
2956        ) -> Result<HttpResponse> {
2957            let mut buffered = Vec::new();
2958            body.read_to_end(&mut buffered)
2959                .map_err(|err| GitError::Io(err.to_string()))?;
2960            *self.last.lock().expect("lock") = Some(("post_reader", buffered));
2961            Self::ok_response()
2962        }
2963    }
2964
2965    fn receive_pack_request<'a>(
2966        db: &'a FileObjectDatabase,
2967        commands: &'a [ReceivePackCommand],
2968        advertisements: &'a [RefAdvertisement],
2969        features: &'a ReceivePackFeatures,
2970    ) -> PushPackRequest<'a> {
2971        PushPackRequest {
2972            local_db: db,
2973            format: ObjectFormat::Sha1,
2974            commands,
2975            pack_objects: &[],
2976            remote_advertisements: advertisements,
2977            features,
2978            options: ReceivePackPushRequestOptions {
2979                report_status: true,
2980                ofs_delta: true,
2981                ..ReceivePackPushRequestOptions::default()
2982            },
2983            thin: false,
2984        }
2985    }
2986
2987    #[test]
2988    fn send_receive_pack_body_gates_on_post_buffer_and_preserves_bytes() {
2989        let git_dir = temp_repo("send-receive-pack-gate");
2990        let commit = write_commit(&git_dir, vec![], "streamed http push");
2991        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
2992        let commands = [ReceivePackCommand {
2993            old_id: ObjectId::null(ObjectFormat::Sha1),
2994            new_id: commit,
2995            name: "refs/heads/main".into(),
2996        }];
2997        let features = ReceivePackFeatures {
2998            report_status: true,
2999            ofs_delta: true,
3000            ..ReceivePackFeatures::default()
3001        };
3002        let req = receive_pack_request(&db, &commands, &[], &features);
3003
3004        // The canonical body the streaming and buffered paths must both deliver.
3005        let mut canonical = Vec::new();
3006        write_receive_pack_body(&req, &mut canonical).expect("canonical body");
3007        assert!(canonical.len() > 1, "body should be non-trivial");
3008
3009        // A post_buffer larger than the body → buffered Content-Length send.
3010        let buffered_client = RecordingClient::default();
3011        send_receive_pack_body(
3012            &buffered_client,
3013            "http://h/git-receive-pack",
3014            "ct",
3015            &[],
3016            &req,
3017            usize::MAX,
3018        )
3019        .expect("buffered send");
3020        let (method, body) = buffered_client.take();
3021        assert_eq!(method, "post");
3022        assert_eq!(body, canonical);
3023
3024        // A post_buffer smaller than the body → streamed chunked send. The probe
3025        // (post_buffer + 1 bytes) plus the rest of the pipe must reproduce the
3026        // exact same bytes.
3027        let streamed_client = RecordingClient::default();
3028        send_receive_pack_body(
3029            &streamed_client,
3030            "http://h/git-receive-pack",
3031            "ct",
3032            &[],
3033            &req,
3034            8,
3035        )
3036        .expect("streamed send");
3037        let (method, body) = streamed_client.take();
3038        assert_eq!(method, "post_reader");
3039        assert_eq!(body, canonical);
3040
3041        let _ = fs::remove_dir_all(git_dir.parent().unwrap_or(&git_dir));
3042    }
3043
3044    #[test]
3045    fn parse_post_buffer_reads_git_size_values() {
3046        assert_eq!(parse_post_buffer("1048576"), Some(1 << 20));
3047        assert_eq!(parse_post_buffer("512k"), Some(512 * 1024));
3048        assert_eq!(parse_post_buffer("1M"), Some(1024 * 1024));
3049        assert_eq!(parse_post_buffer("2g"), Some(2 * 1024 * 1024 * 1024));
3050        assert_eq!(parse_post_buffer("  64k "), Some(64 * 1024));
3051        assert_eq!(parse_post_buffer("garbage"), None);
3052        assert_eq!(parse_post_buffer(""), None);
3053    }
3054
3055    #[test]
3056    fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
3057        let repo = temp_repo("action-plan-infer-roots");
3058        let first = write_commit(&repo, Vec::new(), "first");
3059        let second = write_commit(&repo, vec![first], "second");
3060
3061        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3062            vec![
3063                PushCommand {
3064                    src: Some(first),
3065                    dst: "refs/heads/main".into(),
3066                    expected_old: None,
3067                    force: false,
3068                },
3069                PushCommand {
3070                    src: Some(second),
3071                    dst: "refs/heads/topic".into(),
3072                    expected_old: Some(first),
3073                    force: true,
3074                },
3075            ],
3076            default_options(),
3077        );
3078
3079        assert_eq!(plan.pack_objects, vec![first, second]);
3080        assert!(!plan.commands[0].force);
3081        assert!(plan.commands[1].force);
3082    }
3083
3084    #[test]
3085    fn push_action_plan_inferred_pack_roots_exclude_deletes() {
3086        let repo = temp_repo("action-plan-delete-roots");
3087        let old = write_commit(&repo, Vec::new(), "old");
3088        let new = write_commit(&repo, vec![old], "new");
3089
3090        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3091            vec![
3092                PushCommand {
3093                    src: None,
3094                    dst: "refs/heads/remove".into(),
3095                    expected_old: Some(old),
3096                    force: false,
3097                },
3098                PushCommand {
3099                    src: Some(new),
3100                    dst: "refs/heads/keep".into(),
3101                    expected_old: Some(old),
3102                    force: false,
3103                },
3104            ],
3105            default_options(),
3106        );
3107
3108        assert_eq!(plan.pack_objects, vec![new]);
3109    }
3110
3111    #[test]
3112    fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
3113        let repo = temp_repo("action-plan-dedupe-roots");
3114        let first = write_commit(&repo, Vec::new(), "first");
3115        let second = write_commit(&repo, Vec::new(), "second");
3116
3117        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3118            vec![
3119                PushCommand {
3120                    src: Some(second),
3121                    dst: "refs/heads/second".into(),
3122                    expected_old: None,
3123                    force: false,
3124                },
3125                PushCommand {
3126                    src: Some(first),
3127                    dst: "refs/heads/first".into(),
3128                    expected_old: None,
3129                    force: false,
3130                },
3131                PushCommand {
3132                    src: Some(second),
3133                    dst: "refs/tags/second".into(),
3134                    expected_old: None,
3135                    force: false,
3136                },
3137                PushCommand {
3138                    src: Some(first),
3139                    dst: "refs/tags/first".into(),
3140                    expected_old: None,
3141                    force: false,
3142                },
3143            ],
3144            default_options(),
3145        );
3146
3147        assert_eq!(plan.pack_objects, vec![second, first]);
3148    }
3149
3150    fn push_local_actions(
3151        local: &Path,
3152        remote: &Path,
3153        plan: &PushActionPlan,
3154    ) -> Result<PushOutcome> {
3155        let destination = PushDestination::Local {
3156            git_dir: remote.to_path_buf(),
3157            common_git_dir: remote.to_path_buf(),
3158        };
3159        let config = GitConfig::default();
3160        let mut credentials = NoCredentials;
3161        let mut progress = SilentProgress;
3162        push_actions(
3163            PushActionRequest {
3164                git_dir: local,
3165                common_git_dir: local,
3166                format: ObjectFormat::Sha1,
3167                config: &config,
3168                remote: "origin",
3169                destination: &destination,
3170                plan,
3171            },
3172            PushServices {
3173                credentials: &mut credentials,
3174                progress: &mut progress,
3175            },
3176        )
3177    }
3178
3179    #[test]
3180    fn local_push_returns_success_report_status_and_updates_ref() {
3181        let local = temp_repo("local-success");
3182        let remote = temp_repo("remote-success");
3183        let base = write_commit(&local, Vec::new(), "base");
3184        let tip = write_commit(&local, vec![base], "tip");
3185        set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
3186        set_ref(
3187            &local,
3188            "HEAD",
3189            RefTarget::Symbolic("refs/heads/main".into()),
3190        );
3191        let destination = PushDestination::Local {
3192            git_dir: remote.clone(),
3193            common_git_dir: remote.clone(),
3194        };
3195        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
3196        let options = default_options();
3197        let request = PushRequest {
3198            git_dir: &local,
3199            common_git_dir: &local,
3200            format: ObjectFormat::Sha1,
3201            config: &GitConfig::default(),
3202            remote: "origin",
3203            destination: &destination,
3204            refspecs: &refspecs,
3205            options: &options,
3206        };
3207        let mut credentials = NoCredentials;
3208        let mut progress = SilentProgress;
3209
3210        let outcome = push(
3211            request,
3212            PushServices {
3213                credentials: &mut credentials,
3214                progress: &mut progress,
3215            },
3216        )
3217        .expect("push should succeed");
3218
3219        assert_eq!(outcome.commands.len(), 1);
3220        let report = outcome.report.expect("local receive-pack reports status");
3221        match report {
3222            ReceivePackPushReport::V1(report) => {
3223                assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
3224                assert!(matches!(
3225                    report.commands.as_slice(),
3226                    [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
3227                ));
3228            }
3229            ReceivePackPushReport::V2(report) => {
3230                assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
3231                assert!(matches!(
3232                    report.commands.as_slice(),
3233                    [ReceivePackCommandStatusV2::Ok { name, .. }] if name == "refs/heads/main"
3234                ));
3235            }
3236        }
3237        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3238        assert_eq!(
3239            remote_refs
3240                .read_ref("refs/heads/main")
3241                .expect("remote ref should read"),
3242            Some(RefTarget::Direct(tip))
3243        );
3244    }
3245
3246    #[test]
3247    fn local_push_actions_preserves_exact_old_new_update() {
3248        let local = temp_repo("actions-update-local");
3249        let remote = temp_repo("actions-update-remote");
3250        let base = write_commit(&local, Vec::new(), "base");
3251        let remote_base = write_commit(&remote, Vec::new(), "base");
3252        assert_eq!(remote_base, base);
3253        let tip = write_commit(&local, vec![base], "tip");
3254        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3255        let plan = PushActionPlan::from_actions(
3256            vec![PushAction::Update {
3257                dst: "refs/heads/main".into(),
3258                old: base,
3259                new: tip,
3260            }],
3261            default_options(),
3262        );
3263
3264        let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
3265
3266        assert_eq!(outcome.commands.len(), 1);
3267        assert_eq!(outcome.commands[0].old_id, base);
3268        assert_eq!(outcome.commands[0].new_id, tip);
3269        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3270        assert_eq!(
3271            remote_refs
3272                .read_ref("refs/heads/main")
3273                .expect("remote ref should read"),
3274            Some(RefTarget::Direct(tip))
3275        );
3276    }
3277
3278    #[test]
3279    fn local_push_actions_honors_per_command_force() {
3280        let local = temp_repo("actions-command-force-local");
3281        let remote = temp_repo("actions-command-force-remote");
3282        let base = write_commit(&local, Vec::new(), "base");
3283        let remote_base = write_commit(&remote, Vec::new(), "base");
3284        assert_eq!(remote_base, base);
3285        let unrelated = write_commit(&local, Vec::new(), "unrelated");
3286        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3287
3288        let unforced = PushActionPlan::from_commands(
3289            vec![PushCommand {
3290                src: Some(unrelated),
3291                dst: "refs/heads/main".into(),
3292                expected_old: Some(base),
3293                force: false,
3294            }],
3295            default_options(),
3296        );
3297        let err = push_local_actions(&local, &remote, &unforced)
3298            .expect_err("non-fast-forward should reject without command force");
3299        assert!(err.to_string().contains("non-fast-forward"));
3300
3301        let forced = PushActionPlan::from_commands(
3302            vec![PushCommand {
3303                src: Some(unrelated),
3304                dst: "refs/heads/main".into(),
3305                expected_old: Some(base),
3306                force: true,
3307            }],
3308            default_options(),
3309        );
3310        let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
3311
3312        assert_eq!(outcome.commands.len(), 1);
3313        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3314        assert_eq!(
3315            remote_refs
3316                .read_ref("refs/heads/main")
3317                .expect("remote ref should read"),
3318            Some(RefTarget::Direct(unrelated))
3319        );
3320    }
3321
3322    #[test]
3323    fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
3324        let local = temp_repo("actions-command-force-precise-local");
3325        let remote = temp_repo("actions-command-force-precise-remote");
3326        let base = write_commit(&local, Vec::new(), "base");
3327        let remote_base = write_commit(&remote, Vec::new(), "base");
3328        assert_eq!(remote_base, base);
3329        let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
3330        let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
3331        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3332        set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
3333        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3334            vec![
3335                PushCommand {
3336                    src: Some(forced_unrelated),
3337                    dst: "refs/heads/main".into(),
3338                    expected_old: Some(base),
3339                    force: true,
3340                },
3341                PushCommand {
3342                    src: Some(unforced_unrelated),
3343                    dst: "refs/heads/topic".into(),
3344                    expected_old: Some(base),
3345                    force: false,
3346                },
3347            ],
3348            default_options(),
3349        );
3350
3351        let err = push_local_actions(&local, &remote, &plan)
3352            .expect_err("only the forced command should bypass non-fast-forward validation");
3353
3354        assert!(err.to_string().contains("non-fast-forward update to topic"));
3355        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3356        assert_eq!(
3357            remote_refs
3358                .read_ref("refs/heads/main")
3359                .expect("remote ref should read"),
3360            Some(RefTarget::Direct(base))
3361        );
3362        assert_eq!(
3363            remote_refs
3364                .read_ref("refs/heads/topic")
3365                .expect("remote ref should read"),
3366            Some(RefTarget::Direct(base))
3367        );
3368    }
3369
3370    #[test]
3371    fn local_push_actions_stale_update_old_rejects_without_mutating() {
3372        let local = temp_repo("actions-stale-local");
3373        let remote = temp_repo("actions-stale-remote");
3374        let base = write_commit(&local, Vec::new(), "base");
3375        let remote_base = write_commit(&remote, Vec::new(), "base");
3376        assert_eq!(remote_base, base);
3377        let tip = write_commit(&local, vec![base], "tip");
3378        let concurrent = write_commit(&remote, vec![base], "concurrent");
3379        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3380        let plan = PushActionPlan::from_actions(
3381            vec![PushAction::Update {
3382                dst: "refs/heads/main".into(),
3383                old: base,
3384                new: tip,
3385            }],
3386            default_options(),
3387        );
3388
3389        let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
3390
3391        assert!(err.to_string().contains("expected ref refs/heads/main"));
3392        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3393        assert_eq!(
3394            remote_refs
3395                .read_ref("refs/heads/main")
3396                .expect("remote ref should read"),
3397            Some(RefTarget::Direct(concurrent))
3398        );
3399    }
3400
3401    #[test]
3402    fn local_push_actions_stale_delete_old_rejects_without_mutating() {
3403        let local = temp_repo("actions-delete-local");
3404        let remote = temp_repo("actions-delete-remote");
3405        let base = write_commit(&local, Vec::new(), "base");
3406        let remote_base = write_commit(&remote, Vec::new(), "base");
3407        assert_eq!(remote_base, base);
3408        let concurrent = write_commit(&remote, vec![base], "concurrent");
3409        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3410        let plan = PushActionPlan::from_actions(
3411            vec![PushAction::Delete {
3412                dst: "refs/heads/main".into(),
3413                old: Some(base),
3414            }],
3415            default_options(),
3416        );
3417
3418        let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
3419
3420        assert!(err.to_string().contains("expected ref refs/heads/main"));
3421        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3422        assert_eq!(
3423            remote_refs
3424                .read_ref("refs/heads/main")
3425                .expect("remote ref should read"),
3426            Some(RefTarget::Direct(concurrent))
3427        );
3428    }
3429
3430    #[test]
3431    fn local_push_actions_create_rejects_existing_ref() {
3432        let local = temp_repo("actions-create-local");
3433        let remote = temp_repo("actions-create-remote");
3434        let base = write_commit(&local, Vec::new(), "base");
3435        let remote_base = write_commit(&remote, Vec::new(), "base");
3436        assert_eq!(remote_base, base);
3437        let tip = write_commit(&local, vec![base], "tip");
3438        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3439        let plan = PushActionPlan::from_actions(
3440            vec![PushAction::Create {
3441                dst: "refs/heads/main".into(),
3442                new: tip,
3443            }],
3444            default_options(),
3445        );
3446
3447        let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
3448
3449        assert!(
3450            err.to_string()
3451                .contains("expected ref refs/heads/main to not already exist")
3452        );
3453        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3454        assert_eq!(
3455            remote_refs
3456                .read_ref("refs/heads/main")
3457                .expect("remote ref should read"),
3458            Some(RefTarget::Direct(base))
3459        );
3460    }
3461
3462    #[test]
3463    fn push_url_for_display_redacts_embedded_credentials() {
3464        assert_eq!(
3465            push_url_for_display("https://user:pass@host/repo.git"),
3466            "https://<redacted>@host/repo.git"
3467        );
3468    }
3469
3470    #[test]
3471    fn report_status_rejection_is_an_error() {
3472        let report = ReceivePackReportStatus {
3473            unpack: ReceivePackUnpackStatus::Ok,
3474            commands: vec![ReceivePackCommandStatus::Ng {
3475                name: "refs/heads/main".into(),
3476                message: "hook declined".into(),
3477            }],
3478        };
3479
3480        let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
3481
3482        assert!(err.to_string().contains("hook declined"));
3483    }
3484
3485    #[test]
3486    fn failed_local_push_does_not_partially_mutate_remote_ref() {
3487        let local = temp_repo("local-rejected");
3488        let remote = temp_repo("remote-rejected");
3489        let base = write_commit(&local, Vec::new(), "base");
3490        let planned = write_commit(&local, vec![base], "planned");
3491        let concurrent = write_commit(&local, vec![base], "concurrent");
3492        set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
3493        set_ref(
3494            &local,
3495            "HEAD",
3496            RefTarget::Symbolic("refs/heads/main".into()),
3497        );
3498        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3499        let destination = PushDestination::Local {
3500            git_dir: remote.clone(),
3501            common_git_dir: remote.clone(),
3502        };
3503        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
3504        let options = default_options();
3505        let request = PushRequest {
3506            git_dir: &local,
3507            common_git_dir: &local,
3508            format: ObjectFormat::Sha1,
3509            config: &GitConfig::default(),
3510            remote: "origin",
3511            destination: &destination,
3512            refspecs: &refspecs,
3513            options: &options,
3514        };
3515        let mut credentials = NoCredentials;
3516        let mut progress = SilentProgress;
3517        let mut services = PushServices {
3518            credentials: &mut credentials,
3519            progress: &mut progress,
3520        };
3521        let plan = plan_push(request, &mut services).expect("push should plan");
3522
3523        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3524        let _err = execute_push_plan(request, &mut services, plan)
3525            .expect_err("stale old id should reject the ref update");
3526
3527        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3528        assert_eq!(
3529            remote_refs
3530                .read_ref("refs/heads/main")
3531                .expect("remote ref should read"),
3532            Some(RefTarget::Direct(concurrent))
3533        );
3534    }
3535}