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, build_receive_pack_body};
45use sley_refs::{FileRefStore, Ref, RefTarget};
46use sley_transport::RemoteUrl;
47#[cfg(feature = "http")]
48use sley_transport::{HttpClient, 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 body = build_receive_pack_body(&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 mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
785        client.post(
786            &url,
787            &content_type,
788            &crate::http::http_authorization_headers(auth),
789            &body,
790        )
791    })?;
792    crate::http::http_check_status(&response, &url)?;
793    crate::http::http_validate_content_type(
794        &response,
795        &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
796    )?;
797
798    let report = if features.report_status {
799        let report = read_receive_pack_report_status(&mut response.body)?;
800        validate_receive_pack_report(&report)?;
801        Some(report)
802    } else {
803        let mut sink = Vec::new();
804        response.body.read_to_end(&mut sink)?;
805        None
806    };
807    Ok(PushOutcome { commands, report })
808}
809
810/// Push to a local repository served in-process: advertise from the remote
811/// `git_dir`, plan, build the pack against the remote's reachable objects, and
812/// apply the receive-pack request directly.
813struct PushLocalRequest<'a> {
814    git_dir: &'a Path,
815    common_git_dir: &'a Path,
816    format: ObjectFormat,
817    remote: &'a str,
818    remote_git_dir: &'a Path,
819    remote_common_git_dir: &'a Path,
820    refspecs: &'a [String],
821    options: &'a PushOptions,
822}
823
824fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
825    let PushLocalRequest {
826        git_dir,
827        common_git_dir,
828        format,
829        remote,
830        remote_git_dir,
831        remote_common_git_dir,
832        refspecs,
833        options,
834    } = request;
835    let _ = remote;
836    let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
837    if remote_format != format {
838        return Err(GitError::InvalidObjectId(format!(
839            "remote repository uses {}, local repository uses {}",
840            remote_format.name(),
841            format.name()
842        )));
843    }
844
845    let local_store = FileRefStore::new(git_dir, format);
846    let mut local_refs = local_push_source_refs(&local_store, format)?;
847    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
848    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
849    let command_forces =
850        plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
851    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
852    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
853    let commands = commands_from_forces(&command_forces);
854    let execution = if commands.is_empty() {
855        PushExecution::Noop
856    } else {
857        PushExecution::Local {
858            remote_git_dir: remote_git_dir.to_path_buf(),
859            remote_common_git_dir: remote_common_git_dir.to_path_buf(),
860            remote_refs,
861            command_forces,
862            pack_objects: Vec::new(),
863        }
864    };
865    Ok(PushPlan {
866        commands,
867        execution,
868    })
869}
870
871fn execute_push_local(
872    request: PushRequest<'_>,
873    commands: Vec<ReceivePackCommand>,
874    remote_git_dir: PathBuf,
875    remote_common_git_dir: PathBuf,
876    remote_refs: Vec<RefAdvertisement>,
877    _command_forces: Vec<(ReceivePackCommand, bool)>,
878    pack_objects: Vec<ObjectId>,
879) -> Result<PushOutcome> {
880    let remote_excluded_tips = remote_refs
881        .iter()
882        .map(|reference| reference.oid)
883        .collect::<Vec<_>>();
884    let starts = push_pack_roots(&commands, &pack_objects);
885    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
886    let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
887    let remote_excluded =
888        collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
889    let packfile = if starts.is_empty() {
890        Vec::new()
891    } else {
892        b"PACK".to_vec()
893    };
894    let receive_request = ReceivePackPushRequest {
895        commands: ReceivePackRequest {
896            shallow: Vec::new(),
897            commands: commands.clone(),
898            capabilities: Vec::new(),
899        },
900        push_options: None,
901        packfile,
902    };
903    let report = crate::local::receive_pack_reachable_pack_into_local_repository(
904        &remote_git_dir,
905        request.format,
906        &receive_request,
907        &local_db,
908        starts,
909        remote_excluded,
910    )?;
911    validate_receive_pack_report(&report)?;
912    Ok(PushOutcome {
913        commands,
914        report: Some(report),
915    })
916}
917
918/// Fully resolved inputs for a status-reporting push to a local repository.
919pub struct PushReportRequest<'a> {
920    /// Local repository `$GIT_DIR`.
921    pub git_dir: &'a Path,
922    /// Local repository common `$GIT_DIR`, used for object access.
923    pub common_git_dir: &'a Path,
924    /// Local repository object format.
925    pub format: ObjectFormat,
926    /// The remote repository's `$GIT_DIR`.
927    pub remote_git_dir: &'a Path,
928    /// The remote repository's common `$GIT_DIR`.
929    pub remote_common_git_dir: &'a Path,
930    /// Refspecs requested by the caller (already URL/repo resolved).
931    pub refspecs: &'a [String],
932    /// Force every update (the `--force` flag).
933    pub force: bool,
934    /// `--atomic`: send nothing if any ref would be rejected.
935    pub atomic: bool,
936    /// `--dry-run`: classify and report, but do not send or update.
937    pub dry_run: bool,
938    /// Per-ref `--force-with-lease` expectations: `(dst, expected_old)`. An
939    /// `expected_old` of `None` means "the remote ref must not exist".
940    pub force_with_lease: &'a [(String, Option<ObjectId>)],
941    /// `--force-with-lease` with no per-ref value: lease every pushed ref against
942    /// its remote-tracking ref (git's implicit cas). The expected value per dst
943    /// is supplied via [`Self::force_with_lease`]; this flag only governs whether
944    /// a lease was requested at all (used for the "no actual ref" diagnostics).
945    pub force_with_lease_default: bool,
946    /// `--force-if-includes`: for tracking-based leases, reject when the current
947    /// remote tip is not included in the local branch's reflog/history.
948    pub force_if_includes: bool,
949    /// Receive-pack-side config values supplied by the invoked receive-pack
950    /// command, e.g. `--receive-pack="git -c receive.denyDeletes=false receive-pack"`.
951    pub receive_config_overrides: &'a [(String, String)],
952}
953
954/// Push to a local repository, returning git's per-ref status report instead of
955/// failing on the first rejection. Performs the client-side checks git's
956/// send-pack does — non-fast-forward and `--force-with-lease` (stale info) — then
957/// (unless `--dry-run`) sends the surviving commands and folds the receive-pack
958/// report-status back into each ref. With `--atomic`, a single client-side
959/// rejection turns every other ref into [`PushRefStatus::AtomicPushFailed`] and
960/// nothing is sent. The caller renders the report and derives the exit code.
961pub fn push_local_with_report(
962    request: PushReportRequest<'_>,
963    _config: &GitConfig,
964) -> Result<PushStatusReport> {
965    let format = request.format;
966    let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
967    if remote_format != format {
968        return Err(GitError::InvalidObjectId(format!(
969            "remote repository uses {}, local repository uses {}",
970            remote_format.name(),
971            format.name()
972        )));
973    }
974    let local_store = FileRefStore::new(request.git_dir, format);
975    let mut local_refs = local_push_source_refs(&local_store, format)?;
976    add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
977    let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
978    let planned = plan_push_command_sources(
979        format,
980        &local_refs,
981        &remote_refs,
982        request.refspecs,
983        request.force,
984    )?;
985    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
986    let remote_config =
987        sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
988
989    // Classify each planned command the way git's send-pack does, collecting
990    // rejections rather than bailing on the first one.
991    let mut refs: Vec<PushReportRef> = Vec::new();
992    for plan in &planned {
993        let status = classify_push_command(
994            &local_db,
995            format,
996            plan,
997            &request,
998            &remote_config,
999            request.remote_git_dir,
1000        )?;
1001        // git's `forced_update` reflects either an actual rewind or a rejection
1002        // reason (e.g. stale lease) that was overridden by --force.
1003        let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1004        let forced = matches!(status, PushRefStatus::Ok)
1005            && !plan.command.old_id.is_null()
1006            && !plan.command.new_id.is_null()
1007            && (stale_lease_overridden
1008                || if plan.command.name.starts_with("refs/heads/") {
1009                    !is_fast_forward(
1010                        &local_db,
1011                        format,
1012                        &plan.command.old_id,
1013                        &plan.command.new_id,
1014                    )?
1015                } else {
1016                    plan.force
1017                });
1018        refs.push(PushReportRef {
1019            src: plan.source.clone(),
1020            dst: plan.command.name.clone(),
1021            old_id: plan.command.old_id,
1022            new_id: plan.command.new_id,
1023            forced,
1024            status,
1025        });
1026    }
1027
1028    let any_local_reject = refs.iter().any(|reference| {
1029        matches!(
1030            reference.status,
1031            PushRefStatus::RejectNonFastForward
1032                | PushRefStatus::RejectStale
1033                | PushRefStatus::RejectRemoteUpdated
1034                | PushRefStatus::RejectAlreadyExists
1035        )
1036    });
1037
1038    // `--atomic`: if any ref was rejected client-side, send nothing and mark all
1039    // would-be-OK refs as atomic-push-failed (git's REF_STATUS_ATOMIC_PUSH_FAILED).
1040    // UpToDate refs are *not* converted — git leaves them reported as up to date.
1041    if request.atomic && any_local_reject {
1042        for reference in &mut refs {
1043            if matches!(reference.status, PushRefStatus::Ok) {
1044                reference.status = PushRefStatus::AtomicPushFailed;
1045            }
1046        }
1047        return Ok(PushStatusReport { refs });
1048    }
1049
1050    if request.dry_run {
1051        return Ok(PushStatusReport { refs });
1052    }
1053
1054    // Send only the commands that survived client-side checks.
1055    let send: Vec<ReceivePackCommand> = refs
1056        .iter()
1057        .filter(|reference| {
1058            matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1059        })
1060        .map(|reference| ReceivePackCommand {
1061            old_id: reference.old_id,
1062            new_id: reference.new_id,
1063            name: reference.dst.clone(),
1064        })
1065        .collect();
1066
1067    if !send.is_empty() {
1068        let remote_excluded_tips: Vec<ObjectId> =
1069            remote_refs.iter().map(|reference| reference.oid).collect();
1070        let pack_objects: Vec<ObjectId> = Vec::new();
1071        let starts = push_pack_roots(&send, &pack_objects);
1072        let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1073        let remote_excluded =
1074            collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1075        let packfile = if starts.is_empty() {
1076            Vec::new()
1077        } else {
1078            b"PACK".to_vec()
1079        };
1080        let receive_request = ReceivePackPushRequest {
1081            commands: ReceivePackRequest {
1082                shallow: Vec::new(),
1083                commands: send.clone(),
1084                capabilities: Vec::new(),
1085            },
1086            push_options: None,
1087            packfile,
1088        };
1089        let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1090            request.remote_git_dir,
1091            format,
1092            &receive_request,
1093            &local_db,
1094            starts,
1095            remote_excluded,
1096        )?;
1097        // Fold the receive-pack ng reports back onto the matching refs.
1098        if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1099            for reference in &mut refs {
1100                if matches!(reference.status, PushRefStatus::Ok) {
1101                    reference.status =
1102                        PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1103                }
1104            }
1105        }
1106        for command_status in &report.commands {
1107            if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1108                for reference in &mut refs {
1109                    if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1110                        reference.status = PushRefStatus::RemoteReject(message.clone());
1111                    }
1112                }
1113            }
1114        }
1115    }
1116
1117    Ok(PushStatusReport { refs })
1118}
1119
1120/// Classify one planned command into git's send-pack pre-flight status: an
1121/// up-to-date no-op, a non-fast-forward rejection, a `--force-with-lease` stale
1122/// rejection, or `Ok` (the command will be sent).
1123fn classify_push_command(
1124    local_db: &FileObjectDatabase,
1125    format: ObjectFormat,
1126    plan: &PlannedPushCommand,
1127    request: &PushReportRequest<'_>,
1128    config: &GitConfig,
1129    remote_git_dir: &Path,
1130) -> Result<PushRefStatus> {
1131    let command = &plan.command;
1132
1133    if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1134        let reason = if command.new_id.is_null() {
1135            "deny deleting a hidden ref"
1136        } else {
1137            "deny updating a hidden ref"
1138        };
1139        return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1140    }
1141
1142    // No change: the remote already has exactly this value (and it is not a
1143    // create-from-nothing of a non-existent ref). git reports UPTODATE.
1144    if command.old_id == command.new_id && !command.new_id.is_null() {
1145        return Ok(PushRefStatus::UpToDate);
1146    }
1147
1148    if command.new_id.is_null() && !command.old_id.is_null() {
1149        if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1150            .unwrap_or(false)
1151        {
1152            return Ok(PushRefStatus::RemoteReject(
1153                "deletion prohibited".to_string(),
1154            ));
1155        }
1156        if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1157            return Ok(PushRefStatus::RemoteReject(
1158                "deletion of the current branch prohibited".to_string(),
1159            ));
1160        }
1161    }
1162
1163    if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1164        return Ok(PushRefStatus::RemoteReject(
1165            "branch is currently checked out".to_string(),
1166        ));
1167    }
1168
1169    if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1170        let object = local_db.read_object(&command.new_id)?;
1171        if object.object_type != ObjectType::Commit {
1172            return Ok(PushRefStatus::RemoteReject(
1173                "invalid new value provided".to_string(),
1174            ));
1175        }
1176    }
1177
1178    // `--force-with-lease`: the remote's current value must match the lease, or
1179    // the push is rejected as stale info — checked before the non-ff gate and
1180    // independent of `--force`.
1181    if let Some((_, expected)) = request
1182        .force_with_lease
1183        .iter()
1184        .find(|(dst, _)| *dst == command.name)
1185    {
1186        let actual = if command.old_id.is_null() {
1187            None
1188        } else {
1189            Some(command.old_id)
1190        };
1191        if *expected != actual {
1192            if plan.force {
1193                return Ok(PushRefStatus::Ok);
1194            }
1195            return Ok(PushRefStatus::RejectStale);
1196        }
1197        if request.force_if_includes
1198            && !command.old_id.is_null()
1199            && (command.new_id.is_null()
1200                || !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?)
1201            && force_if_includes_rejects(
1202                local_db,
1203                format,
1204                request.git_dir,
1205                &command.name,
1206                &command.old_id,
1207            )?
1208        {
1209            if plan.force {
1210                return Ok(PushRefStatus::Ok);
1211            }
1212            return Ok(PushRefStatus::RejectRemoteUpdated);
1213        }
1214        // A satisfied lease forces the update.
1215        return Ok(PushRefStatus::Ok);
1216    }
1217
1218    if command.name.starts_with("refs/heads/")
1219        && !command.old_id.is_null()
1220        && !command.new_id.is_null()
1221        && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1222        && receive_config_bool(
1223            config,
1224            request.receive_config_overrides,
1225            "denynonfastforwards",
1226        )
1227        .unwrap_or(false)
1228    {
1229        return Ok(PushRefStatus::RemoteReject(format!(
1230            "denying non-fast-forward {} (you should pull first)",
1231            command.name
1232        )));
1233    }
1234
1235    // Non-fast-forward branch update: rejected unless forced. Creations,
1236    // deletions, and non-branch refs skip this gate (matching git's send-pack).
1237    if !plan.force
1238        && command.name.starts_with("refs/tags/")
1239        && !command.old_id.is_null()
1240        && !command.new_id.is_null()
1241    {
1242        return Ok(PushRefStatus::RejectAlreadyExists);
1243    }
1244
1245    if !plan.force
1246        && command.name.starts_with("refs/heads/")
1247        && !command.old_id.is_null()
1248        && !command.new_id.is_null()
1249        && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1250    {
1251        return Ok(PushRefStatus::RejectNonFastForward);
1252    }
1253
1254    Ok(PushRefStatus::Ok)
1255}
1256
1257fn receive_ref_is_hidden(
1258    config: &GitConfig,
1259    overrides: &[(String, String)],
1260    refname: &str,
1261) -> bool {
1262    let mut hide_refs = Vec::new();
1263    hide_refs.extend(hidden_ref_values(config, "transfer", None));
1264    hide_refs.extend(hidden_ref_values(config, "receive", None));
1265    hide_refs.extend(
1266        overrides
1267            .iter()
1268            .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1269            .map(|(_, value)| trim_hidden_ref_pattern(value)),
1270    );
1271    ref_is_hidden_by_patterns(refname, &hide_refs)
1272}
1273
1274fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1275    config
1276        .get_all(section, subsection, "hiderefs")
1277        .into_iter()
1278        .flatten()
1279        .map(trim_hidden_ref_pattern)
1280        .collect()
1281}
1282
1283fn trim_hidden_ref_pattern(value: &str) -> String {
1284    value.trim_end_matches('/').to_string()
1285}
1286
1287fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1288    for pattern in patterns.iter().rev() {
1289        let mut pattern = pattern.as_str();
1290        let negated = pattern.strip_prefix('!').is_some();
1291        if negated {
1292            pattern = &pattern[1..];
1293        }
1294        if let Some(rest) = pattern.strip_prefix('^') {
1295            pattern = rest;
1296        }
1297        if hidden_ref_pattern_matches(refname, pattern) {
1298            return !negated;
1299        }
1300    }
1301    false
1302}
1303
1304fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1305    refname
1306        .strip_prefix(pattern)
1307        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1308}
1309
1310fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1311    let command = &plan.command;
1312    let actual = if command.old_id.is_null() {
1313        None
1314    } else {
1315        Some(command.old_id)
1316    };
1317    request
1318        .force_with_lease
1319        .iter()
1320        .find(|(dst, _)| *dst == command.name)
1321        .is_some_and(|(_, expected)| *expected != actual)
1322}
1323
1324fn force_if_includes_rejects(
1325    db: &FileObjectDatabase,
1326    format: ObjectFormat,
1327    git_dir: &Path,
1328    local_ref: &str,
1329    remote_old: &ObjectId,
1330) -> Result<bool> {
1331    let store = FileRefStore::new(git_dir, format);
1332    let mut candidates = Vec::new();
1333    match store.read_ref(local_ref)? {
1334        Some(RefTarget::Direct(oid)) => candidates.push(oid),
1335        Some(RefTarget::Symbolic(target)) => {
1336            if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1337                candidates.push(oid);
1338            }
1339        }
1340        None => return Ok(false),
1341    }
1342    for entry in store.read_reflog(local_ref)? {
1343        if !entry.new_oid.is_null() {
1344            candidates.push(entry.new_oid);
1345        }
1346    }
1347    candidates.sort();
1348    candidates.dedup();
1349    for candidate in candidates {
1350        if candidate == *remote_old {
1351            return Ok(false);
1352        }
1353        if let Ok(ancestors) = ancestor_depths(db, format, &candidate)
1354            && ancestors.contains_key(remote_old)
1355        {
1356            return Ok(false);
1357        }
1358    }
1359    Ok(true)
1360}
1361
1362fn receive_config_bool(
1363    config: &GitConfig,
1364    overrides: &[(String, String)],
1365    key: &str,
1366) -> Option<bool> {
1367    overrides
1368        .iter()
1369        .rev()
1370        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1371        .and_then(|(_, value)| sley_config::parse_config_bool(value))
1372        .or_else(|| config.get_bool("receive", None, key))
1373}
1374
1375fn receive_denies_current_branch(
1376    format: ObjectFormat,
1377    command: &ReceivePackCommand,
1378    config: &GitConfig,
1379    remote_git_dir: &Path,
1380) -> Result<bool> {
1381    if command.new_id.is_null() {
1382        return Ok(false);
1383    }
1384    if !command.name.starts_with("refs/heads/") {
1385        return Ok(false);
1386    }
1387    let deny = config
1388        .get("receive", None, "denycurrentbranch")
1389        .unwrap_or("refuse");
1390    let denies = matches!(
1391        deny.to_ascii_lowercase().as_str(),
1392        "true" | "yes" | "on" | "1" | "refuse"
1393    );
1394    if !denies {
1395        return Ok(false);
1396    }
1397    if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1398        return Ok(false);
1399    }
1400    let store = FileRefStore::new(remote_git_dir, format);
1401    Ok(matches!(
1402        store.read_ref("HEAD")?,
1403        Some(RefTarget::Symbolic(target)) if target == command.name
1404    ))
1405}
1406
1407fn receive_targets_current_branch(
1408    format: ObjectFormat,
1409    command: &ReceivePackCommand,
1410    remote_git_dir: &Path,
1411) -> Result<bool> {
1412    if !command.name.starts_with("refs/heads/") {
1413        return Ok(false);
1414    }
1415    if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1416        return Ok(false);
1417    }
1418    let store = FileRefStore::new(remote_git_dir, format);
1419    Ok(matches!(
1420        store.read_ref("HEAD")?,
1421        Some(RefTarget::Symbolic(target)) if target == command.name
1422    ))
1423}
1424
1425fn receive_denies_current_branch_delete(
1426    format: ObjectFormat,
1427    command: &ReceivePackCommand,
1428    config: &GitConfig,
1429    request: &PushReportRequest<'_>,
1430    remote_git_dir: &Path,
1431) -> Result<bool> {
1432    if !receive_targets_current_branch(format, command, remote_git_dir)? {
1433        return Ok(false);
1434    }
1435    let deny = request
1436        .receive_config_overrides
1437        .iter()
1438        .rev()
1439        .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1440        .map(|(_, value)| value.as_str())
1441        .or_else(|| config.get("receive", None, "denydeletecurrent"))
1442        .unwrap_or("refuse");
1443    Ok(!matches!(
1444        deny.to_ascii_lowercase().as_str(),
1445        "ignore" | "warn" | "false" | "no" | "off" | "0"
1446    ))
1447}
1448
1449/// Whether `old` is an ancestor of `new` (a fast-forward). A walk from `new`;
1450/// `old` reachable ⇒ fast-forward.
1451fn is_fast_forward(
1452    db: &FileObjectDatabase,
1453    format: ObjectFormat,
1454    old: &ObjectId,
1455    new: &ObjectId,
1456) -> Result<bool> {
1457    let ancestors = ancestor_depths(db, format, new)?;
1458    Ok(ancestors.contains_key(old))
1459}
1460
1461/// Parse the receive-pack features from the leading ref advertisement (the empty
1462/// default when the remote advertised no refs).
1463#[cfg(feature = "http")]
1464fn advertised_receive_pack_features(
1465    advertisements: &[RefAdvertisement],
1466) -> Result<ReceivePackFeatures> {
1467    advertisements
1468        .first()
1469        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1470        .transpose()
1471        .map(Option::unwrap_or_default)
1472}
1473
1474/// Reject a push whose object format disagrees with the remote's advertised
1475/// `object-format`, and require the advertisement for any non-SHA-1 push.
1476#[cfg(feature = "http")]
1477fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1478    if let Some(remote_format) = features.object_format {
1479        if remote_format != format {
1480            return Err(GitError::InvalidObjectId(format!(
1481                "remote repository uses {}, local repository uses {}",
1482                remote_format.name(),
1483                format.name()
1484            )));
1485        }
1486    } else if format != ObjectFormat::Sha1 {
1487        return Err(GitError::InvalidObjectId(format!(
1488            "remote repository did not advertise object-format for {} push",
1489            format.name()
1490        )));
1491    }
1492    Ok(())
1493}
1494
1495/// The receive-pack push-request options for the negotiated `features`, matching
1496/// git: report-status when advertised, ofs-delta when advertised, `quiet` only
1497/// when both requested and advertised, and the advertised object-format only when
1498/// the local repository's `format` is not SHA-1.
1499#[cfg(feature = "http")]
1500fn receive_pack_push_options(
1501    features: &ReceivePackFeatures,
1502    format: ObjectFormat,
1503    quiet: bool,
1504) -> ReceivePackPushRequestOptions {
1505    ReceivePackPushRequestOptions {
1506        report_status: features.report_status,
1507        ofs_delta: features.ofs_delta,
1508        quiet: quiet && features.quiet,
1509        object_format: features
1510            .object_format
1511            .filter(|_| format != ObjectFormat::Sha1),
1512        ..ReceivePackPushRequestOptions::default()
1513    }
1514}
1515
1516/// Plan the receive-pack commands for `refspecs`, pairing each with whether it is
1517/// forced (the global `force` flag or the refspec's own `+`). Each refspec is
1518/// normalized then planned independently so per-refspec force is preserved,
1519/// matching the CLI.
1520pub(crate) fn plan_push_command_forces(
1521    format: ObjectFormat,
1522    local_refs: &[PushSourceRef],
1523    remote_refs: &[RefAdvertisement],
1524    refspecs: &[String],
1525    force: bool,
1526) -> Result<Vec<(ReceivePackCommand, bool)>> {
1527    let parsed_refspecs = refspecs
1528        .iter()
1529        .map(|refspec| {
1530            let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1531            parse_refspec(&normalized)
1532        })
1533        .collect::<Result<Vec<_>>>()?;
1534    let mut command_forces = Vec::new();
1535    for refspec in &parsed_refspecs {
1536        for command in plan_push_commands(
1537            format,
1538            local_refs,
1539            remote_refs,
1540            std::slice::from_ref(refspec),
1541        )? {
1542            command_forces.push((command, force || refspec.force));
1543        }
1544    }
1545    Ok(command_forces)
1546}
1547
1548/// One planned push command paired with its forcing flag and the local source
1549/// ref it came from (git's `ref->peer_ref`). A delete carries `source: None`.
1550struct PlannedPushCommand {
1551    command: ReceivePackCommand,
1552    force: bool,
1553    source: Option<String>,
1554}
1555
1556/// Like [`plan_push_command_forces`], but also records the local source ref each
1557/// command resolved from so the status report can print the `from -> to` line.
1558/// The source is the normalized refspec source name; a delete (`:dst`) has no
1559/// source. A pattern refspec re-derives each expanded command's source from its
1560/// destination by reversing the wildcard substitution.
1561fn plan_push_command_sources(
1562    format: ObjectFormat,
1563    local_refs: &[PushSourceRef],
1564    remote_refs: &[RefAdvertisement],
1565    refspecs: &[String],
1566    force: bool,
1567) -> Result<Vec<PlannedPushCommand>> {
1568    let mut planned = Vec::new();
1569    for refspec in refspecs {
1570        let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1571        let parsed = parse_refspec(&normalized)?;
1572        let commands = plan_push_commands(
1573            format,
1574            local_refs,
1575            remote_refs,
1576            std::slice::from_ref(&parsed),
1577        )?;
1578        for command in commands {
1579            let source = push_command_source_name(&parsed, &command);
1580            planned.push(PlannedPushCommand {
1581                command,
1582                force: force || parsed.force,
1583                source,
1584            });
1585        }
1586    }
1587    Ok(planned)
1588}
1589
1590/// Recover the local source ref name for one planned `command` from its owning
1591/// `refspec`. Deletes (no `src`) return `None`. A wildcard pattern reverses the
1592/// substitution: the command's destination minus the pattern's destination
1593/// affix yields the matched stem, which slots into the pattern's source affix.
1594fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1595    let src = refspec.src.as_deref()?;
1596    if !refspec.pattern {
1597        return Some(src.to_string());
1598    }
1599    let (src_prefix, src_suffix) = src.split_once('*')?;
1600    let dst = refspec.dst.as_deref()?;
1601    let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1602    let stem = command
1603        .name
1604        .strip_prefix(dst_prefix)
1605        .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1606    Some(format!("{src_prefix}{stem}{src_suffix}"))
1607}
1608
1609pub(crate) fn add_revision_push_sources(
1610    git_dir: &Path,
1611    format: ObjectFormat,
1612    refspecs: &[String],
1613    local_refs: &mut Vec<PushSourceRef>,
1614) {
1615    for refspec in refspecs {
1616        let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1617        let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1618        if src.is_empty() || src == "HEAD" {
1619            continue;
1620        }
1621        if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
1622            continue;
1623        }
1624        if local_refs.iter().any(|reference| {
1625            reference.name == src
1626                || reference.name == format!("refs/heads/{src}")
1627                || reference.name == format!("refs/tags/{src}")
1628        }) {
1629            continue;
1630        }
1631        if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1632            && !local_refs.iter().any(|reference| reference.name == src)
1633        {
1634            local_refs.push(PushSourceRef {
1635                name: src.to_string(),
1636                oid,
1637            });
1638        }
1639    }
1640}
1641
1642fn normalize_push_refspec_for_sources(
1643    refspec: &str,
1644    local_refs: &[PushSourceRef],
1645    remote_refs: &[RefAdvertisement],
1646) -> Result<String> {
1647    let (force, refspec) = refspec
1648        .strip_prefix('+')
1649        .map_or((false, refspec), |refspec| (true, refspec));
1650    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1651        let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1652        let dst = if src.is_empty() {
1653            normalize_push_delete_destination_refname(dst, remote_refs)?
1654        } else {
1655            normalize_push_destination_refname(dst, src_kind, remote_refs)?
1656        };
1657        if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
1658            return Err(GitError::Command(format!(
1659                "destination refspec {dst} is not a valid ref"
1660            )));
1661        }
1662        format!("{src}:{dst}")
1663    } else {
1664        let (name, _) = normalize_push_source_refname(refspec, local_refs);
1665        // A colon-less refspec re-uses the source's *resolved* full name as the
1666        // implicit destination (git's `match_explicit`: a NULL dst resolves to
1667        // the matched source ref). That full name is then disambiguated against
1668        // the remote's existing refs, so `git push <remote> frotz` (a tag)
1669        // lands on `refs/tags/frotz` even when the remote also has a same-named
1670        // branch.
1671        let dst = match count_refspec_match_dst(&name, remote_refs) {
1672            DstMatch::Unique(matched) => matched.to_string(),
1673            DstMatch::None => name.clone(),
1674            DstMatch::Ambiguous => {
1675                return Err(GitError::Command(format!(
1676                    "dst refspec {name} matches more than one"
1677                )));
1678            }
1679        };
1680        format!("{name}:{dst}")
1681    };
1682    Ok(if force {
1683        format!("+{normalized}")
1684    } else {
1685        normalized
1686    })
1687}
1688
1689/// git's `refname_match`: true when `full_name` equals `abbrev` expanded by one
1690/// of the `ref_rev_parse_rules`. Returns the matched rule's rank (higher = more
1691/// specific) so the caller can replicate git's strong/weak distinction.
1692fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
1693    const RULES: [&str; 6] = [
1694        "{}",
1695        "refs/{}",
1696        "refs/tags/{}",
1697        "refs/heads/{}",
1698        "refs/remotes/{}",
1699        "refs/remotes/{}/HEAD",
1700    ];
1701    for (idx, rule) in RULES.iter().enumerate() {
1702        let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
1703        if full_name == format!("{prefix}{abbrev}{suffix}") {
1704            return Some(RULES.len() - idx);
1705        }
1706    }
1707    None
1708}
1709
1710/// The outcome of git's `count_refspec_match` for a push destination.
1711enum DstMatch<'a> {
1712    /// Exactly one acceptable match (one strong, or zero strong + one weak).
1713    Unique(&'a str),
1714    /// No remote ref matched — the caller should `guess_ref` or use the literal.
1715    None,
1716    /// More than one match — git dies with "dst refspec … matches more than one".
1717    Ambiguous,
1718}
1719
1720/// git's `count_refspec_match` for a push destination: find the unique existing
1721/// remote ref that `pattern` resolves to, distinguishing strong matches (full
1722/// name, top-level, or a head/tag) from weak ones (a partial match outside
1723/// heads/tags, e.g. `origin/main` → `refs/remotes/origin/main`). One strong
1724/// match wins outright; with no strong match a single weak match is used; more
1725/// than one acceptable match is ambiguous.
1726fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1727    let patlen = pattern.len();
1728    let mut strong: Option<&str> = None;
1729    let mut strong_count = 0usize;
1730    let mut weak: Option<&str> = None;
1731    let mut weak_count = 0usize;
1732    for advert in remote_refs {
1733        let name = advert.name.as_str();
1734        if refname_match_rank(pattern, name).is_none() {
1735            continue;
1736        }
1737        let namelen = name.len();
1738        let is_weak = namelen != patlen
1739            && patlen + 5 != namelen
1740            && !name.starts_with("refs/heads/")
1741            && !name.starts_with("refs/tags/");
1742        if is_weak {
1743            weak = Some(name);
1744            weak_count += 1;
1745        } else {
1746            strong = Some(name);
1747            strong_count += 1;
1748        }
1749    }
1750    match (strong_count, weak_count, strong, weak) {
1751        (1, _, Some(matched), _) => DstMatch::Unique(matched),
1752        (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1753        (0, 0, _, _) => DstMatch::None,
1754        _ => DstMatch::Ambiguous,
1755    }
1756}
1757
1758#[derive(Clone, Copy)]
1759enum PushSourceKind {
1760    Branch,
1761    Tag,
1762    /// A source ref that resolves but is neither under `refs/heads/` nor
1763    /// `refs/tags/` (e.g. `HEAD`, a fully-qualified `refs/...` name). git's
1764    /// `guess_ref` still guesses `refs/heads/<dst>` for these.
1765    Other,
1766    /// A source that is NOT a ref at all (a raw object id or a rev-expression
1767    /// like `main^`). git's `guess_ref` resolves nothing for these, so an
1768    /// unqualified destination cannot be guessed and the push is rejected.
1769    Unqualifiable,
1770}
1771
1772fn normalize_push_source_refname(
1773    name: &str,
1774    local_refs: &[PushSourceRef],
1775) -> (String, PushSourceKind) {
1776    // `@` is git's documented alias for `HEAD`; like `HEAD` it resolves to a
1777    // branch, so `guess_ref` can still qualify an unqualified destination.
1778    if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
1779        return (name.to_string(), PushSourceKind::Other);
1780    }
1781    let branch = format!("refs/heads/{name}");
1782    let tag = format!("refs/tags/{name}");
1783    let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1784    let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1785    if has_tag && !has_branch {
1786        (tag, PushSourceKind::Tag)
1787    } else if has_branch {
1788        (branch, PushSourceKind::Branch)
1789    } else if local_refs.iter().any(|reference| reference.name == name) {
1790        // A literal match outside heads/tags/HEAD/refs is a revision source
1791        // injected by `add_revision_push_sources` (an oid or `main^`-style
1792        // expression) — not a ref, so a partial dst cannot be guessed.
1793        (name.to_string(), PushSourceKind::Unqualifiable)
1794    } else {
1795        (branch, PushSourceKind::Branch)
1796    }
1797}
1798
1799fn normalize_push_delete_destination_refname(
1800    name: &str,
1801    remote_refs: &[RefAdvertisement],
1802) -> Result<String> {
1803    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1804        return Ok(name.to_string());
1805    }
1806    match count_refspec_match_dst(name, remote_refs) {
1807        DstMatch::Unique(matched) => Ok(matched.to_string()),
1808        DstMatch::Ambiguous => Err(GitError::Command(format!(
1809            "dst refspec {name} matches more than one"
1810        ))),
1811        DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
1812    }
1813}
1814
1815fn normalize_push_destination_refname(
1816    name: &str,
1817    src_kind: PushSourceKind,
1818    remote_refs: &[RefAdvertisement],
1819) -> Result<String> {
1820    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1821        return Ok(name.to_string());
1822    }
1823    // git's `match_explicit`: a partial destination first resolves against the
1824    // remote's existing refs (so `main:origin/main` lands on the existing
1825    // `refs/remotes/origin/main`); an ambiguous match is fatal; only when
1826    // nothing matches does it fall back to `guess_ref`'s heads/tags choice
1827    // driven by the source ref's kind.
1828    match count_refspec_match_dst(name, remote_refs) {
1829        DstMatch::Unique(matched) => Ok(matched.to_string()),
1830        DstMatch::Ambiguous => Err(GitError::Command(format!(
1831            "dst refspec {name} matches more than one"
1832        ))),
1833        DstMatch::None => match src_kind {
1834            PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
1835            PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
1836            // git's `guess_ref` returns NULL for a non-ref source, so the
1837            // unqualified destination is unresolvable (the "destination is not a
1838            // full refname … you must fully qualify the ref" error).
1839            PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
1840                "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
1841            ))),
1842        },
1843    }
1844}
1845
1846fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
1847    name.strip_prefix("refs/")
1848        .is_some_and(|rest| !rest.contains('/'))
1849}
1850
1851/// The planned commands, dropping the per-command force flags.
1852fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1853    command_forces
1854        .iter()
1855        .map(|(command, _)| command.clone())
1856        .collect()
1857}
1858
1859fn receive_pack_commands_from_action_plan(
1860    format: ObjectFormat,
1861    plan: &PushActionPlan,
1862) -> Result<Vec<ReceivePackCommand>> {
1863    let zero = ObjectId::null(format);
1864    for oid in &plan.pack_objects {
1865        if oid.format() != format {
1866            return Err(GitError::InvalidObjectId(format!(
1867                "push pack object {oid} has {} object id for {} repository",
1868                oid.format().name(),
1869                format.name()
1870            )));
1871        }
1872    }
1873    plan.commands
1874        .iter()
1875        .map(|command| {
1876            let old_id = command.expected_old.unwrap_or(zero);
1877            let new_id = command.src.unwrap_or(zero);
1878            if old_id.format() != format {
1879                return Err(GitError::InvalidObjectId(format!(
1880                    "push command {} expected old has {} object id for {} repository",
1881                    command.dst,
1882                    old_id.format().name(),
1883                    format.name()
1884                )));
1885            }
1886            if new_id.format() != format {
1887                return Err(GitError::InvalidObjectId(format!(
1888                    "push command {} new id has {} object id for {} repository",
1889                    command.dst,
1890                    new_id.format().name(),
1891                    format.name()
1892                )));
1893            }
1894            Ok(ReceivePackCommand {
1895                old_id,
1896                new_id,
1897                name: command.dst.clone(),
1898            })
1899        })
1900        .collect()
1901}
1902
1903/// Validate a receive-pack report-status, surfacing a failed unpack or any
1904/// rejected ref as an error (matching git's exit-failure message form).
1905pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1906    if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1907        return Err(GitError::Command(format!(
1908            "failed to push some refs: unpack failed: {message}"
1909        )));
1910    }
1911    for status in &report.commands {
1912        if let ReceivePackCommandStatus::Ng { name, message } = status {
1913            return Err(GitError::Command(format!(
1914                "failed to push {name}: {message}"
1915            )));
1916        }
1917    }
1918    Ok(())
1919}
1920
1921/// The push-source refs a local repository can match refspecs against: every ref
1922/// resolved to its object id, plus the short `refs/heads/`*and `refs/tags/`*
1923/// aliases, plus `HEAD`. Errors if any ref's object id does not match `format`.
1924pub fn local_push_source_refs(
1925    store: &FileRefStore,
1926    format: ObjectFormat,
1927) -> Result<Vec<PushSourceRef>> {
1928    let mut refs = Vec::new();
1929    for reference in store.list_refs()? {
1930        let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1931            continue;
1932        };
1933        if oid.format() != format {
1934            return Err(GitError::InvalidObjectId(format!(
1935                "local ref {} has {} object id for {} repository",
1936                reference.name,
1937                oid.format().name(),
1938                format.name()
1939            )));
1940        }
1941        refs.push(PushSourceRef {
1942            name: reference.name.clone(),
1943            oid,
1944        });
1945        if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1946            refs.push(PushSourceRef {
1947                name: short.to_string(),
1948                oid,
1949            });
1950        }
1951        if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1952            refs.push(PushSourceRef {
1953                name: short.to_string(),
1954                oid,
1955            });
1956        }
1957    }
1958    if let Some(target) = store.read_ref("HEAD")? {
1959        let head = Ref {
1960            name: "HEAD".to_string(),
1961            target,
1962        };
1963        if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1964            && oid.format() == format
1965        {
1966            refs.push(PushSourceRef {
1967                name: "HEAD".to_string(),
1968                oid,
1969            });
1970        }
1971    }
1972    Ok(refs)
1973}
1974
1975/// Normalize a push refspec, expanding short names to `refs/heads/<name>` on both
1976/// sides and supplying the source as the destination when none is given, while
1977/// preserving a leading `+` force marker.
1978pub fn normalize_push_refspec(refspec: &str) -> String {
1979    let (force, refspec) = refspec
1980        .strip_prefix('+')
1981        .map_or((false, refspec), |refspec| (true, refspec));
1982    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1983        let src = normalize_push_refname(src);
1984        let dst = normalize_push_refname(dst);
1985        format!("{src}:{dst}")
1986    } else {
1987        let name = normalize_push_refname(refspec);
1988        format!("{name}:{name}")
1989    };
1990    if force {
1991        format!("+{normalized}")
1992    } else {
1993        normalized
1994    }
1995}
1996
1997/// Expand a short push ref name to `refs/heads/<name>`, leaving empty names,
1998/// `HEAD`, and already-qualified `refs/`* names untouched.
1999pub fn normalize_push_refname(name: &str) -> String {
2000    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2001        name.to_string()
2002    } else {
2003        format!("refs/heads/{name}")
2004    }
2005}
2006
2007/// Reject any non-forced branch update whose old tip is not an ancestor of the
2008/// new tip (a non-fast-forward). Forced updates, non-branch refs, and
2009/// creations/deletions are skipped.
2010pub fn reject_non_fast_forward_pushes(
2011    local_db: &FileObjectDatabase,
2012    format: ObjectFormat,
2013    command_forces: &[(ReceivePackCommand, bool)],
2014) -> Result<()> {
2015    for (command, force) in command_forces {
2016        if *force
2017            || !command.name.starts_with("refs/heads/")
2018            || command.old_id.is_null()
2019            || command.new_id.is_null()
2020        {
2021            continue;
2022        }
2023        let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
2024        if !ancestors.contains_key(&command.old_id) {
2025            let short = command.name.trim_start_matches("refs/heads/");
2026            return Err(GitError::Command(format!(
2027                "failed to push some refs: non-fast-forward update to {short}"
2028            )));
2029        }
2030    }
2031    Ok(())
2032}
2033
2034/// The depth of every commit reachable from `start` (a breadth-first ancestry
2035/// walk). Used to test fast-forwardness: `start`'s ancestors include `start`
2036/// itself at depth zero. Errors if a reachable object is not a commit.
2037fn ancestor_depths(
2038    db: &FileObjectDatabase,
2039    format: ObjectFormat,
2040    start: &ObjectId,
2041) -> Result<HashMap<ObjectId, usize>> {
2042    let mut depths = HashMap::new();
2043    let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
2044    while let Some((oid, depth)) = pending.pop_front() {
2045        if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
2046            continue;
2047        }
2048        depths.insert(oid, depth);
2049        let object = db.read_object(&oid)?;
2050        if object.object_type != ObjectType::Commit {
2051            return Err(GitError::InvalidObject(format!(
2052                "expected commit {oid}, found {}",
2053                object.object_type.as_str()
2054            )));
2055        }
2056        let commit = Commit::parse_ref(format, &object.body)?;
2057        for parent in commit.parents {
2058            pending.push_back((parent, depth + 1));
2059        }
2060    }
2061    Ok(depths)
2062}
2063
2064/// Resolve a (possibly symbolic) ref target to its object id, following up to
2065/// five levels of symbolic indirection, returning the first symbolic name seen.
2066fn resolve_for_each_ref_target(
2067    store: &FileRefStore,
2068    reference: &Ref,
2069) -> Result<Option<(ObjectId, Option<String>)>> {
2070    let mut target = reference.target.clone();
2071    let mut symref = None;
2072    for _ in 0..5 {
2073        match target {
2074            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2075            RefTarget::Symbolic(name) => {
2076                symref.get_or_insert_with(|| name.clone());
2077                let Some(next) = store.read_ref(&name)? else {
2078                    return Ok(None);
2079                };
2080                target = next;
2081            }
2082        }
2083    }
2084    Ok(None)
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090    use std::fs;
2091    use std::sync::atomic::{AtomicU64, Ordering};
2092
2093    use sley_formats::RepositoryLayout;
2094    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2095    use sley_odb::{FileObjectDatabase, ObjectWriter};
2096    use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2097    use sley_refs::{RefTarget, RefUpdate};
2098
2099    use crate::{NoCredentials, SilentProgress};
2100
2101    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2102
2103    fn temp_repo(name: &str) -> PathBuf {
2104        let dir = std::env::temp_dir().join(format!(
2105            "sley-remote-push-{name}-{}-{}",
2106            std::process::id(),
2107            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2108        ));
2109        let _ = fs::remove_dir_all(&dir);
2110        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2111            .expect("test repository should initialize");
2112        dir.join(".git")
2113    }
2114
2115    fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2116        let format = ObjectFormat::Sha1;
2117        let db = FileObjectDatabase::from_git_dir(git_dir, format);
2118        let tree = db
2119            .write_object(EncodedObject::new(
2120                ObjectType::Tree,
2121                Tree { entries: vec![] }.write(),
2122            ))
2123            .expect("tree should write");
2124        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2125        db.write_object(EncodedObject::new(
2126            ObjectType::Commit,
2127            Commit {
2128                tree,
2129                parents,
2130                author: identity.clone(),
2131                committer: identity,
2132                encoding: None,
2133                message: format!("{message}\n").into_bytes(),
2134            }
2135            .write(),
2136        ))
2137        .expect("commit should write")
2138    }
2139
2140    fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
2141        let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
2142        let mut tx = store.transaction();
2143        tx.update(RefUpdate {
2144            name: name.to_string(),
2145            expected: None,
2146            new: target,
2147            reflog: None,
2148        });
2149        tx.commit().expect("ref should update");
2150    }
2151
2152    fn default_options() -> PushOptions {
2153        PushOptions {
2154            quiet: true,
2155            force: false,
2156        }
2157    }
2158
2159    #[test]
2160    fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
2161        let repo = temp_repo("action-plan-infer-roots");
2162        let first = write_commit(&repo, Vec::new(), "first");
2163        let second = write_commit(&repo, vec![first], "second");
2164
2165        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2166            vec![
2167                PushCommand {
2168                    src: Some(first),
2169                    dst: "refs/heads/main".into(),
2170                    expected_old: None,
2171                    force: false,
2172                },
2173                PushCommand {
2174                    src: Some(second),
2175                    dst: "refs/heads/topic".into(),
2176                    expected_old: Some(first),
2177                    force: true,
2178                },
2179            ],
2180            default_options(),
2181        );
2182
2183        assert_eq!(plan.pack_objects, vec![first, second]);
2184        assert!(!plan.commands[0].force);
2185        assert!(plan.commands[1].force);
2186    }
2187
2188    #[test]
2189    fn push_action_plan_inferred_pack_roots_exclude_deletes() {
2190        let repo = temp_repo("action-plan-delete-roots");
2191        let old = write_commit(&repo, Vec::new(), "old");
2192        let new = write_commit(&repo, vec![old], "new");
2193
2194        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2195            vec![
2196                PushCommand {
2197                    src: None,
2198                    dst: "refs/heads/remove".into(),
2199                    expected_old: Some(old),
2200                    force: false,
2201                },
2202                PushCommand {
2203                    src: Some(new),
2204                    dst: "refs/heads/keep".into(),
2205                    expected_old: Some(old),
2206                    force: false,
2207                },
2208            ],
2209            default_options(),
2210        );
2211
2212        assert_eq!(plan.pack_objects, vec![new]);
2213    }
2214
2215    #[test]
2216    fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
2217        let repo = temp_repo("action-plan-dedupe-roots");
2218        let first = write_commit(&repo, Vec::new(), "first");
2219        let second = write_commit(&repo, Vec::new(), "second");
2220
2221        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2222            vec![
2223                PushCommand {
2224                    src: Some(second),
2225                    dst: "refs/heads/second".into(),
2226                    expected_old: None,
2227                    force: false,
2228                },
2229                PushCommand {
2230                    src: Some(first),
2231                    dst: "refs/heads/first".into(),
2232                    expected_old: None,
2233                    force: false,
2234                },
2235                PushCommand {
2236                    src: Some(second),
2237                    dst: "refs/tags/second".into(),
2238                    expected_old: None,
2239                    force: false,
2240                },
2241                PushCommand {
2242                    src: Some(first),
2243                    dst: "refs/tags/first".into(),
2244                    expected_old: None,
2245                    force: false,
2246                },
2247            ],
2248            default_options(),
2249        );
2250
2251        assert_eq!(plan.pack_objects, vec![second, first]);
2252    }
2253
2254    fn push_local_actions(
2255        local: &Path,
2256        remote: &Path,
2257        plan: &PushActionPlan,
2258    ) -> Result<PushOutcome> {
2259        let destination = PushDestination::Local {
2260            git_dir: remote.to_path_buf(),
2261            common_git_dir: remote.to_path_buf(),
2262        };
2263        let config = GitConfig::default();
2264        let mut credentials = NoCredentials;
2265        let mut progress = SilentProgress;
2266        push_actions(
2267            PushActionRequest {
2268                git_dir: local,
2269                common_git_dir: local,
2270                format: ObjectFormat::Sha1,
2271                config: &config,
2272                remote: "origin",
2273                destination: &destination,
2274                plan,
2275            },
2276            PushServices {
2277                credentials: &mut credentials,
2278                progress: &mut progress,
2279            },
2280        )
2281    }
2282
2283    #[test]
2284    fn local_push_returns_success_report_status_and_updates_ref() {
2285        let local = temp_repo("local-success");
2286        let remote = temp_repo("remote-success");
2287        let base = write_commit(&local, Vec::new(), "base");
2288        let tip = write_commit(&local, vec![base], "tip");
2289        set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
2290        set_ref(
2291            &local,
2292            "HEAD",
2293            RefTarget::Symbolic("refs/heads/main".into()),
2294        );
2295        let destination = PushDestination::Local {
2296            git_dir: remote.clone(),
2297            common_git_dir: remote.clone(),
2298        };
2299        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2300        let options = default_options();
2301        let request = PushRequest {
2302            git_dir: &local,
2303            common_git_dir: &local,
2304            format: ObjectFormat::Sha1,
2305            config: &GitConfig::default(),
2306            remote: "origin",
2307            destination: &destination,
2308            refspecs: &refspecs,
2309            options: &options,
2310        };
2311        let mut credentials = NoCredentials;
2312        let mut progress = SilentProgress;
2313
2314        let outcome = push(
2315            request,
2316            PushServices {
2317                credentials: &mut credentials,
2318                progress: &mut progress,
2319            },
2320        )
2321        .expect("push should succeed");
2322
2323        assert_eq!(outcome.commands.len(), 1);
2324        let report = outcome.report.expect("local receive-pack reports status");
2325        assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
2326        assert!(matches!(
2327            report.commands.as_slice(),
2328            [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
2329        ));
2330        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2331        assert_eq!(
2332            remote_refs
2333                .read_ref("refs/heads/main")
2334                .expect("remote ref should read"),
2335            Some(RefTarget::Direct(tip))
2336        );
2337    }
2338
2339    #[test]
2340    fn local_push_actions_preserves_exact_old_new_update() {
2341        let local = temp_repo("actions-update-local");
2342        let remote = temp_repo("actions-update-remote");
2343        let base = write_commit(&local, Vec::new(), "base");
2344        let remote_base = write_commit(&remote, Vec::new(), "base");
2345        assert_eq!(remote_base, base);
2346        let tip = write_commit(&local, vec![base], "tip");
2347        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2348        let plan = PushActionPlan::from_actions(
2349            vec![PushAction::Update {
2350                dst: "refs/heads/main".into(),
2351                old: base,
2352                new: tip,
2353            }],
2354            default_options(),
2355        );
2356
2357        let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2358
2359        assert_eq!(outcome.commands.len(), 1);
2360        assert_eq!(outcome.commands[0].old_id, base);
2361        assert_eq!(outcome.commands[0].new_id, tip);
2362        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2363        assert_eq!(
2364            remote_refs
2365                .read_ref("refs/heads/main")
2366                .expect("remote ref should read"),
2367            Some(RefTarget::Direct(tip))
2368        );
2369    }
2370
2371    #[test]
2372    fn local_push_actions_honors_per_command_force() {
2373        let local = temp_repo("actions-command-force-local");
2374        let remote = temp_repo("actions-command-force-remote");
2375        let base = write_commit(&local, Vec::new(), "base");
2376        let remote_base = write_commit(&remote, Vec::new(), "base");
2377        assert_eq!(remote_base, base);
2378        let unrelated = write_commit(&local, Vec::new(), "unrelated");
2379        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2380
2381        let unforced = PushActionPlan::from_commands(
2382            vec![PushCommand {
2383                src: Some(unrelated),
2384                dst: "refs/heads/main".into(),
2385                expected_old: Some(base),
2386                force: false,
2387            }],
2388            default_options(),
2389        );
2390        let err = push_local_actions(&local, &remote, &unforced)
2391            .expect_err("non-fast-forward should reject without command force");
2392        assert!(err.to_string().contains("non-fast-forward"));
2393
2394        let forced = PushActionPlan::from_commands(
2395            vec![PushCommand {
2396                src: Some(unrelated),
2397                dst: "refs/heads/main".into(),
2398                expected_old: Some(base),
2399                force: true,
2400            }],
2401            default_options(),
2402        );
2403        let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2404
2405        assert_eq!(outcome.commands.len(), 1);
2406        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2407        assert_eq!(
2408            remote_refs
2409                .read_ref("refs/heads/main")
2410                .expect("remote ref should read"),
2411            Some(RefTarget::Direct(unrelated))
2412        );
2413    }
2414
2415    #[test]
2416    fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2417        let local = temp_repo("actions-command-force-precise-local");
2418        let remote = temp_repo("actions-command-force-precise-remote");
2419        let base = write_commit(&local, Vec::new(), "base");
2420        let remote_base = write_commit(&remote, Vec::new(), "base");
2421        assert_eq!(remote_base, base);
2422        let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2423        let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2424        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2425        set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2426        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2427            vec![
2428                PushCommand {
2429                    src: Some(forced_unrelated),
2430                    dst: "refs/heads/main".into(),
2431                    expected_old: Some(base),
2432                    force: true,
2433                },
2434                PushCommand {
2435                    src: Some(unforced_unrelated),
2436                    dst: "refs/heads/topic".into(),
2437                    expected_old: Some(base),
2438                    force: false,
2439                },
2440            ],
2441            default_options(),
2442        );
2443
2444        let err = push_local_actions(&local, &remote, &plan)
2445            .expect_err("only the forced command should bypass non-fast-forward validation");
2446
2447        assert!(err.to_string().contains("non-fast-forward update to topic"));
2448        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2449        assert_eq!(
2450            remote_refs
2451                .read_ref("refs/heads/main")
2452                .expect("remote ref should read"),
2453            Some(RefTarget::Direct(base))
2454        );
2455        assert_eq!(
2456            remote_refs
2457                .read_ref("refs/heads/topic")
2458                .expect("remote ref should read"),
2459            Some(RefTarget::Direct(base))
2460        );
2461    }
2462
2463    #[test]
2464    fn local_push_actions_stale_update_old_rejects_without_mutating() {
2465        let local = temp_repo("actions-stale-local");
2466        let remote = temp_repo("actions-stale-remote");
2467        let base = write_commit(&local, Vec::new(), "base");
2468        let remote_base = write_commit(&remote, Vec::new(), "base");
2469        assert_eq!(remote_base, base);
2470        let tip = write_commit(&local, vec![base], "tip");
2471        let concurrent = write_commit(&remote, vec![base], "concurrent");
2472        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2473        let plan = PushActionPlan::from_actions(
2474            vec![PushAction::Update {
2475                dst: "refs/heads/main".into(),
2476                old: base,
2477                new: tip,
2478            }],
2479            default_options(),
2480        );
2481
2482        let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2483
2484        assert!(err.to_string().contains("expected ref refs/heads/main"));
2485        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2486        assert_eq!(
2487            remote_refs
2488                .read_ref("refs/heads/main")
2489                .expect("remote ref should read"),
2490            Some(RefTarget::Direct(concurrent))
2491        );
2492    }
2493
2494    #[test]
2495    fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2496        let local = temp_repo("actions-delete-local");
2497        let remote = temp_repo("actions-delete-remote");
2498        let base = write_commit(&local, Vec::new(), "base");
2499        let remote_base = write_commit(&remote, Vec::new(), "base");
2500        assert_eq!(remote_base, base);
2501        let concurrent = write_commit(&remote, vec![base], "concurrent");
2502        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2503        let plan = PushActionPlan::from_actions(
2504            vec![PushAction::Delete {
2505                dst: "refs/heads/main".into(),
2506                old: Some(base),
2507            }],
2508            default_options(),
2509        );
2510
2511        let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
2512
2513        assert!(err.to_string().contains("expected ref refs/heads/main"));
2514        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2515        assert_eq!(
2516            remote_refs
2517                .read_ref("refs/heads/main")
2518                .expect("remote ref should read"),
2519            Some(RefTarget::Direct(concurrent))
2520        );
2521    }
2522
2523    #[test]
2524    fn local_push_actions_create_rejects_existing_ref() {
2525        let local = temp_repo("actions-create-local");
2526        let remote = temp_repo("actions-create-remote");
2527        let base = write_commit(&local, Vec::new(), "base");
2528        let remote_base = write_commit(&remote, Vec::new(), "base");
2529        assert_eq!(remote_base, base);
2530        let tip = write_commit(&local, vec![base], "tip");
2531        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2532        let plan = PushActionPlan::from_actions(
2533            vec![PushAction::Create {
2534                dst: "refs/heads/main".into(),
2535                new: tip,
2536            }],
2537            default_options(),
2538        );
2539
2540        let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
2541
2542        assert!(
2543            err.to_string()
2544                .contains("expected ref refs/heads/main to not already exist")
2545        );
2546        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2547        assert_eq!(
2548            remote_refs
2549                .read_ref("refs/heads/main")
2550                .expect("remote ref should read"),
2551            Some(RefTarget::Direct(base))
2552        );
2553    }
2554
2555    #[test]
2556    fn report_status_rejection_is_an_error() {
2557        let report = ReceivePackReportStatus {
2558            unpack: ReceivePackUnpackStatus::Ok,
2559            commands: vec![ReceivePackCommandStatus::Ng {
2560                name: "refs/heads/main".into(),
2561                message: "hook declined".into(),
2562            }],
2563        };
2564
2565        let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
2566
2567        assert!(err.to_string().contains("hook declined"));
2568    }
2569
2570    #[test]
2571    fn failed_local_push_does_not_partially_mutate_remote_ref() {
2572        let local = temp_repo("local-rejected");
2573        let remote = temp_repo("remote-rejected");
2574        let base = write_commit(&local, Vec::new(), "base");
2575        let planned = write_commit(&local, vec![base], "planned");
2576        let concurrent = write_commit(&local, vec![base], "concurrent");
2577        set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
2578        set_ref(
2579            &local,
2580            "HEAD",
2581            RefTarget::Symbolic("refs/heads/main".into()),
2582        );
2583        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2584        let destination = PushDestination::Local {
2585            git_dir: remote.clone(),
2586            common_git_dir: remote.clone(),
2587        };
2588        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2589        let options = default_options();
2590        let request = PushRequest {
2591            git_dir: &local,
2592            common_git_dir: &local,
2593            format: ObjectFormat::Sha1,
2594            config: &GitConfig::default(),
2595            remote: "origin",
2596            destination: &destination,
2597            refspecs: &refspecs,
2598            options: &options,
2599        };
2600        let mut credentials = NoCredentials;
2601        let mut progress = SilentProgress;
2602        let mut services = PushServices {
2603            credentials: &mut credentials,
2604            progress: &mut progress,
2605        };
2606        let plan = plan_push(request, &mut services).expect("push should plan");
2607
2608        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2609        let _err = execute_push_plan(request, &mut services, plan)
2610            .expect_err("stale old id should reject the ref update");
2611
2612        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2613        assert_eq!(
2614            remote_refs
2615                .read_ref("refs/heads/main")
2616                .expect("remote ref should read"),
2617            Some(RefTarget::Direct(concurrent))
2618        );
2619    }
2620}