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