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, RefSpec,
39    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    /// The receive-pack side reported `ng <ref> <message>`.
231    RemoteReject(String),
232    /// Part of an `--atomic` push that failed because a sibling ref was rejected.
233    AtomicPushFailed,
234}
235
236/// One ref's line in git's push status report. Carries everything
237/// `print_one_push_report` needs: the source ("from") ref, the destination
238/// ("to") ref, the old/new object ids, whether the update was forced, whether it
239/// is a deletion, and the classified [`PushRefStatus`].
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct PushReportRef {
242    /// The local source ref name (git's `ref->peer_ref->name`), e.g.
243    /// `refs/heads/main`. `None` for a deletion (git prints `:dst`).
244    pub src: Option<String>,
245    /// The destination ref name (git's `ref->name`), e.g. `refs/heads/main`.
246    pub dst: String,
247    /// The remote's old object id for `dst` (zero for a create).
248    pub old_id: ObjectId,
249    /// The object id installed at `dst` (zero for a delete).
250    pub new_id: ObjectId,
251    /// True when the update overwrote a non-fast-forward (git's `forced_update`).
252    pub forced: bool,
253    /// The classified outcome.
254    pub status: PushRefStatus,
255}
256
257impl PushReportRef {
258    /// Whether this ref is a deletion (new id is the zero oid).
259    pub fn is_deletion(&self) -> bool {
260        self.new_id.is_null()
261    }
262
263    /// Whether this ref's status counts as a push error (git's `push_had_errors`:
264    /// anything that is not `Ok`/`UpToDate`/none).
265    pub fn had_error(&self) -> bool {
266        !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
267    }
268}
269
270/// The full result of a push as git's transport layer models it: every ref's
271/// classified status, ready to be rendered into the "To <url>" report and used
272/// to decide the process exit code and the `pull-before-push` advice.
273#[derive(Debug, Clone, Default, PartialEq, Eq)]
274pub struct PushStatusReport {
275    /// Every requested ref, in planning order.
276    pub refs: Vec<PushReportRef>,
277}
278
279impl PushStatusReport {
280    /// True when any ref was rejected (git's overall push error flag).
281    pub fn had_errors(&self) -> bool {
282        self.refs.iter().any(PushReportRef::had_error)
283    }
284
285    /// True when at least one ref was actually updated (git's
286    /// `transport_refs_pushed`): used to print "Everything up-to-date".
287    pub fn refs_pushed(&self) -> bool {
288        self.refs.iter().any(|reference| {
289            reference.old_id != reference.new_id
290                && matches!(reference.status, PushRefStatus::Ok)
291        })
292    }
293}
294
295/// Fully resolved inputs for a [`push`] run.
296#[derive(Clone, Copy)]
297pub struct PushRequest<'a> {
298    /// Local repository `$GIT_DIR`.
299    pub git_dir: &'a Path,
300    /// Local repository common `$GIT_DIR`, used for object access.
301    pub common_git_dir: &'a Path,
302    /// Local repository object format.
303    pub format: ObjectFormat,
304    /// Local repository config snapshot.
305    pub config: &'a GitConfig,
306    /// Remote name or source string, used for diagnostics.
307    pub remote: &'a str,
308    /// Already-resolved push destination.
309    pub destination: &'a PushDestination,
310    /// Refspecs requested by the caller.
311    pub refspecs: &'a [String],
312    /// Push behavior flags.
313    pub options: &'a PushOptions,
314}
315
316/// Fully resolved inputs for a caller-authored exact push plan.
317#[derive(Clone, Copy)]
318pub struct PushActionRequest<'a> {
319    /// Local repository `$GIT_DIR`.
320    pub git_dir: &'a Path,
321    /// Local repository common `$GIT_DIR`, used for object access.
322    pub common_git_dir: &'a Path,
323    /// Local repository object format.
324    pub format: ObjectFormat,
325    /// Local repository config snapshot.
326    pub config: &'a GitConfig,
327    /// Remote name or source string, used for diagnostics.
328    pub remote: &'a str,
329    /// Already-resolved push destination.
330    pub destination: &'a PushDestination,
331    /// Caller-authored exact push plan.
332    pub plan: &'a PushActionPlan,
333}
334
335/// Mutable seams used while pushing.
336pub struct PushServices<'a> {
337    /// Credential source for authenticated transports.
338    pub credentials: &'a mut dyn CredentialProvider,
339    /// Progress sink reserved for future push progress.
340    pub progress: &'a mut dyn ProgressSink,
341}
342
343/// A push after ref negotiation and command planning, but before any ref update
344/// is sent or applied.
345pub struct PushPlan {
346    /// The receive-pack commands that will be executed if the caller proceeds.
347    pub commands: Vec<ReceivePackCommand>,
348    execution: PushExecution,
349}
350
351enum PushExecution {
352    Noop,
353    #[cfg(feature = "http")]
354    Http {
355        remote_url: RemoteUrl,
356        features: ReceivePackFeatures,
357        advertisements: Vec<RefAdvertisement>,
358        pack_objects: Vec<ObjectId>,
359    },
360    Ssh(crate::ssh::SshPushPlan),
361    Git(crate::git::GitPushPlan),
362    Local {
363        remote_git_dir: PathBuf,
364        remote_common_git_dir: PathBuf,
365        remote_refs: Vec<RefAdvertisement>,
366        command_forces: Vec<(ReceivePackCommand, bool)>,
367        pack_objects: Vec<ObjectId>,
368    },
369}
370
371/// Push `refspecs` to a resolved `destination` from the repository at `git_dir`.
372///
373/// Performs the work the CLI's `push_http_repository`/`push_local_repository`
374/// did: advertises the remote's refs, plans the receive-pack commands for
375/// `refspecs`, rejects non-fast-forward branch updates (unless forced), builds
376/// the pack of objects the remote lacks, sends the receive-pack request, parses
377/// and validates the report-status, and returns the executed commands. `remote`
378/// is the remote/argument the caller resolved `destination` from (used only for
379/// error messages here).
380///
381/// Returns the structured [`PushOutcome`]; never prints or returns
382/// `GitError::Exit`. A still-`None` report in the outcome means the remote did
383/// not advertise `report-status`. Set-upstream config and the "To <remote>"
384/// summary are the caller's job, driven from [`PushOutcome::commands`].
385pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
386    let plan = plan_push(request, &mut services)?;
387    execute_push_plan(request, &mut services, plan)
388}
389
390/// Push a caller-authored exact plan, preserving its old/new/delete command ids.
391pub fn push_actions(
392    request: PushActionRequest<'_>,
393    mut services: PushServices<'_>,
394) -> Result<PushOutcome> {
395    let plan = plan_push_actions(request, &mut services)?;
396    execute_push_action_plan(request, &mut services, plan)
397}
398
399/// Negotiate with the remote and compute the receive-pack command list without
400/// sending a pack or applying a ref update.
401pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
402    // `config` and `progress` are part of the seam (mirroring `fetch`) but the
403    // current push flow drives credentials from the caller-built provider and
404    // returns its summary in `PushOutcome` rather than streaming progress, so
405    // neither is consumed yet. Kept named for the public API and future use.
406    let _ = request.config;
407    let _ = &mut services.progress;
408    match request.destination {
409        #[cfg(feature = "http")]
410        PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
411            git_dir: request.git_dir,
412            common_git_dir: request.common_git_dir,
413            format: request.format,
414            remote_url,
415            refspecs: request.refspecs,
416            options: request.options,
417            credentials: services.credentials,
418        }),
419        #[cfg(not(feature = "http"))]
420        PushDestination::Http(_) => Err(GitError::Unsupported(
421            "HTTP transport is not enabled in this build".into(),
422        )),
423        PushDestination::Ssh(remote_url) => {
424            let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
425                git_dir: request.git_dir,
426                common_git_dir: request.common_git_dir,
427                format: request.format,
428                remote: remote_url,
429                refspecs: request.refspecs,
430                force: request.options.force,
431            })?;
432            let commands = plan.commands.clone();
433            let execution = if commands.is_empty() {
434                PushExecution::Noop
435            } else {
436                PushExecution::Ssh(plan)
437            };
438            Ok(PushPlan {
439                commands,
440                execution,
441            })
442        }
443        PushDestination::Git(remote_url) => {
444            let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
445                git_dir: request.git_dir,
446                common_git_dir: request.common_git_dir,
447                format: request.format,
448                remote: remote_url,
449                refspecs: request.refspecs,
450                force: request.options.force,
451            })?;
452            let commands = plan.commands.clone();
453            let execution = if commands.is_empty() {
454                PushExecution::Noop
455            } else {
456                PushExecution::Git(plan)
457            };
458            Ok(PushPlan {
459                commands,
460                execution,
461            })
462        }
463        PushDestination::Local {
464            git_dir: remote_git_dir,
465            common_git_dir: remote_common_git_dir,
466        } => plan_push_local(PushLocalRequest {
467            git_dir: request.git_dir,
468            common_git_dir: request.common_git_dir,
469            format: request.format,
470            remote: request.remote,
471            remote_git_dir,
472            remote_common_git_dir,
473            refspecs: request.refspecs,
474            options: request.options,
475        }),
476    }
477}
478
479/// Negotiate with the remote and bind a caller-authored exact push plan to a
480/// transport execution token.
481pub fn plan_push_actions(
482    request: PushActionRequest<'_>,
483    services: &mut PushServices<'_>,
484) -> Result<PushPlan> {
485    let _ = request.config;
486    let _ = &mut services.progress;
487    let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
488    let command_forces = commands
489        .iter()
490        .cloned()
491        .zip(request.plan.commands.iter())
492        .map(|(command, planned)| (command, request.plan.options.force || planned.force))
493        .collect::<Vec<_>>();
494    match request.destination {
495        #[cfg(feature = "http")]
496        PushDestination::Http(remote_url) => {
497            let client = crate::http::new_http_client();
498            let discovered = crate::http::http_service_advertisements(
499                &client,
500                remote_url,
501                request.format,
502                GitService::ReceivePack,
503                services.credentials,
504            )?;
505            let advertisement_set = discovered.set;
506            let features = advertised_receive_pack_features(&advertisement_set.refs)?;
507            verify_remote_object_format(&features, request.format)?;
508            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
509            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
510            let execution = if commands.is_empty() {
511                PushExecution::Noop
512            } else {
513                PushExecution::Http {
514                    remote_url: remote_url.clone(),
515                    features,
516                    advertisements: advertisement_set.refs,
517                    pack_objects: request.plan.pack_objects.clone(),
518                }
519            };
520            Ok(PushPlan {
521                commands,
522                execution,
523            })
524        }
525        #[cfg(not(feature = "http"))]
526        PushDestination::Http(_) => Err(GitError::Unsupported(
527            "HTTP transport is not enabled in this build".into(),
528        )),
529        PushDestination::Ssh(remote_url) => {
530            let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
531                common_git_dir: request.common_git_dir,
532                format: request.format,
533                remote: remote_url,
534                command_forces: command_forces.clone(),
535                pack_objects: request.plan.pack_objects.clone(),
536            })?;
537            let commands = plan.commands.clone();
538            let execution = if commands.is_empty() {
539                PushExecution::Noop
540            } else {
541                PushExecution::Ssh(plan)
542            };
543            Ok(PushPlan {
544                commands,
545                execution,
546            })
547        }
548        PushDestination::Git(remote_url) => {
549            let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
550                common_git_dir: request.common_git_dir,
551                format: request.format,
552                remote: remote_url,
553                command_forces: command_forces.clone(),
554                pack_objects: request.plan.pack_objects.clone(),
555            })?;
556            let commands = plan.commands.clone();
557            let execution = if commands.is_empty() {
558                PushExecution::Noop
559            } else {
560                PushExecution::Git(plan)
561            };
562            Ok(PushPlan {
563                commands,
564                execution,
565            })
566        }
567        PushDestination::Local {
568            git_dir: remote_git_dir,
569            common_git_dir: remote_common_git_dir,
570        } => {
571            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
572            if remote_format != request.format {
573                return Err(GitError::InvalidObjectId(format!(
574                    "remote repository uses {}, local repository uses {}",
575                    remote_format.name(),
576                    request.format.name()
577                )));
578            }
579            let remote_refs =
580                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
581            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
582            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
583            let execution = if commands.is_empty() {
584                PushExecution::Noop
585            } else {
586                PushExecution::Local {
587                    remote_git_dir: remote_git_dir.to_path_buf(),
588                    remote_common_git_dir: remote_common_git_dir.to_path_buf(),
589                    remote_refs,
590                    command_forces,
591                    pack_objects: request.plan.pack_objects.clone(),
592                }
593            };
594            Ok(PushPlan {
595                commands,
596                execution,
597            })
598        }
599    }
600}
601
602/// Execute a previously planned push.
603pub fn execute_push_plan(
604    request: PushRequest<'_>,
605    services: &mut PushServices<'_>,
606    plan: PushPlan,
607) -> Result<PushOutcome> {
608    let _ = (request.config, request.remote);
609    let _ = &mut services.progress;
610    if plan.commands.is_empty() {
611        return Ok(PushOutcome::default());
612    }
613    match plan.execution {
614        PushExecution::Noop => Ok(PushOutcome::default()),
615        #[cfg(feature = "http")]
616        PushExecution::Http {
617            remote_url,
618            features,
619            advertisements,
620            pack_objects,
621        } => execute_push_http(
622            request,
623            services.credentials,
624            plan.commands,
625            remote_url,
626            features,
627            advertisements,
628            pack_objects,
629        ),
630        PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
631        PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
632        PushExecution::Local {
633            remote_git_dir,
634            remote_common_git_dir,
635            remote_refs,
636            command_forces,
637            pack_objects,
638        } => execute_push_local(
639            request,
640            plan.commands,
641            remote_git_dir,
642            remote_common_git_dir,
643            remote_refs,
644            command_forces,
645            pack_objects,
646        ),
647    }
648}
649
650/// Execute a previously negotiated exact push plan.
651pub fn execute_push_action_plan(
652    request: PushActionRequest<'_>,
653    services: &mut PushServices<'_>,
654    plan: PushPlan,
655) -> Result<PushOutcome> {
656    let refspecs: &[String] = &[];
657    execute_push_plan(
658        PushRequest {
659            git_dir: request.git_dir,
660            common_git_dir: request.common_git_dir,
661            format: request.format,
662            config: request.config,
663            remote: request.remote,
664            destination: request.destination,
665            refspecs,
666            options: &request.plan.options,
667        },
668        services,
669        plan,
670    )
671}
672
673/// Push to a smart-HTTP(S) remote: advertise via receive-pack info/refs, plan,
674/// build the pack, POST the receive-pack RPC, and validate the report-status.
675#[cfg(feature = "http")]
676struct PushHttpRequest<'a> {
677    git_dir: &'a Path,
678    common_git_dir: &'a Path,
679    format: ObjectFormat,
680    remote_url: &'a RemoteUrl,
681    refspecs: &'a [String],
682    options: &'a PushOptions,
683    credentials: &'a mut dyn CredentialProvider,
684}
685
686#[cfg(feature = "http")]
687fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
688    let PushHttpRequest {
689        git_dir,
690        common_git_dir,
691        format,
692        remote_url,
693        refspecs,
694        options,
695        credentials,
696    } = request;
697    let client = crate::http::new_http_client();
698    let discovered = crate::http::http_service_advertisements(
699        &client,
700        remote_url,
701        format,
702        GitService::ReceivePack,
703        credentials,
704    )?;
705    let advertisement_set = discovered.set;
706    let features = advertised_receive_pack_features(&advertisement_set.refs)?;
707    verify_remote_object_format(&features, format)?;
708
709    let local_store = FileRefStore::new(git_dir, format);
710    let mut local_refs = local_push_source_refs(&local_store, format)?;
711    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
712    let command_forces = plan_push_command_forces(
713        format,
714        &local_refs,
715        &advertisement_set.refs,
716        refspecs,
717        options.force,
718    )?;
719    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
720    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
721    let commands = commands_from_forces(&command_forces);
722    let execution = if commands.is_empty() {
723        PushExecution::Noop
724    } else {
725        PushExecution::Http {
726            remote_url: remote_url.clone(),
727            features,
728            advertisements: advertisement_set.refs,
729            pack_objects: Vec::new(),
730        }
731    };
732    Ok(PushPlan {
733        commands,
734        execution,
735    })
736}
737
738#[cfg(feature = "http")]
739fn execute_push_http(
740    request: PushRequest<'_>,
741    credentials: &mut dyn CredentialProvider,
742    commands: Vec<ReceivePackCommand>,
743    remote_url: RemoteUrl,
744    features: ReceivePackFeatures,
745    advertisements: Vec<RefAdvertisement>,
746    pack_objects: Vec<ObjectId>,
747) -> Result<PushOutcome> {
748    let client = crate::http::new_http_client();
749    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
750    let body = build_receive_pack_body(&PushPackRequest {
751        local_db: &local_db,
752        format: request.format,
753        commands: &commands,
754        pack_objects: &pack_objects,
755        remote_advertisements: &advertisements,
756        features: &features,
757        options: receive_pack_push_options(&features, request.format, request.options.quiet),
758        thin: false,
759    })?;
760    let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
761    let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
762    let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
763        client.post(
764            &url,
765            &content_type,
766            &crate::http::http_authorization_headers(auth),
767            &body,
768        )
769    })?;
770    crate::http::http_check_status(&response, &url)?;
771    crate::http::http_validate_content_type(
772        &response,
773        &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
774    )?;
775
776    let report = if features.report_status {
777        let report = read_receive_pack_report_status(&mut response.body)?;
778        validate_receive_pack_report(&report)?;
779        Some(report)
780    } else {
781        let mut sink = Vec::new();
782        response.body.read_to_end(&mut sink)?;
783        None
784    };
785    Ok(PushOutcome { commands, report })
786}
787
788/// Push to a local repository served in-process: advertise from the remote
789/// `git_dir`, plan, build the pack against the remote's reachable objects, and
790/// apply the receive-pack request directly.
791struct PushLocalRequest<'a> {
792    git_dir: &'a Path,
793    common_git_dir: &'a Path,
794    format: ObjectFormat,
795    remote: &'a str,
796    remote_git_dir: &'a Path,
797    remote_common_git_dir: &'a Path,
798    refspecs: &'a [String],
799    options: &'a PushOptions,
800}
801
802fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
803    let PushLocalRequest {
804        git_dir,
805        common_git_dir,
806        format,
807        remote,
808        remote_git_dir,
809        remote_common_git_dir,
810        refspecs,
811        options,
812    } = request;
813    let _ = remote;
814    let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
815    if remote_format != format {
816        return Err(GitError::InvalidObjectId(format!(
817            "remote repository uses {}, local repository uses {}",
818            remote_format.name(),
819            format.name()
820        )));
821    }
822
823    let local_store = FileRefStore::new(git_dir, format);
824    let mut local_refs = local_push_source_refs(&local_store, format)?;
825    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
826    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
827    let command_forces =
828        plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
829    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
830    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
831    let commands = commands_from_forces(&command_forces);
832    let execution = if commands.is_empty() {
833        PushExecution::Noop
834    } else {
835        PushExecution::Local {
836            remote_git_dir: remote_git_dir.to_path_buf(),
837            remote_common_git_dir: remote_common_git_dir.to_path_buf(),
838            remote_refs,
839            command_forces,
840            pack_objects: Vec::new(),
841        }
842    };
843    Ok(PushPlan {
844        commands,
845        execution,
846    })
847}
848
849fn execute_push_local(
850    request: PushRequest<'_>,
851    commands: Vec<ReceivePackCommand>,
852    remote_git_dir: PathBuf,
853    remote_common_git_dir: PathBuf,
854    remote_refs: Vec<RefAdvertisement>,
855    _command_forces: Vec<(ReceivePackCommand, bool)>,
856    pack_objects: Vec<ObjectId>,
857) -> Result<PushOutcome> {
858    let remote_excluded_tips = remote_refs
859        .iter()
860        .map(|reference| reference.oid)
861        .collect::<Vec<_>>();
862    let starts = push_pack_roots(&commands, &pack_objects);
863    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
864    let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
865    let remote_excluded =
866        collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
867    let packfile = if starts.is_empty() {
868        Vec::new()
869    } else {
870        b"PACK".to_vec()
871    };
872    let receive_request = ReceivePackPushRequest {
873        commands: ReceivePackRequest {
874            shallow: Vec::new(),
875            commands: commands.clone(),
876            capabilities: Vec::new(),
877        },
878        push_options: None,
879        packfile,
880    };
881    let report = crate::local::receive_pack_reachable_pack_into_local_repository(
882        &remote_git_dir,
883        request.format,
884        &receive_request,
885        &local_db,
886        starts,
887        remote_excluded,
888    )?;
889    validate_receive_pack_report(&report)?;
890    Ok(PushOutcome {
891        commands,
892        report: Some(report),
893    })
894}
895
896/// Fully resolved inputs for a status-reporting push to a local repository.
897pub struct PushReportRequest<'a> {
898    /// Local repository `$GIT_DIR`.
899    pub git_dir: &'a Path,
900    /// Local repository common `$GIT_DIR`, used for object access.
901    pub common_git_dir: &'a Path,
902    /// Local repository object format.
903    pub format: ObjectFormat,
904    /// The remote repository's `$GIT_DIR`.
905    pub remote_git_dir: &'a Path,
906    /// The remote repository's common `$GIT_DIR`.
907    pub remote_common_git_dir: &'a Path,
908    /// Refspecs requested by the caller (already URL/repo resolved).
909    pub refspecs: &'a [String],
910    /// Force every update (the `--force` flag).
911    pub force: bool,
912    /// `--atomic`: send nothing if any ref would be rejected.
913    pub atomic: bool,
914    /// `--dry-run`: classify and report, but do not send or update.
915    pub dry_run: bool,
916    /// Per-ref `--force-with-lease` expectations: `(dst, expected_old)`. An
917    /// `expected_old` of `None` means "the remote ref must not exist".
918    pub force_with_lease: &'a [(String, Option<ObjectId>)],
919    /// `--force-with-lease` with no per-ref value: lease every pushed ref against
920    /// its remote-tracking ref (git's implicit cas). The expected value per dst
921    /// is supplied via [`Self::force_with_lease`]; this flag only governs whether
922    /// a lease was requested at all (used for the "no actual ref" diagnostics).
923    pub force_with_lease_default: bool,
924}
925
926/// Push to a local repository, returning git's per-ref status report instead of
927/// failing on the first rejection. Performs the client-side checks git's
928/// send-pack does — non-fast-forward and `--force-with-lease` (stale info) — then
929/// (unless `--dry-run`) sends the surviving commands and folds the receive-pack
930/// report-status back into each ref. With `--atomic`, a single client-side
931/// rejection turns every other ref into [`PushRefStatus::AtomicPushFailed`] and
932/// nothing is sent. The caller renders the report and derives the exit code.
933pub fn push_local_with_report(
934    request: PushReportRequest<'_>,
935    config: &GitConfig,
936) -> Result<PushStatusReport> {
937    let format = request.format;
938    let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
939    if remote_format != format {
940        return Err(GitError::InvalidObjectId(format!(
941            "remote repository uses {}, local repository uses {}",
942            remote_format.name(),
943            format.name()
944        )));
945    }
946    let local_store = FileRefStore::new(request.git_dir, format);
947    let mut local_refs = local_push_source_refs(&local_store, format)?;
948    add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
949    let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
950    let planned = plan_push_command_sources(
951        format,
952        &local_refs,
953        &remote_refs,
954        request.refspecs,
955        request.force,
956    )?;
957    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
958
959    // Classify each planned command the way git's send-pack does, collecting
960    // rejections rather than bailing on the first one.
961    let mut refs: Vec<PushReportRef> = Vec::new();
962    for plan in &planned {
963        let status = classify_push_command(
964            &local_db,
965            format,
966            plan,
967            &request,
968            config,
969            request.remote_git_dir,
970        )?;
971        // git's `forced_update` reflects whether the update is *actually* a
972        // non-fast-forward (a rewind), independent of the `--force` flag — the
973        // flag only permits it. A create/delete is never "forced".
974        let forced = matches!(status, PushRefStatus::Ok)
975            && !plan.command.old_id.is_null()
976            && !plan.command.new_id.is_null()
977            && !is_fast_forward(&local_db, format, &plan.command.old_id, &plan.command.new_id)?;
978        refs.push(PushReportRef {
979            src: plan.source.clone(),
980            dst: plan.command.name.clone(),
981            old_id: plan.command.old_id,
982            new_id: plan.command.new_id,
983            forced,
984            status,
985        });
986    }
987
988    let any_local_reject = refs.iter().any(|reference| {
989        matches!(
990            reference.status,
991            PushRefStatus::RejectNonFastForward | PushRefStatus::RejectStale
992        )
993    });
994
995    // `--atomic`: if any ref was rejected client-side, send nothing and mark all
996    // would-be-OK refs as atomic-push-failed (git's REF_STATUS_ATOMIC_PUSH_FAILED).
997    // UpToDate refs are *not* converted — git leaves them reported as up to date.
998    if request.atomic && any_local_reject {
999        for reference in &mut refs {
1000            if matches!(reference.status, PushRefStatus::Ok) {
1001                reference.status = PushRefStatus::AtomicPushFailed;
1002            }
1003        }
1004        return Ok(PushStatusReport { refs });
1005    }
1006
1007    if request.dry_run {
1008        return Ok(PushStatusReport { refs });
1009    }
1010
1011    // Send only the commands that survived client-side checks.
1012    let send: Vec<ReceivePackCommand> = refs
1013        .iter()
1014        .filter(|reference| matches!(reference.status, PushRefStatus::Ok))
1015        .map(|reference| ReceivePackCommand {
1016            old_id: reference.old_id,
1017            new_id: reference.new_id,
1018            name: reference.dst.clone(),
1019        })
1020        .collect();
1021
1022    if !send.is_empty() {
1023        let remote_excluded_tips: Vec<ObjectId> =
1024            remote_refs.iter().map(|reference| reference.oid).collect();
1025        let pack_objects: Vec<ObjectId> = Vec::new();
1026        let starts = push_pack_roots(&send, &pack_objects);
1027        let remote_db =
1028            FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1029        let remote_excluded =
1030            collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1031        let packfile = if starts.is_empty() {
1032            Vec::new()
1033        } else {
1034            b"PACK".to_vec()
1035        };
1036        let receive_request = ReceivePackPushRequest {
1037            commands: ReceivePackRequest {
1038                shallow: Vec::new(),
1039                commands: send.clone(),
1040                capabilities: Vec::new(),
1041            },
1042            push_options: None,
1043            packfile,
1044        };
1045        let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1046            request.remote_git_dir,
1047            format,
1048            &receive_request,
1049            &local_db,
1050            starts,
1051            remote_excluded,
1052        )?;
1053        // Fold the receive-pack ng reports back onto the matching refs.
1054        if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1055            for reference in &mut refs {
1056                if matches!(reference.status, PushRefStatus::Ok) {
1057                    reference.status = PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1058                }
1059            }
1060        }
1061        for command_status in &report.commands {
1062            if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1063                for reference in &mut refs {
1064                    if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1065                        reference.status = PushRefStatus::RemoteReject(message.clone());
1066                    }
1067                }
1068            }
1069        }
1070    }
1071
1072    Ok(PushStatusReport { refs })
1073}
1074
1075/// Classify one planned command into git's send-pack pre-flight status: an
1076/// up-to-date no-op, a non-fast-forward rejection, a `--force-with-lease` stale
1077/// rejection, or `Ok` (the command will be sent).
1078fn classify_push_command(
1079    local_db: &FileObjectDatabase,
1080    format: ObjectFormat,
1081    plan: &PlannedPushCommand,
1082    request: &PushReportRequest<'_>,
1083    _config: &GitConfig,
1084    _remote_git_dir: &Path,
1085) -> Result<PushRefStatus> {
1086    let command = &plan.command;
1087
1088    // No change: the remote already has exactly this value (and it is not a
1089    // create-from-nothing of a non-existent ref). git reports UPTODATE.
1090    if command.old_id == command.new_id {
1091        return Ok(PushRefStatus::UpToDate);
1092    }
1093
1094    // `--force-with-lease`: the remote's current value must match the lease, or
1095    // the push is rejected as stale info — checked before the non-ff gate and
1096    // independent of `--force`.
1097    if let Some((_, expected)) = request
1098        .force_with_lease
1099        .iter()
1100        .find(|(dst, _)| *dst == command.name)
1101    {
1102        let actual = if command.old_id.is_null() {
1103            None
1104        } else {
1105            Some(command.old_id)
1106        };
1107        if *expected != actual {
1108            return Ok(PushRefStatus::RejectStale);
1109        }
1110        // A satisfied lease forces the update.
1111        return Ok(PushRefStatus::Ok);
1112    }
1113
1114    // Non-fast-forward branch update: rejected unless forced. Creations,
1115    // deletions, and non-branch refs skip this gate (matching git's send-pack).
1116    if !plan.force
1117        && command.name.starts_with("refs/heads/")
1118        && !command.old_id.is_null()
1119        && !command.new_id.is_null()
1120        && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1121    {
1122        return Ok(PushRefStatus::RejectNonFastForward);
1123    }
1124
1125    Ok(PushRefStatus::Ok)
1126}
1127
1128/// Whether `old` is an ancestor of `new` (a fast-forward). A walk from `new`;
1129/// `old` reachable ⇒ fast-forward.
1130fn is_fast_forward(
1131    db: &FileObjectDatabase,
1132    format: ObjectFormat,
1133    old: &ObjectId,
1134    new: &ObjectId,
1135) -> Result<bool> {
1136    let ancestors = ancestor_depths(db, format, new)?;
1137    Ok(ancestors.contains_key(old))
1138}
1139
1140/// Parse the receive-pack features from the leading ref advertisement (the empty
1141/// default when the remote advertised no refs).
1142#[cfg(feature = "http")]
1143fn advertised_receive_pack_features(
1144    advertisements: &[RefAdvertisement],
1145) -> Result<ReceivePackFeatures> {
1146    advertisements
1147        .first()
1148        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1149        .transpose()
1150        .map(Option::unwrap_or_default)
1151}
1152
1153/// Reject a push whose object format disagrees with the remote's advertised
1154/// `object-format`, and require the advertisement for any non-SHA-1 push.
1155#[cfg(feature = "http")]
1156fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1157    if let Some(remote_format) = features.object_format {
1158        if remote_format != format {
1159            return Err(GitError::InvalidObjectId(format!(
1160                "remote repository uses {}, local repository uses {}",
1161                remote_format.name(),
1162                format.name()
1163            )));
1164        }
1165    } else if format != ObjectFormat::Sha1 {
1166        return Err(GitError::InvalidObjectId(format!(
1167            "remote repository did not advertise object-format for {} push",
1168            format.name()
1169        )));
1170    }
1171    Ok(())
1172}
1173
1174/// The receive-pack push-request options for the negotiated `features`, matching
1175/// git: report-status when advertised, ofs-delta when advertised, `quiet` only
1176/// when both requested and advertised, and the advertised object-format only when
1177/// the local repository's `format` is not SHA-1.
1178#[cfg(feature = "http")]
1179fn receive_pack_push_options(
1180    features: &ReceivePackFeatures,
1181    format: ObjectFormat,
1182    quiet: bool,
1183) -> ReceivePackPushRequestOptions {
1184    ReceivePackPushRequestOptions {
1185        report_status: features.report_status,
1186        ofs_delta: features.ofs_delta,
1187        quiet: quiet && features.quiet,
1188        object_format: features
1189            .object_format
1190            .filter(|_| format != ObjectFormat::Sha1),
1191        ..ReceivePackPushRequestOptions::default()
1192    }
1193}
1194
1195/// Plan the receive-pack commands for `refspecs`, pairing each with whether it is
1196/// forced (the global `force` flag or the refspec's own `+`). Each refspec is
1197/// normalized then planned independently so per-refspec force is preserved,
1198/// matching the CLI.
1199fn plan_push_command_forces(
1200    format: ObjectFormat,
1201    local_refs: &[PushSourceRef],
1202    remote_refs: &[RefAdvertisement],
1203    refspecs: &[String],
1204    force: bool,
1205) -> Result<Vec<(ReceivePackCommand, bool)>> {
1206    let parsed_refspecs = refspecs
1207        .iter()
1208        .map(|refspec| {
1209            let normalized =
1210                normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1211            parse_refspec(&normalized)
1212        })
1213        .collect::<Result<Vec<_>>>()?;
1214    let mut command_forces = Vec::new();
1215    for refspec in &parsed_refspecs {
1216        for command in plan_push_commands(
1217            format,
1218            local_refs,
1219            remote_refs,
1220            std::slice::from_ref(refspec),
1221        )? {
1222            command_forces.push((command, force || refspec.force));
1223        }
1224    }
1225    Ok(command_forces)
1226}
1227
1228/// One planned push command paired with its forcing flag and the local source
1229/// ref it came from (git's `ref->peer_ref`). A delete carries `source: None`.
1230struct PlannedPushCommand {
1231    command: ReceivePackCommand,
1232    force: bool,
1233    source: Option<String>,
1234}
1235
1236/// Like [`plan_push_command_forces`], but also records the local source ref each
1237/// command resolved from so the status report can print the `from -> to` line.
1238/// The source is the normalized refspec source name; a delete (`:dst`) has no
1239/// source. A pattern refspec re-derives each expanded command's source from its
1240/// destination by reversing the wildcard substitution.
1241fn plan_push_command_sources(
1242    format: ObjectFormat,
1243    local_refs: &[PushSourceRef],
1244    remote_refs: &[RefAdvertisement],
1245    refspecs: &[String],
1246    force: bool,
1247) -> Result<Vec<PlannedPushCommand>> {
1248    let mut planned = Vec::new();
1249    for refspec in refspecs {
1250        let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1251        let parsed = parse_refspec(&normalized)?;
1252        let commands =
1253            plan_push_commands(format, local_refs, remote_refs, std::slice::from_ref(&parsed))?;
1254        for command in commands {
1255            let source = push_command_source_name(&parsed, &command);
1256            planned.push(PlannedPushCommand {
1257                command,
1258                force: force || parsed.force,
1259                source,
1260            });
1261        }
1262    }
1263    Ok(planned)
1264}
1265
1266/// Recover the local source ref name for one planned `command` from its owning
1267/// `refspec`. Deletes (no `src`) return `None`. A wildcard pattern reverses the
1268/// substitution: the command's destination minus the pattern's destination
1269/// affix yields the matched stem, which slots into the pattern's source affix.
1270fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1271    let src = refspec.src.as_deref()?;
1272    if !refspec.pattern {
1273        return Some(src.to_string());
1274    }
1275    let (src_prefix, src_suffix) = src.split_once('*')?;
1276    let dst = refspec.dst.as_deref()?;
1277    let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1278    let stem = command
1279        .name
1280        .strip_prefix(dst_prefix)
1281        .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1282    Some(format!("{src_prefix}{stem}{src_suffix}"))
1283}
1284
1285fn add_revision_push_sources(
1286    git_dir: &Path,
1287    format: ObjectFormat,
1288    refspecs: &[String],
1289    local_refs: &mut Vec<PushSourceRef>,
1290) {
1291    for refspec in refspecs {
1292        let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1293        let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1294        if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
1295            continue;
1296        }
1297        if local_refs.iter().any(|reference| {
1298            reference.name == src
1299                || reference.name == format!("refs/heads/{src}")
1300                || reference.name == format!("refs/tags/{src}")
1301        }) {
1302            continue;
1303        }
1304        if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1305            && !local_refs.iter().any(|reference| reference.name == src)
1306        {
1307            local_refs.push(PushSourceRef {
1308                name: src.to_string(),
1309                oid,
1310            });
1311        }
1312    }
1313}
1314
1315fn normalize_push_refspec_for_sources(
1316    refspec: &str,
1317    local_refs: &[PushSourceRef],
1318    remote_refs: &[RefAdvertisement],
1319) -> Result<String> {
1320    let (force, refspec) = refspec
1321        .strip_prefix('+')
1322        .map_or((false, refspec), |refspec| (true, refspec));
1323    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1324        let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1325        let dst = normalize_push_destination_refname(dst, src_kind, remote_refs)?;
1326        format!("{src}:{dst}")
1327    } else {
1328        let (name, _) = normalize_push_source_refname(refspec, local_refs);
1329        // A colon-less refspec re-uses the source's *resolved* full name as the
1330        // implicit destination (git's `match_explicit`: a NULL dst resolves to
1331        // the matched source ref). That full name is then disambiguated against
1332        // the remote's existing refs, so `git push <remote> frotz` (a tag)
1333        // lands on `refs/tags/frotz` even when the remote also has a same-named
1334        // branch.
1335        let dst = match count_refspec_match_dst(&name, remote_refs) {
1336            DstMatch::Unique(matched) => matched.to_string(),
1337            DstMatch::None => name.clone(),
1338            DstMatch::Ambiguous => {
1339                return Err(GitError::Command(format!(
1340                    "dst refspec {name} matches more than one"
1341                )));
1342            }
1343        };
1344        format!("{name}:{dst}")
1345    };
1346    Ok(if force {
1347        format!("+{normalized}")
1348    } else {
1349        normalized
1350    })
1351}
1352
1353/// git's `refname_match`: true when `full_name` equals `abbrev` expanded by one
1354/// of the `ref_rev_parse_rules`. Returns the matched rule's rank (higher = more
1355/// specific) so the caller can replicate git's strong/weak distinction.
1356fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
1357    const RULES: [&str; 6] = [
1358        "{}",
1359        "refs/{}",
1360        "refs/tags/{}",
1361        "refs/heads/{}",
1362        "refs/remotes/{}",
1363        "refs/remotes/{}/HEAD",
1364    ];
1365    for (idx, rule) in RULES.iter().enumerate() {
1366        let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
1367        if full_name == format!("{prefix}{abbrev}{suffix}") {
1368            return Some(RULES.len() - idx);
1369        }
1370    }
1371    None
1372}
1373
1374/// The outcome of git's `count_refspec_match` for a push destination.
1375enum DstMatch<'a> {
1376    /// Exactly one acceptable match (one strong, or zero strong + one weak).
1377    Unique(&'a str),
1378    /// No remote ref matched — the caller should `guess_ref` or use the literal.
1379    None,
1380    /// More than one match — git dies with "dst refspec … matches more than one".
1381    Ambiguous,
1382}
1383
1384/// git's `count_refspec_match` for a push destination: find the unique existing
1385/// remote ref that `pattern` resolves to, distinguishing strong matches (full
1386/// name, top-level, or a head/tag) from weak ones (a partial match outside
1387/// heads/tags, e.g. `origin/main` → `refs/remotes/origin/main`). One strong
1388/// match wins outright; with no strong match a single weak match is used; more
1389/// than one acceptable match is ambiguous.
1390fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1391    let patlen = pattern.len();
1392    let mut strong: Option<&str> = None;
1393    let mut strong_count = 0usize;
1394    let mut weak: Option<&str> = None;
1395    let mut weak_count = 0usize;
1396    for advert in remote_refs {
1397        let name = advert.name.as_str();
1398        if refname_match_rank(pattern, name).is_none() {
1399            continue;
1400        }
1401        let namelen = name.len();
1402        let is_weak = namelen != patlen
1403            && patlen + 5 != namelen
1404            && !name.starts_with("refs/heads/")
1405            && !name.starts_with("refs/tags/");
1406        if is_weak {
1407            weak = Some(name);
1408            weak_count += 1;
1409        } else {
1410            strong = Some(name);
1411            strong_count += 1;
1412        }
1413    }
1414    match (strong_count, weak_count, strong, weak) {
1415        (1, _, Some(matched), _) => DstMatch::Unique(matched),
1416        (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1417        (0, 0, _, _) => DstMatch::None,
1418        _ => DstMatch::Ambiguous,
1419    }
1420}
1421
1422#[derive(Clone, Copy)]
1423enum PushSourceKind {
1424    Branch,
1425    Tag,
1426    /// A source ref that resolves but is neither under `refs/heads/` nor
1427    /// `refs/tags/` (e.g. `HEAD`, a fully-qualified `refs/...` name). git's
1428    /// `guess_ref` still guesses `refs/heads/<dst>` for these.
1429    Other,
1430    /// A source that is NOT a ref at all (a raw object id or a rev-expression
1431    /// like `main^`). git's `guess_ref` resolves nothing for these, so an
1432    /// unqualified destination cannot be guessed and the push is rejected.
1433    Unqualifiable,
1434}
1435
1436fn normalize_push_source_refname(
1437    name: &str,
1438    local_refs: &[PushSourceRef],
1439) -> (String, PushSourceKind) {
1440    // `@` is git's documented alias for `HEAD`; like `HEAD` it resolves to a
1441    // branch, so `guess_ref` can still qualify an unqualified destination.
1442    if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
1443        return (name.to_string(), PushSourceKind::Other);
1444    }
1445    let branch = format!("refs/heads/{name}");
1446    let tag = format!("refs/tags/{name}");
1447    let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1448    let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1449    if has_tag && !has_branch {
1450        (tag, PushSourceKind::Tag)
1451    } else if has_branch {
1452        (branch, PushSourceKind::Branch)
1453    } else if local_refs.iter().any(|reference| reference.name == name) {
1454        // A literal match outside heads/tags/HEAD/refs is a revision source
1455        // injected by `add_revision_push_sources` (an oid or `main^`-style
1456        // expression) — not a ref, so a partial dst cannot be guessed.
1457        (name.to_string(), PushSourceKind::Unqualifiable)
1458    } else {
1459        (branch, PushSourceKind::Branch)
1460    }
1461}
1462
1463fn normalize_push_destination_refname(
1464    name: &str,
1465    src_kind: PushSourceKind,
1466    remote_refs: &[RefAdvertisement],
1467) -> Result<String> {
1468    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1469        return Ok(name.to_string());
1470    }
1471    // git's `match_explicit`: a partial destination first resolves against the
1472    // remote's existing refs (so `main:origin/main` lands on the existing
1473    // `refs/remotes/origin/main`); an ambiguous match is fatal; only when
1474    // nothing matches does it fall back to `guess_ref`'s heads/tags choice
1475    // driven by the source ref's kind.
1476    match count_refspec_match_dst(name, remote_refs) {
1477        DstMatch::Unique(matched) => Ok(matched.to_string()),
1478        DstMatch::Ambiguous => Err(GitError::Command(format!(
1479            "dst refspec {name} matches more than one"
1480        ))),
1481        DstMatch::None => match src_kind {
1482            PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
1483            PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
1484            // git's `guess_ref` returns NULL for a non-ref source, so the
1485            // unqualified destination is unresolvable (the "destination is not a
1486            // full refname … you must fully qualify the ref" error).
1487            PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
1488                "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
1489            ))),
1490        },
1491    }
1492}
1493
1494/// The planned commands, dropping the per-command force flags.
1495fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1496    command_forces
1497        .iter()
1498        .map(|(command, _)| command.clone())
1499        .collect()
1500}
1501
1502fn receive_pack_commands_from_action_plan(
1503    format: ObjectFormat,
1504    plan: &PushActionPlan,
1505) -> Result<Vec<ReceivePackCommand>> {
1506    let zero = ObjectId::null(format);
1507    for oid in &plan.pack_objects {
1508        if oid.format() != format {
1509            return Err(GitError::InvalidObjectId(format!(
1510                "push pack object {oid} has {} object id for {} repository",
1511                oid.format().name(),
1512                format.name()
1513            )));
1514        }
1515    }
1516    plan.commands
1517        .iter()
1518        .map(|command| {
1519            let old_id = command.expected_old.unwrap_or(zero);
1520            let new_id = command.src.unwrap_or(zero);
1521            if old_id.format() != format {
1522                return Err(GitError::InvalidObjectId(format!(
1523                    "push command {} expected old has {} object id for {} repository",
1524                    command.dst,
1525                    old_id.format().name(),
1526                    format.name()
1527                )));
1528            }
1529            if new_id.format() != format {
1530                return Err(GitError::InvalidObjectId(format!(
1531                    "push command {} new id has {} object id for {} repository",
1532                    command.dst,
1533                    new_id.format().name(),
1534                    format.name()
1535                )));
1536            }
1537            Ok(ReceivePackCommand {
1538                old_id,
1539                new_id,
1540                name: command.dst.clone(),
1541            })
1542        })
1543        .collect()
1544}
1545
1546/// Validate a receive-pack report-status, surfacing a failed unpack or any
1547/// rejected ref as an error (matching git's exit-failure message form).
1548pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1549    if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1550        return Err(GitError::Command(format!(
1551            "failed to push some refs: unpack failed: {message}"
1552        )));
1553    }
1554    for status in &report.commands {
1555        if let ReceivePackCommandStatus::Ng { name, message } = status {
1556            return Err(GitError::Command(format!(
1557                "failed to push {name}: {message}"
1558            )));
1559        }
1560    }
1561    Ok(())
1562}
1563
1564/// The push-source refs a local repository can match refspecs against: every ref
1565/// resolved to its object id, plus the short `refs/heads/`*and `refs/tags/`*
1566/// aliases, plus `HEAD`. Errors if any ref's object id does not match `format`.
1567pub fn local_push_source_refs(
1568    store: &FileRefStore,
1569    format: ObjectFormat,
1570) -> Result<Vec<PushSourceRef>> {
1571    let mut refs = Vec::new();
1572    for reference in store.list_refs()? {
1573        let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1574            continue;
1575        };
1576        if oid.format() != format {
1577            return Err(GitError::InvalidObjectId(format!(
1578                "local ref {} has {} object id for {} repository",
1579                reference.name,
1580                oid.format().name(),
1581                format.name()
1582            )));
1583        }
1584        refs.push(PushSourceRef {
1585            name: reference.name.clone(),
1586            oid,
1587        });
1588        if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1589            refs.push(PushSourceRef {
1590                name: short.to_string(),
1591                oid,
1592            });
1593        }
1594        if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1595            refs.push(PushSourceRef {
1596                name: short.to_string(),
1597                oid,
1598            });
1599        }
1600    }
1601    if let Some(target) = store.read_ref("HEAD")? {
1602        let head = Ref {
1603            name: "HEAD".to_string(),
1604            target,
1605        };
1606        if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1607            && oid.format() == format
1608        {
1609            refs.push(PushSourceRef {
1610                name: "HEAD".to_string(),
1611                oid,
1612            });
1613        }
1614    }
1615    Ok(refs)
1616}
1617
1618/// Normalize a push refspec, expanding short names to `refs/heads/<name>` on both
1619/// sides and supplying the source as the destination when none is given, while
1620/// preserving a leading `+` force marker.
1621pub fn normalize_push_refspec(refspec: &str) -> String {
1622    let (force, refspec) = refspec
1623        .strip_prefix('+')
1624        .map_or((false, refspec), |refspec| (true, refspec));
1625    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1626        let src = normalize_push_refname(src);
1627        let dst = normalize_push_refname(dst);
1628        format!("{src}:{dst}")
1629    } else {
1630        let name = normalize_push_refname(refspec);
1631        format!("{name}:{name}")
1632    };
1633    if force {
1634        format!("+{normalized}")
1635    } else {
1636        normalized
1637    }
1638}
1639
1640/// Expand a short push ref name to `refs/heads/<name>`, leaving empty names,
1641/// `HEAD`, and already-qualified `refs/`* names untouched.
1642pub fn normalize_push_refname(name: &str) -> String {
1643    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1644        name.to_string()
1645    } else {
1646        format!("refs/heads/{name}")
1647    }
1648}
1649
1650/// Reject any non-forced branch update whose old tip is not an ancestor of the
1651/// new tip (a non-fast-forward). Forced updates, non-branch refs, and
1652/// creations/deletions are skipped.
1653pub fn reject_non_fast_forward_pushes(
1654    local_db: &FileObjectDatabase,
1655    format: ObjectFormat,
1656    command_forces: &[(ReceivePackCommand, bool)],
1657) -> Result<()> {
1658    for (command, force) in command_forces {
1659        if *force
1660            || !command.name.starts_with("refs/heads/")
1661            || command.old_id.is_null()
1662            || command.new_id.is_null()
1663        {
1664            continue;
1665        }
1666        let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1667        if !ancestors.contains_key(&command.old_id) {
1668            let short = command.name.trim_start_matches("refs/heads/");
1669            return Err(GitError::Command(format!(
1670                "failed to push some refs: non-fast-forward update to {short}"
1671            )));
1672        }
1673    }
1674    Ok(())
1675}
1676
1677/// The depth of every commit reachable from `start` (a breadth-first ancestry
1678/// walk). Used to test fast-forwardness: `start`'s ancestors include `start`
1679/// itself at depth zero. Errors if a reachable object is not a commit.
1680fn ancestor_depths(
1681    db: &FileObjectDatabase,
1682    format: ObjectFormat,
1683    start: &ObjectId,
1684) -> Result<HashMap<ObjectId, usize>> {
1685    let mut depths = HashMap::new();
1686    let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1687    while let Some((oid, depth)) = pending.pop_front() {
1688        if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1689            continue;
1690        }
1691        depths.insert(oid, depth);
1692        let object = db.read_object(&oid)?;
1693        if object.object_type != ObjectType::Commit {
1694            return Err(GitError::InvalidObject(format!(
1695                "expected commit {oid}, found {}",
1696                object.object_type.as_str()
1697            )));
1698        }
1699        let commit = Commit::parse_ref(format, &object.body)?;
1700        for parent in commit.parents {
1701            pending.push_back((parent, depth + 1));
1702        }
1703    }
1704    Ok(depths)
1705}
1706
1707/// Resolve a (possibly symbolic) ref target to its object id, following up to
1708/// five levels of symbolic indirection, returning the first symbolic name seen.
1709fn resolve_for_each_ref_target(
1710    store: &FileRefStore,
1711    reference: &Ref,
1712) -> Result<Option<(ObjectId, Option<String>)>> {
1713    let mut target = reference.target.clone();
1714    let mut symref = None;
1715    for _ in 0..5 {
1716        match target {
1717            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1718            RefTarget::Symbolic(name) => {
1719                symref.get_or_insert_with(|| name.clone());
1720                let Some(next) = store.read_ref(&name)? else {
1721                    return Ok(None);
1722                };
1723                target = next;
1724            }
1725        }
1726    }
1727    Ok(None)
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use std::fs;
1734    use std::sync::atomic::{AtomicU64, Ordering};
1735
1736    use sley_formats::RepositoryLayout;
1737    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1738    use sley_odb::{FileObjectDatabase, ObjectWriter};
1739    use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1740    use sley_refs::{RefTarget, RefUpdate};
1741
1742    use crate::{NoCredentials, SilentProgress};
1743
1744    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1745
1746    fn temp_repo(name: &str) -> PathBuf {
1747        let dir = std::env::temp_dir().join(format!(
1748            "sley-remote-push-{name}-{}-{}",
1749            std::process::id(),
1750            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1751        ));
1752        let _ = fs::remove_dir_all(&dir);
1753        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1754            .expect("test repository should initialize");
1755        dir.join(".git")
1756    }
1757
1758    fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1759        let format = ObjectFormat::Sha1;
1760        let db = FileObjectDatabase::from_git_dir(git_dir, format);
1761        let tree = db
1762            .write_object(EncodedObject::new(
1763                ObjectType::Tree,
1764                Tree { entries: vec![] }.write(),
1765            ))
1766            .expect("tree should write");
1767        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1768        db.write_object(EncodedObject::new(
1769            ObjectType::Commit,
1770            Commit {
1771                tree,
1772                parents,
1773                author: identity.clone(),
1774                committer: identity,
1775                encoding: None,
1776                message: format!("{message}\n").into_bytes(),
1777            }
1778            .write(),
1779        ))
1780        .expect("commit should write")
1781    }
1782
1783    fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1784        let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1785        let mut tx = store.transaction();
1786        tx.update(RefUpdate {
1787            name: name.to_string(),
1788            expected: None,
1789            new: target,
1790            reflog: None,
1791        });
1792        tx.commit().expect("ref should update");
1793    }
1794
1795    fn default_options() -> PushOptions {
1796        PushOptions {
1797            quiet: true,
1798            force: false,
1799        }
1800    }
1801
1802    #[test]
1803    fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1804        let repo = temp_repo("action-plan-infer-roots");
1805        let first = write_commit(&repo, Vec::new(), "first");
1806        let second = write_commit(&repo, vec![first], "second");
1807
1808        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1809            vec![
1810                PushCommand {
1811                    src: Some(first),
1812                    dst: "refs/heads/main".into(),
1813                    expected_old: None,
1814                    force: false,
1815                },
1816                PushCommand {
1817                    src: Some(second),
1818                    dst: "refs/heads/topic".into(),
1819                    expected_old: Some(first),
1820                    force: true,
1821                },
1822            ],
1823            default_options(),
1824        );
1825
1826        assert_eq!(plan.pack_objects, vec![first, second]);
1827        assert!(!plan.commands[0].force);
1828        assert!(plan.commands[1].force);
1829    }
1830
1831    #[test]
1832    fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1833        let repo = temp_repo("action-plan-delete-roots");
1834        let old = write_commit(&repo, Vec::new(), "old");
1835        let new = write_commit(&repo, vec![old], "new");
1836
1837        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1838            vec![
1839                PushCommand {
1840                    src: None,
1841                    dst: "refs/heads/remove".into(),
1842                    expected_old: Some(old),
1843                    force: false,
1844                },
1845                PushCommand {
1846                    src: Some(new),
1847                    dst: "refs/heads/keep".into(),
1848                    expected_old: Some(old),
1849                    force: false,
1850                },
1851            ],
1852            default_options(),
1853        );
1854
1855        assert_eq!(plan.pack_objects, vec![new]);
1856    }
1857
1858    #[test]
1859    fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1860        let repo = temp_repo("action-plan-dedupe-roots");
1861        let first = write_commit(&repo, Vec::new(), "first");
1862        let second = write_commit(&repo, Vec::new(), "second");
1863
1864        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1865            vec![
1866                PushCommand {
1867                    src: Some(second),
1868                    dst: "refs/heads/second".into(),
1869                    expected_old: None,
1870                    force: false,
1871                },
1872                PushCommand {
1873                    src: Some(first),
1874                    dst: "refs/heads/first".into(),
1875                    expected_old: None,
1876                    force: false,
1877                },
1878                PushCommand {
1879                    src: Some(second),
1880                    dst: "refs/tags/second".into(),
1881                    expected_old: None,
1882                    force: false,
1883                },
1884                PushCommand {
1885                    src: Some(first),
1886                    dst: "refs/tags/first".into(),
1887                    expected_old: None,
1888                    force: false,
1889                },
1890            ],
1891            default_options(),
1892        );
1893
1894        assert_eq!(plan.pack_objects, vec![second, first]);
1895    }
1896
1897    fn push_local_actions(
1898        local: &Path,
1899        remote: &Path,
1900        plan: &PushActionPlan,
1901    ) -> Result<PushOutcome> {
1902        let destination = PushDestination::Local {
1903            git_dir: remote.to_path_buf(),
1904            common_git_dir: remote.to_path_buf(),
1905        };
1906        let config = GitConfig::default();
1907        let mut credentials = NoCredentials;
1908        let mut progress = SilentProgress;
1909        push_actions(
1910            PushActionRequest {
1911                git_dir: local,
1912                common_git_dir: local,
1913                format: ObjectFormat::Sha1,
1914                config: &config,
1915                remote: "origin",
1916                destination: &destination,
1917                plan,
1918            },
1919            PushServices {
1920                credentials: &mut credentials,
1921                progress: &mut progress,
1922            },
1923        )
1924    }
1925
1926    #[test]
1927    fn local_push_returns_success_report_status_and_updates_ref() {
1928        let local = temp_repo("local-success");
1929        let remote = temp_repo("remote-success");
1930        let base = write_commit(&local, Vec::new(), "base");
1931        let tip = write_commit(&local, vec![base], "tip");
1932        set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1933        set_ref(
1934            &local,
1935            "HEAD",
1936            RefTarget::Symbolic("refs/heads/main".into()),
1937        );
1938        let destination = PushDestination::Local {
1939            git_dir: remote.clone(),
1940            common_git_dir: remote.clone(),
1941        };
1942        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1943        let options = default_options();
1944        let request = PushRequest {
1945            git_dir: &local,
1946            common_git_dir: &local,
1947            format: ObjectFormat::Sha1,
1948            config: &GitConfig::default(),
1949            remote: "origin",
1950            destination: &destination,
1951            refspecs: &refspecs,
1952            options: &options,
1953        };
1954        let mut credentials = NoCredentials;
1955        let mut progress = SilentProgress;
1956
1957        let outcome = push(
1958            request,
1959            PushServices {
1960                credentials: &mut credentials,
1961                progress: &mut progress,
1962            },
1963        )
1964        .expect("push should succeed");
1965
1966        assert_eq!(outcome.commands.len(), 1);
1967        let report = outcome.report.expect("local receive-pack reports status");
1968        assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1969        assert!(matches!(
1970            report.commands.as_slice(),
1971            [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1972        ));
1973        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1974        assert_eq!(
1975            remote_refs
1976                .read_ref("refs/heads/main")
1977                .expect("remote ref should read"),
1978            Some(RefTarget::Direct(tip))
1979        );
1980    }
1981
1982    #[test]
1983    fn local_push_actions_preserves_exact_old_new_update() {
1984        let local = temp_repo("actions-update-local");
1985        let remote = temp_repo("actions-update-remote");
1986        let base = write_commit(&local, Vec::new(), "base");
1987        let remote_base = write_commit(&remote, Vec::new(), "base");
1988        assert_eq!(remote_base, base);
1989        let tip = write_commit(&local, vec![base], "tip");
1990        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1991        let plan = PushActionPlan::from_actions(
1992            vec![PushAction::Update {
1993                dst: "refs/heads/main".into(),
1994                old: base,
1995                new: tip,
1996            }],
1997            default_options(),
1998        );
1999
2000        let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2001
2002        assert_eq!(outcome.commands.len(), 1);
2003        assert_eq!(outcome.commands[0].old_id, base);
2004        assert_eq!(outcome.commands[0].new_id, tip);
2005        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2006        assert_eq!(
2007            remote_refs
2008                .read_ref("refs/heads/main")
2009                .expect("remote ref should read"),
2010            Some(RefTarget::Direct(tip))
2011        );
2012    }
2013
2014    #[test]
2015    fn local_push_actions_honors_per_command_force() {
2016        let local = temp_repo("actions-command-force-local");
2017        let remote = temp_repo("actions-command-force-remote");
2018        let base = write_commit(&local, Vec::new(), "base");
2019        let remote_base = write_commit(&remote, Vec::new(), "base");
2020        assert_eq!(remote_base, base);
2021        let unrelated = write_commit(&local, Vec::new(), "unrelated");
2022        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2023
2024        let unforced = PushActionPlan::from_commands(
2025            vec![PushCommand {
2026                src: Some(unrelated),
2027                dst: "refs/heads/main".into(),
2028                expected_old: Some(base),
2029                force: false,
2030            }],
2031            default_options(),
2032        );
2033        let err = push_local_actions(&local, &remote, &unforced)
2034            .expect_err("non-fast-forward should reject without command force");
2035        assert!(err.to_string().contains("non-fast-forward"));
2036
2037        let forced = PushActionPlan::from_commands(
2038            vec![PushCommand {
2039                src: Some(unrelated),
2040                dst: "refs/heads/main".into(),
2041                expected_old: Some(base),
2042                force: true,
2043            }],
2044            default_options(),
2045        );
2046        let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2047
2048        assert_eq!(outcome.commands.len(), 1);
2049        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2050        assert_eq!(
2051            remote_refs
2052                .read_ref("refs/heads/main")
2053                .expect("remote ref should read"),
2054            Some(RefTarget::Direct(unrelated))
2055        );
2056    }
2057
2058    #[test]
2059    fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2060        let local = temp_repo("actions-command-force-precise-local");
2061        let remote = temp_repo("actions-command-force-precise-remote");
2062        let base = write_commit(&local, Vec::new(), "base");
2063        let remote_base = write_commit(&remote, Vec::new(), "base");
2064        assert_eq!(remote_base, base);
2065        let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2066        let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2067        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2068        set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2069        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2070            vec![
2071                PushCommand {
2072                    src: Some(forced_unrelated),
2073                    dst: "refs/heads/main".into(),
2074                    expected_old: Some(base),
2075                    force: true,
2076                },
2077                PushCommand {
2078                    src: Some(unforced_unrelated),
2079                    dst: "refs/heads/topic".into(),
2080                    expected_old: Some(base),
2081                    force: false,
2082                },
2083            ],
2084            default_options(),
2085        );
2086
2087        let err = push_local_actions(&local, &remote, &plan)
2088            .expect_err("only the forced command should bypass non-fast-forward validation");
2089
2090        assert!(err.to_string().contains("non-fast-forward update to topic"));
2091        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2092        assert_eq!(
2093            remote_refs
2094                .read_ref("refs/heads/main")
2095                .expect("remote ref should read"),
2096            Some(RefTarget::Direct(base))
2097        );
2098        assert_eq!(
2099            remote_refs
2100                .read_ref("refs/heads/topic")
2101                .expect("remote ref should read"),
2102            Some(RefTarget::Direct(base))
2103        );
2104    }
2105
2106    #[test]
2107    fn local_push_actions_stale_update_old_rejects_without_mutating() {
2108        let local = temp_repo("actions-stale-local");
2109        let remote = temp_repo("actions-stale-remote");
2110        let base = write_commit(&local, Vec::new(), "base");
2111        let remote_base = write_commit(&remote, Vec::new(), "base");
2112        assert_eq!(remote_base, base);
2113        let tip = write_commit(&local, vec![base], "tip");
2114        let concurrent = write_commit(&remote, vec![base], "concurrent");
2115        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2116        let plan = PushActionPlan::from_actions(
2117            vec![PushAction::Update {
2118                dst: "refs/heads/main".into(),
2119                old: base,
2120                new: tip,
2121            }],
2122            default_options(),
2123        );
2124
2125        let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2126
2127        assert!(err.to_string().contains("expected ref refs/heads/main"));
2128        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2129        assert_eq!(
2130            remote_refs
2131                .read_ref("refs/heads/main")
2132                .expect("remote ref should read"),
2133            Some(RefTarget::Direct(concurrent))
2134        );
2135    }
2136
2137    #[test]
2138    fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2139        let local = temp_repo("actions-delete-local");
2140        let remote = temp_repo("actions-delete-remote");
2141        let base = write_commit(&local, Vec::new(), "base");
2142        let remote_base = write_commit(&remote, Vec::new(), "base");
2143        assert_eq!(remote_base, base);
2144        let concurrent = write_commit(&remote, vec![base], "concurrent");
2145        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2146        let plan = PushActionPlan::from_actions(
2147            vec![PushAction::Delete {
2148                dst: "refs/heads/main".into(),
2149                old: Some(base),
2150            }],
2151            default_options(),
2152        );
2153
2154        let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
2155
2156        assert!(err.to_string().contains("expected ref refs/heads/main"));
2157        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2158        assert_eq!(
2159            remote_refs
2160                .read_ref("refs/heads/main")
2161                .expect("remote ref should read"),
2162            Some(RefTarget::Direct(concurrent))
2163        );
2164    }
2165
2166    #[test]
2167    fn local_push_actions_create_rejects_existing_ref() {
2168        let local = temp_repo("actions-create-local");
2169        let remote = temp_repo("actions-create-remote");
2170        let base = write_commit(&local, Vec::new(), "base");
2171        let remote_base = write_commit(&remote, Vec::new(), "base");
2172        assert_eq!(remote_base, base);
2173        let tip = write_commit(&local, vec![base], "tip");
2174        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2175        let plan = PushActionPlan::from_actions(
2176            vec![PushAction::Create {
2177                dst: "refs/heads/main".into(),
2178                new: tip,
2179            }],
2180            default_options(),
2181        );
2182
2183        let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
2184
2185        assert!(
2186            err.to_string()
2187                .contains("expected ref refs/heads/main to not already exist")
2188        );
2189        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2190        assert_eq!(
2191            remote_refs
2192                .read_ref("refs/heads/main")
2193                .expect("remote ref should read"),
2194            Some(RefTarget::Direct(base))
2195        );
2196    }
2197
2198    #[test]
2199    fn report_status_rejection_is_an_error() {
2200        let report = ReceivePackReportStatus {
2201            unpack: ReceivePackUnpackStatus::Ok,
2202            commands: vec![ReceivePackCommandStatus::Ng {
2203                name: "refs/heads/main".into(),
2204                message: "hook declined".into(),
2205            }],
2206        };
2207
2208        let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
2209
2210        assert!(err.to_string().contains("hook declined"));
2211    }
2212
2213    #[test]
2214    fn failed_local_push_does_not_partially_mutate_remote_ref() {
2215        let local = temp_repo("local-rejected");
2216        let remote = temp_repo("remote-rejected");
2217        let base = write_commit(&local, Vec::new(), "base");
2218        let planned = write_commit(&local, vec![base], "planned");
2219        let concurrent = write_commit(&local, vec![base], "concurrent");
2220        set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
2221        set_ref(
2222            &local,
2223            "HEAD",
2224            RefTarget::Symbolic("refs/heads/main".into()),
2225        );
2226        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2227        let destination = PushDestination::Local {
2228            git_dir: remote.clone(),
2229            common_git_dir: remote.clone(),
2230        };
2231        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2232        let options = default_options();
2233        let request = PushRequest {
2234            git_dir: &local,
2235            common_git_dir: &local,
2236            format: ObjectFormat::Sha1,
2237            config: &GitConfig::default(),
2238            remote: "origin",
2239            destination: &destination,
2240            refspecs: &refspecs,
2241            options: &options,
2242        };
2243        let mut credentials = NoCredentials;
2244        let mut progress = SilentProgress;
2245        let mut services = PushServices {
2246            credentials: &mut credentials,
2247            progress: &mut progress,
2248        };
2249        let plan = plan_push(request, &mut services).expect("push should plan");
2250
2251        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2252        let _err = execute_push_plan(request, &mut services, plan)
2253            .expect_err("stale old id should reject the ref update");
2254
2255        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2256        assert_eq!(
2257            remote_refs
2258                .read_ref("refs/heads/main")
2259                .expect("remote ref should read"),
2260            Some(RefTarget::Direct(concurrent))
2261        );
2262    }
2263}