Skip to main content

sley_remote/
push.rs

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