Skip to main content

sley_remote/
push.rs

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