Skip to main content

sley_remote/
push.rs

1//! Callable push orchestration for HTTP(S) and local (`file://`/path) remotes.
2//!
3//! [`push`] sequences the moved transport plumbing ([`crate::http`],
4//! [`crate::local`]) and the protocol codecs ([`sley_protocol`]) into the full
5//! push flow: it advertises the remote's refs, plans the receive-pack commands
6//! for the requested refspecs, rejects non-fast-forward updates (unless forced),
7//! builds the packfile of the objects the remote is missing, sends the
8//! receive-pack request, and parses the report-status. Everything is taken as
9//! explicit parameters — `git_dir`, `common_git_dir`, the [`ObjectFormat`], the
10//! repository [`GitConfig`], the already-resolved destination, the push refspecs,
11//! a [`PushOptions`], and the seam objects ([`CredentialProvider`],
12//! [`ProgressSink`]) — so it never reads process-global state, parses arguments,
13//! or prints. The structured result ([`PushOutcome`]) carries the executed
14//! receive-pack commands and the remote's report-status for the caller to format
15//! into git's "To <remote>" summary and to drive any set-upstream config write.
16//!
17//! SSH push still lives in the CLI; only HTTP and local move here. The
18//! push-planning helpers are shared (the CLI's SSH path calls the same `pub`
19//! functions) so there is a single implementation.
20
21use std::collections::HashMap;
22#[cfg(feature = "http")]
23use std::io::Read;
24use std::path::{Path, PathBuf};
25
26use sley_config::GitConfig;
27use sley_core::{GitError, ObjectFormat, ObjectId, Result};
28use sley_object::{Commit, ObjectType};
29use sley_odb::{FileObjectDatabase, ObjectReader, collect_reachable_object_ids};
30#[cfg(feature = "http")]
31use sley_protocol::{
32    GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
33    read_receive_pack_report_status, smart_http_rpc_request_content_type,
34    smart_http_rpc_result_content_type,
35};
36use sley_protocol::{
37    PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
38    ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
39    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/// Fully resolved inputs for a [`push`] run.
217#[derive(Clone, Copy)]
218pub struct PushRequest<'a> {
219    /// Local repository `$GIT_DIR`.
220    pub git_dir: &'a Path,
221    /// Local repository common `$GIT_DIR`, used for object access.
222    pub common_git_dir: &'a Path,
223    /// Local repository object format.
224    pub format: ObjectFormat,
225    /// Local repository config snapshot.
226    pub config: &'a GitConfig,
227    /// Remote name or source string, used for diagnostics.
228    pub remote: &'a str,
229    /// Already-resolved push destination.
230    pub destination: &'a PushDestination,
231    /// Refspecs requested by the caller.
232    pub refspecs: &'a [String],
233    /// Push behavior flags.
234    pub options: &'a PushOptions,
235}
236
237/// Fully resolved inputs for a caller-authored exact push plan.
238#[derive(Clone, Copy)]
239pub struct PushActionRequest<'a> {
240    /// Local repository `$GIT_DIR`.
241    pub git_dir: &'a Path,
242    /// Local repository common `$GIT_DIR`, used for object access.
243    pub common_git_dir: &'a Path,
244    /// Local repository object format.
245    pub format: ObjectFormat,
246    /// Local repository config snapshot.
247    pub config: &'a GitConfig,
248    /// Remote name or source string, used for diagnostics.
249    pub remote: &'a str,
250    /// Already-resolved push destination.
251    pub destination: &'a PushDestination,
252    /// Caller-authored exact push plan.
253    pub plan: &'a PushActionPlan,
254}
255
256/// Mutable seams used while pushing.
257pub struct PushServices<'a> {
258    /// Credential source for authenticated transports.
259    pub credentials: &'a mut dyn CredentialProvider,
260    /// Progress sink reserved for future push progress.
261    pub progress: &'a mut dyn ProgressSink,
262}
263
264/// A push after ref negotiation and command planning, but before any ref update
265/// is sent or applied.
266pub struct PushPlan {
267    /// The receive-pack commands that will be executed if the caller proceeds.
268    pub commands: Vec<ReceivePackCommand>,
269    execution: PushExecution,
270}
271
272enum PushExecution {
273    Noop,
274    #[cfg(feature = "http")]
275    Http {
276        remote_url: RemoteUrl,
277        features: ReceivePackFeatures,
278        advertisements: Vec<RefAdvertisement>,
279        pack_objects: Vec<ObjectId>,
280    },
281    Ssh(crate::ssh::SshPushPlan),
282    Git(crate::git::GitPushPlan),
283    Local {
284        remote_git_dir: PathBuf,
285        remote_common_git_dir: PathBuf,
286        remote_refs: Vec<RefAdvertisement>,
287        command_forces: Vec<(ReceivePackCommand, bool)>,
288        pack_objects: Vec<ObjectId>,
289    },
290}
291
292/// Push `refspecs` to a resolved `destination` from the repository at `git_dir`.
293///
294/// Performs the work the CLI's `push_http_repository`/`push_local_repository`
295/// did: advertises the remote's refs, plans the receive-pack commands for
296/// `refspecs`, rejects non-fast-forward branch updates (unless forced), builds
297/// the pack of objects the remote lacks, sends the receive-pack request, parses
298/// and validates the report-status, and returns the executed commands. `remote`
299/// is the remote/argument the caller resolved `destination` from (used only for
300/// error messages here).
301///
302/// Returns the structured [`PushOutcome`]; never prints or returns
303/// `GitError::Exit`. A still-`None` report in the outcome means the remote did
304/// not advertise `report-status`. Set-upstream config and the "To <remote>"
305/// summary are the caller's job, driven from [`PushOutcome::commands`].
306pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
307    let plan = plan_push(request, &mut services)?;
308    execute_push_plan(request, &mut services, plan)
309}
310
311/// Push a caller-authored exact plan, preserving its old/new/delete command ids.
312pub fn push_actions(
313    request: PushActionRequest<'_>,
314    mut services: PushServices<'_>,
315) -> Result<PushOutcome> {
316    let plan = plan_push_actions(request, &mut services)?;
317    execute_push_action_plan(request, &mut services, plan)
318}
319
320/// Negotiate with the remote and compute the receive-pack command list without
321/// sending a pack or applying a ref update.
322pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
323    // `config` and `progress` are part of the seam (mirroring `fetch`) but the
324    // current push flow drives credentials from the caller-built provider and
325    // returns its summary in `PushOutcome` rather than streaming progress, so
326    // neither is consumed yet. Kept named for the public API and future use.
327    let _ = request.config;
328    let _ = &mut services.progress;
329    match request.destination {
330        #[cfg(feature = "http")]
331        PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
332            git_dir: request.git_dir,
333            common_git_dir: request.common_git_dir,
334            format: request.format,
335            remote_url,
336            refspecs: request.refspecs,
337            options: request.options,
338            credentials: services.credentials,
339        }),
340        #[cfg(not(feature = "http"))]
341        PushDestination::Http(_) => Err(GitError::Unsupported(
342            "HTTP transport is not enabled in this build".into(),
343        )),
344        PushDestination::Ssh(remote_url) => {
345            let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
346                git_dir: request.git_dir,
347                common_git_dir: request.common_git_dir,
348                format: request.format,
349                remote: remote_url,
350                refspecs: request.refspecs,
351                force: request.options.force,
352            })?;
353            let commands = plan.commands.clone();
354            let execution = if commands.is_empty() {
355                PushExecution::Noop
356            } else {
357                PushExecution::Ssh(plan)
358            };
359            Ok(PushPlan {
360                commands,
361                execution,
362            })
363        }
364        PushDestination::Git(remote_url) => {
365            let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
366                git_dir: request.git_dir,
367                common_git_dir: request.common_git_dir,
368                format: request.format,
369                remote: remote_url,
370                refspecs: request.refspecs,
371                force: request.options.force,
372            })?;
373            let commands = plan.commands.clone();
374            let execution = if commands.is_empty() {
375                PushExecution::Noop
376            } else {
377                PushExecution::Git(plan)
378            };
379            Ok(PushPlan {
380                commands,
381                execution,
382            })
383        }
384        PushDestination::Local {
385            git_dir: remote_git_dir,
386            common_git_dir: remote_common_git_dir,
387        } => plan_push_local(PushLocalRequest {
388            git_dir: request.git_dir,
389            common_git_dir: request.common_git_dir,
390            format: request.format,
391            remote: request.remote,
392            remote_git_dir,
393            remote_common_git_dir,
394            refspecs: request.refspecs,
395            options: request.options,
396        }),
397    }
398}
399
400/// Negotiate with the remote and bind a caller-authored exact push plan to a
401/// transport execution token.
402pub fn plan_push_actions(
403    request: PushActionRequest<'_>,
404    services: &mut PushServices<'_>,
405) -> Result<PushPlan> {
406    let _ = request.config;
407    let _ = &mut services.progress;
408    let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
409    let command_forces = commands
410        .iter()
411        .cloned()
412        .zip(request.plan.commands.iter())
413        .map(|(command, planned)| (command, request.plan.options.force || planned.force))
414        .collect::<Vec<_>>();
415    match request.destination {
416        #[cfg(feature = "http")]
417        PushDestination::Http(remote_url) => {
418            let client = crate::http::new_http_client();
419            let discovered = crate::http::http_service_advertisements(
420                &client,
421                remote_url,
422                request.format,
423                GitService::ReceivePack,
424                services.credentials,
425            )?;
426            let advertisement_set = discovered.set;
427            let features = advertised_receive_pack_features(&advertisement_set.refs)?;
428            verify_remote_object_format(&features, request.format)?;
429            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
430            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
431            let execution = if commands.is_empty() {
432                PushExecution::Noop
433            } else {
434                PushExecution::Http {
435                    remote_url: remote_url.clone(),
436                    features,
437                    advertisements: advertisement_set.refs,
438                    pack_objects: request.plan.pack_objects.clone(),
439                }
440            };
441            Ok(PushPlan {
442                commands,
443                execution,
444            })
445        }
446        #[cfg(not(feature = "http"))]
447        PushDestination::Http(_) => Err(GitError::Unsupported(
448            "HTTP transport is not enabled in this build".into(),
449        )),
450        PushDestination::Ssh(remote_url) => {
451            let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
452                common_git_dir: request.common_git_dir,
453                format: request.format,
454                remote: remote_url,
455                command_forces: command_forces.clone(),
456                pack_objects: request.plan.pack_objects.clone(),
457            })?;
458            let commands = plan.commands.clone();
459            let execution = if commands.is_empty() {
460                PushExecution::Noop
461            } else {
462                PushExecution::Ssh(plan)
463            };
464            Ok(PushPlan {
465                commands,
466                execution,
467            })
468        }
469        PushDestination::Git(remote_url) => {
470            let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
471                common_git_dir: request.common_git_dir,
472                format: request.format,
473                remote: remote_url,
474                command_forces: command_forces.clone(),
475                pack_objects: request.plan.pack_objects.clone(),
476            })?;
477            let commands = plan.commands.clone();
478            let execution = if commands.is_empty() {
479                PushExecution::Noop
480            } else {
481                PushExecution::Git(plan)
482            };
483            Ok(PushPlan {
484                commands,
485                execution,
486            })
487        }
488        PushDestination::Local {
489            git_dir: remote_git_dir,
490            common_git_dir: remote_common_git_dir,
491        } => {
492            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
493            if remote_format != request.format {
494                return Err(GitError::InvalidObjectId(format!(
495                    "remote repository uses {}, local repository uses {}",
496                    remote_format.name(),
497                    request.format.name()
498                )));
499            }
500            let remote_refs =
501                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
502            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
503            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
504            let execution = if commands.is_empty() {
505                PushExecution::Noop
506            } else {
507                PushExecution::Local {
508                    remote_git_dir: remote_git_dir.to_path_buf(),
509                    remote_common_git_dir: remote_common_git_dir.to_path_buf(),
510                    remote_refs,
511                    command_forces,
512                    pack_objects: request.plan.pack_objects.clone(),
513                }
514            };
515            Ok(PushPlan {
516                commands,
517                execution,
518            })
519        }
520    }
521}
522
523/// Execute a previously planned push.
524pub fn execute_push_plan(
525    request: PushRequest<'_>,
526    services: &mut PushServices<'_>,
527    plan: PushPlan,
528) -> Result<PushOutcome> {
529    let _ = (request.config, request.remote);
530    let _ = &mut services.progress;
531    if plan.commands.is_empty() {
532        return Ok(PushOutcome::default());
533    }
534    match plan.execution {
535        PushExecution::Noop => Ok(PushOutcome::default()),
536        #[cfg(feature = "http")]
537        PushExecution::Http {
538            remote_url,
539            features,
540            advertisements,
541            pack_objects,
542        } => execute_push_http(
543            request,
544            services.credentials,
545            plan.commands,
546            remote_url,
547            features,
548            advertisements,
549            pack_objects,
550        ),
551        PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
552        PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
553        PushExecution::Local {
554            remote_git_dir,
555            remote_common_git_dir,
556            remote_refs,
557            command_forces,
558            pack_objects,
559        } => execute_push_local(
560            request,
561            plan.commands,
562            remote_git_dir,
563            remote_common_git_dir,
564            remote_refs,
565            command_forces,
566            pack_objects,
567        ),
568    }
569}
570
571/// Execute a previously negotiated exact push plan.
572pub fn execute_push_action_plan(
573    request: PushActionRequest<'_>,
574    services: &mut PushServices<'_>,
575    plan: PushPlan,
576) -> Result<PushOutcome> {
577    let refspecs: &[String] = &[];
578    execute_push_plan(
579        PushRequest {
580            git_dir: request.git_dir,
581            common_git_dir: request.common_git_dir,
582            format: request.format,
583            config: request.config,
584            remote: request.remote,
585            destination: request.destination,
586            refspecs,
587            options: &request.plan.options,
588        },
589        services,
590        plan,
591    )
592}
593
594/// Push to a smart-HTTP(S) remote: advertise via receive-pack info/refs, plan,
595/// build the pack, POST the receive-pack RPC, and validate the report-status.
596#[cfg(feature = "http")]
597struct PushHttpRequest<'a> {
598    git_dir: &'a Path,
599    common_git_dir: &'a Path,
600    format: ObjectFormat,
601    remote_url: &'a RemoteUrl,
602    refspecs: &'a [String],
603    options: &'a PushOptions,
604    credentials: &'a mut dyn CredentialProvider,
605}
606
607#[cfg(feature = "http")]
608fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
609    let PushHttpRequest {
610        git_dir,
611        common_git_dir,
612        format,
613        remote_url,
614        refspecs,
615        options,
616        credentials,
617    } = request;
618    let client = crate::http::new_http_client();
619    let discovered = crate::http::http_service_advertisements(
620        &client,
621        remote_url,
622        format,
623        GitService::ReceivePack,
624        credentials,
625    )?;
626    let advertisement_set = discovered.set;
627    let features = advertised_receive_pack_features(&advertisement_set.refs)?;
628    verify_remote_object_format(&features, format)?;
629
630    let local_store = FileRefStore::new(git_dir, format);
631    let mut local_refs = local_push_source_refs(&local_store, format)?;
632    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
633    let command_forces = plan_push_command_forces(
634        format,
635        &local_refs,
636        &advertisement_set.refs,
637        refspecs,
638        options.force,
639    )?;
640    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
641    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
642    let commands = commands_from_forces(&command_forces);
643    let execution = if commands.is_empty() {
644        PushExecution::Noop
645    } else {
646        PushExecution::Http {
647            remote_url: remote_url.clone(),
648            features,
649            advertisements: advertisement_set.refs,
650            pack_objects: Vec::new(),
651        }
652    };
653    Ok(PushPlan {
654        commands,
655        execution,
656    })
657}
658
659#[cfg(feature = "http")]
660fn execute_push_http(
661    request: PushRequest<'_>,
662    credentials: &mut dyn CredentialProvider,
663    commands: Vec<ReceivePackCommand>,
664    remote_url: RemoteUrl,
665    features: ReceivePackFeatures,
666    advertisements: Vec<RefAdvertisement>,
667    pack_objects: Vec<ObjectId>,
668) -> Result<PushOutcome> {
669    let client = crate::http::new_http_client();
670    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
671    let body = build_receive_pack_body(&PushPackRequest {
672        local_db: &local_db,
673        format: request.format,
674        commands: &commands,
675        pack_objects: &pack_objects,
676        remote_advertisements: &advertisements,
677        features: &features,
678        options: receive_pack_push_options(&features, request.format, request.options.quiet),
679        thin: false,
680    })?;
681    let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
682    let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
683    let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
684        client.post(
685            &url,
686            &content_type,
687            &crate::http::http_authorization_headers(auth),
688            &body,
689        )
690    })?;
691    crate::http::http_check_status(&response, &url)?;
692    crate::http::http_validate_content_type(
693        &response,
694        &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
695    )?;
696
697    let report = if features.report_status {
698        let report = read_receive_pack_report_status(&mut response.body)?;
699        validate_receive_pack_report(&report)?;
700        Some(report)
701    } else {
702        let mut sink = Vec::new();
703        response.body.read_to_end(&mut sink)?;
704        None
705    };
706    Ok(PushOutcome { commands, report })
707}
708
709/// Push to a local repository served in-process: advertise from the remote
710/// `git_dir`, plan, build the pack against the remote's reachable objects, and
711/// apply the receive-pack request directly.
712struct PushLocalRequest<'a> {
713    git_dir: &'a Path,
714    common_git_dir: &'a Path,
715    format: ObjectFormat,
716    remote: &'a str,
717    remote_git_dir: &'a Path,
718    remote_common_git_dir: &'a Path,
719    refspecs: &'a [String],
720    options: &'a PushOptions,
721}
722
723fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
724    let PushLocalRequest {
725        git_dir,
726        common_git_dir,
727        format,
728        remote,
729        remote_git_dir,
730        remote_common_git_dir,
731        refspecs,
732        options,
733    } = request;
734    let _ = remote;
735    let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
736    if remote_format != format {
737        return Err(GitError::InvalidObjectId(format!(
738            "remote repository uses {}, local repository uses {}",
739            remote_format.name(),
740            format.name()
741        )));
742    }
743
744    let local_store = FileRefStore::new(git_dir, format);
745    let mut local_refs = local_push_source_refs(&local_store, format)?;
746    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
747    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
748    let command_forces =
749        plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
750    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
751    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
752    let commands = commands_from_forces(&command_forces);
753    let execution = if commands.is_empty() {
754        PushExecution::Noop
755    } else {
756        PushExecution::Local {
757            remote_git_dir: remote_git_dir.to_path_buf(),
758            remote_common_git_dir: remote_common_git_dir.to_path_buf(),
759            remote_refs,
760            command_forces,
761            pack_objects: Vec::new(),
762        }
763    };
764    Ok(PushPlan {
765        commands,
766        execution,
767    })
768}
769
770fn execute_push_local(
771    request: PushRequest<'_>,
772    commands: Vec<ReceivePackCommand>,
773    remote_git_dir: PathBuf,
774    remote_common_git_dir: PathBuf,
775    remote_refs: Vec<RefAdvertisement>,
776    _command_forces: Vec<(ReceivePackCommand, bool)>,
777    pack_objects: Vec<ObjectId>,
778) -> Result<PushOutcome> {
779    let remote_excluded_tips = remote_refs
780        .iter()
781        .map(|reference| reference.oid)
782        .collect::<Vec<_>>();
783    let starts = push_pack_roots(&commands, &pack_objects);
784    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
785    let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
786    let remote_excluded =
787        collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
788    let packfile = if starts.is_empty() {
789        Vec::new()
790    } else {
791        b"PACK".to_vec()
792    };
793    let receive_request = ReceivePackPushRequest {
794        commands: ReceivePackRequest {
795            shallow: Vec::new(),
796            commands: commands.clone(),
797            capabilities: Vec::new(),
798        },
799        push_options: None,
800        packfile,
801    };
802    let report = crate::local::receive_pack_reachable_pack_into_local_repository(
803        &remote_git_dir,
804        request.format,
805        &receive_request,
806        &local_db,
807        starts,
808        remote_excluded,
809    )?;
810    validate_receive_pack_report(&report)?;
811    Ok(PushOutcome {
812        commands,
813        report: Some(report),
814    })
815}
816
817/// Parse the receive-pack features from the leading ref advertisement (the empty
818/// default when the remote advertised no refs).
819#[cfg(feature = "http")]
820fn advertised_receive_pack_features(
821    advertisements: &[RefAdvertisement],
822) -> Result<ReceivePackFeatures> {
823    advertisements
824        .first()
825        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
826        .transpose()
827        .map(Option::unwrap_or_default)
828}
829
830/// Reject a push whose object format disagrees with the remote's advertised
831/// `object-format`, and require the advertisement for any non-SHA-1 push.
832#[cfg(feature = "http")]
833fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
834    if let Some(remote_format) = features.object_format {
835        if remote_format != format {
836            return Err(GitError::InvalidObjectId(format!(
837                "remote repository uses {}, local repository uses {}",
838                remote_format.name(),
839                format.name()
840            )));
841        }
842    } else if format != ObjectFormat::Sha1 {
843        return Err(GitError::InvalidObjectId(format!(
844            "remote repository did not advertise object-format for {} push",
845            format.name()
846        )));
847    }
848    Ok(())
849}
850
851/// The receive-pack push-request options for the negotiated `features`, matching
852/// git: report-status when advertised, ofs-delta when advertised, `quiet` only
853/// when both requested and advertised, and the advertised object-format only when
854/// the local repository's `format` is not SHA-1.
855#[cfg(feature = "http")]
856fn receive_pack_push_options(
857    features: &ReceivePackFeatures,
858    format: ObjectFormat,
859    quiet: bool,
860) -> ReceivePackPushRequestOptions {
861    ReceivePackPushRequestOptions {
862        report_status: features.report_status,
863        ofs_delta: features.ofs_delta,
864        quiet: quiet && features.quiet,
865        object_format: features
866            .object_format
867            .filter(|_| format != ObjectFormat::Sha1),
868        ..ReceivePackPushRequestOptions::default()
869    }
870}
871
872/// Plan the receive-pack commands for `refspecs`, pairing each with whether it is
873/// forced (the global `force` flag or the refspec's own `+`). Each refspec is
874/// normalized then planned independently so per-refspec force is preserved,
875/// matching the CLI.
876fn plan_push_command_forces(
877    format: ObjectFormat,
878    local_refs: &[PushSourceRef],
879    remote_refs: &[RefAdvertisement],
880    refspecs: &[String],
881    force: bool,
882) -> Result<Vec<(ReceivePackCommand, bool)>> {
883    let parsed_refspecs = refspecs
884        .iter()
885        .map(|refspec| parse_refspec(&normalize_push_refspec_for_sources(refspec, local_refs)))
886        .collect::<Result<Vec<_>>>()?;
887    let mut command_forces = Vec::new();
888    for refspec in &parsed_refspecs {
889        for command in plan_push_commands(
890            format,
891            local_refs,
892            remote_refs,
893            std::slice::from_ref(refspec),
894        )? {
895            command_forces.push((command, force || refspec.force));
896        }
897    }
898    Ok(command_forces)
899}
900
901fn add_revision_push_sources(
902    git_dir: &Path,
903    format: ObjectFormat,
904    refspecs: &[String],
905    local_refs: &mut Vec<PushSourceRef>,
906) {
907    for refspec in refspecs {
908        let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
909        let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
910        if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
911            continue;
912        }
913        if local_refs.iter().any(|reference| {
914            reference.name == src
915                || reference.name == format!("refs/heads/{src}")
916                || reference.name == format!("refs/tags/{src}")
917        }) {
918            continue;
919        }
920        if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
921            && !local_refs.iter().any(|reference| reference.name == src)
922        {
923            local_refs.push(PushSourceRef {
924                name: src.to_string(),
925                oid,
926            });
927        }
928    }
929}
930
931fn normalize_push_refspec_for_sources(refspec: &str, local_refs: &[PushSourceRef]) -> String {
932    let (force, refspec) = refspec
933        .strip_prefix('+')
934        .map_or((false, refspec), |refspec| (true, refspec));
935    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
936        let (src, src_kind) = normalize_push_source_refname(src, local_refs);
937        let dst = normalize_push_destination_refname(dst, src_kind);
938        format!("{src}:{dst}")
939    } else {
940        let (name, _) = normalize_push_source_refname(refspec, local_refs);
941        format!("{name}:{name}")
942    };
943    if force {
944        format!("+{normalized}")
945    } else {
946        normalized
947    }
948}
949
950#[derive(Clone, Copy)]
951enum PushSourceKind {
952    Branch,
953    Tag,
954    Other,
955}
956
957fn normalize_push_source_refname(
958    name: &str,
959    local_refs: &[PushSourceRef],
960) -> (String, PushSourceKind) {
961    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
962        return (name.to_string(), PushSourceKind::Other);
963    }
964    let branch = format!("refs/heads/{name}");
965    let tag = format!("refs/tags/{name}");
966    let has_branch = local_refs.iter().any(|reference| reference.name == branch);
967    let has_tag = local_refs.iter().any(|reference| reference.name == tag);
968    if has_tag && !has_branch {
969        (tag, PushSourceKind::Tag)
970    } else if has_branch {
971        (branch, PushSourceKind::Branch)
972    } else if local_refs.iter().any(|reference| reference.name == name) {
973        (name.to_string(), PushSourceKind::Other)
974    } else {
975        (branch, PushSourceKind::Branch)
976    }
977}
978
979fn normalize_push_destination_refname(name: &str, src_kind: PushSourceKind) -> String {
980    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
981        return name.to_string();
982    }
983    match src_kind {
984        PushSourceKind::Tag => format!("refs/tags/{name}"),
985        PushSourceKind::Branch | PushSourceKind::Other => format!("refs/heads/{name}"),
986    }
987}
988
989/// The planned commands, dropping the per-command force flags.
990fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
991    command_forces
992        .iter()
993        .map(|(command, _)| command.clone())
994        .collect()
995}
996
997fn receive_pack_commands_from_action_plan(
998    format: ObjectFormat,
999    plan: &PushActionPlan,
1000) -> Result<Vec<ReceivePackCommand>> {
1001    let zero = ObjectId::null(format);
1002    for oid in &plan.pack_objects {
1003        if oid.format() != format {
1004            return Err(GitError::InvalidObjectId(format!(
1005                "push pack object {oid} has {} object id for {} repository",
1006                oid.format().name(),
1007                format.name()
1008            )));
1009        }
1010    }
1011    plan.commands
1012        .iter()
1013        .map(|command| {
1014            let old_id = command.expected_old.unwrap_or(zero);
1015            let new_id = command.src.unwrap_or(zero);
1016            if old_id.format() != format {
1017                return Err(GitError::InvalidObjectId(format!(
1018                    "push command {} expected old has {} object id for {} repository",
1019                    command.dst,
1020                    old_id.format().name(),
1021                    format.name()
1022                )));
1023            }
1024            if new_id.format() != format {
1025                return Err(GitError::InvalidObjectId(format!(
1026                    "push command {} new id has {} object id for {} repository",
1027                    command.dst,
1028                    new_id.format().name(),
1029                    format.name()
1030                )));
1031            }
1032            Ok(ReceivePackCommand {
1033                old_id,
1034                new_id,
1035                name: command.dst.clone(),
1036            })
1037        })
1038        .collect()
1039}
1040
1041/// Validate a receive-pack report-status, surfacing a failed unpack or any
1042/// rejected ref as an error (matching git's exit-failure message form).
1043pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1044    if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1045        return Err(GitError::Command(format!(
1046            "failed to push some refs: unpack failed: {message}"
1047        )));
1048    }
1049    for status in &report.commands {
1050        if let ReceivePackCommandStatus::Ng { name, message } = status {
1051            return Err(GitError::Command(format!(
1052                "failed to push {name}: {message}"
1053            )));
1054        }
1055    }
1056    Ok(())
1057}
1058
1059/// The push-source refs a local repository can match refspecs against: every ref
1060/// resolved to its object id, plus the short `refs/heads/`*and `refs/tags/`*
1061/// aliases, plus `HEAD`. Errors if any ref's object id does not match `format`.
1062pub fn local_push_source_refs(
1063    store: &FileRefStore,
1064    format: ObjectFormat,
1065) -> Result<Vec<PushSourceRef>> {
1066    let mut refs = Vec::new();
1067    for reference in store.list_refs()? {
1068        let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1069            continue;
1070        };
1071        if oid.format() != format {
1072            return Err(GitError::InvalidObjectId(format!(
1073                "local ref {} has {} object id for {} repository",
1074                reference.name,
1075                oid.format().name(),
1076                format.name()
1077            )));
1078        }
1079        refs.push(PushSourceRef {
1080            name: reference.name.clone(),
1081            oid,
1082        });
1083        if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1084            refs.push(PushSourceRef {
1085                name: short.to_string(),
1086                oid,
1087            });
1088        }
1089        if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1090            refs.push(PushSourceRef {
1091                name: short.to_string(),
1092                oid,
1093            });
1094        }
1095    }
1096    if let Some(target) = store.read_ref("HEAD")? {
1097        let head = Ref {
1098            name: "HEAD".to_string(),
1099            target,
1100        };
1101        if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1102            && oid.format() == format
1103        {
1104            refs.push(PushSourceRef {
1105                name: "HEAD".to_string(),
1106                oid,
1107            });
1108        }
1109    }
1110    Ok(refs)
1111}
1112
1113/// Normalize a push refspec, expanding short names to `refs/heads/<name>` on both
1114/// sides and supplying the source as the destination when none is given, while
1115/// preserving a leading `+` force marker.
1116pub fn normalize_push_refspec(refspec: &str) -> String {
1117    let (force, refspec) = refspec
1118        .strip_prefix('+')
1119        .map_or((false, refspec), |refspec| (true, refspec));
1120    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1121        let src = normalize_push_refname(src);
1122        let dst = normalize_push_refname(dst);
1123        format!("{src}:{dst}")
1124    } else {
1125        let name = normalize_push_refname(refspec);
1126        format!("{name}:{name}")
1127    };
1128    if force {
1129        format!("+{normalized}")
1130    } else {
1131        normalized
1132    }
1133}
1134
1135/// Expand a short push ref name to `refs/heads/<name>`, leaving empty names,
1136/// `HEAD`, and already-qualified `refs/`* names untouched.
1137pub fn normalize_push_refname(name: &str) -> String {
1138    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1139        name.to_string()
1140    } else {
1141        format!("refs/heads/{name}")
1142    }
1143}
1144
1145/// Reject any non-forced branch update whose old tip is not an ancestor of the
1146/// new tip (a non-fast-forward). Forced updates, non-branch refs, and
1147/// creations/deletions are skipped.
1148pub fn reject_non_fast_forward_pushes(
1149    local_db: &FileObjectDatabase,
1150    format: ObjectFormat,
1151    command_forces: &[(ReceivePackCommand, bool)],
1152) -> Result<()> {
1153    for (command, force) in command_forces {
1154        if *force
1155            || !command.name.starts_with("refs/heads/")
1156            || command.old_id.is_null()
1157            || command.new_id.is_null()
1158        {
1159            continue;
1160        }
1161        let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1162        if !ancestors.contains_key(&command.old_id) {
1163            let short = command.name.trim_start_matches("refs/heads/");
1164            return Err(GitError::Command(format!(
1165                "failed to push some refs: non-fast-forward update to {short}"
1166            )));
1167        }
1168    }
1169    Ok(())
1170}
1171
1172/// The depth of every commit reachable from `start` (a breadth-first ancestry
1173/// walk). Used to test fast-forwardness: `start`'s ancestors include `start`
1174/// itself at depth zero. Errors if a reachable object is not a commit.
1175fn ancestor_depths(
1176    db: &FileObjectDatabase,
1177    format: ObjectFormat,
1178    start: &ObjectId,
1179) -> Result<HashMap<ObjectId, usize>> {
1180    let mut depths = HashMap::new();
1181    let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1182    while let Some((oid, depth)) = pending.pop_front() {
1183        if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1184            continue;
1185        }
1186        depths.insert(oid, depth);
1187        let object = db.read_object(&oid)?;
1188        if object.object_type != ObjectType::Commit {
1189            return Err(GitError::InvalidObject(format!(
1190                "expected commit {oid}, found {}",
1191                object.object_type.as_str()
1192            )));
1193        }
1194        let commit = Commit::parse_ref(format, &object.body)?;
1195        for parent in commit.parents {
1196            pending.push_back((parent, depth + 1));
1197        }
1198    }
1199    Ok(depths)
1200}
1201
1202/// Resolve a (possibly symbolic) ref target to its object id, following up to
1203/// five levels of symbolic indirection, returning the first symbolic name seen.
1204fn resolve_for_each_ref_target(
1205    store: &FileRefStore,
1206    reference: &Ref,
1207) -> Result<Option<(ObjectId, Option<String>)>> {
1208    let mut target = reference.target.clone();
1209    let mut symref = None;
1210    for _ in 0..5 {
1211        match target {
1212            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1213            RefTarget::Symbolic(name) => {
1214                symref.get_or_insert_with(|| name.clone());
1215                let Some(next) = store.read_ref(&name)? else {
1216                    return Ok(None);
1217                };
1218                target = next;
1219            }
1220        }
1221    }
1222    Ok(None)
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228    use std::fs;
1229    use std::sync::atomic::{AtomicU64, Ordering};
1230
1231    use sley_formats::RepositoryLayout;
1232    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1233    use sley_odb::{FileObjectDatabase, ObjectWriter};
1234    use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1235    use sley_refs::{RefTarget, RefUpdate};
1236
1237    use crate::{NoCredentials, SilentProgress};
1238
1239    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1240
1241    fn temp_repo(name: &str) -> PathBuf {
1242        let dir = std::env::temp_dir().join(format!(
1243            "sley-remote-push-{name}-{}-{}",
1244            std::process::id(),
1245            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1246        ));
1247        let _ = fs::remove_dir_all(&dir);
1248        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1249            .expect("test repository should initialize");
1250        dir.join(".git")
1251    }
1252
1253    fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1254        let format = ObjectFormat::Sha1;
1255        let db = FileObjectDatabase::from_git_dir(git_dir, format);
1256        let tree = db
1257            .write_object(EncodedObject::new(
1258                ObjectType::Tree,
1259                Tree { entries: vec![] }.write(),
1260            ))
1261            .expect("tree should write");
1262        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1263        db.write_object(EncodedObject::new(
1264            ObjectType::Commit,
1265            Commit {
1266                tree,
1267                parents,
1268                author: identity.clone(),
1269                committer: identity,
1270                encoding: None,
1271                message: format!("{message}\n").into_bytes(),
1272            }
1273            .write(),
1274        ))
1275        .expect("commit should write")
1276    }
1277
1278    fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1279        let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1280        let mut tx = store.transaction();
1281        tx.update(RefUpdate {
1282            name: name.to_string(),
1283            expected: None,
1284            new: target,
1285            reflog: None,
1286        });
1287        tx.commit().expect("ref should update");
1288    }
1289
1290    fn default_options() -> PushOptions {
1291        PushOptions {
1292            quiet: true,
1293            force: false,
1294        }
1295    }
1296
1297    #[test]
1298    fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1299        let repo = temp_repo("action-plan-infer-roots");
1300        let first = write_commit(&repo, Vec::new(), "first");
1301        let second = write_commit(&repo, vec![first], "second");
1302
1303        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1304            vec![
1305                PushCommand {
1306                    src: Some(first),
1307                    dst: "refs/heads/main".into(),
1308                    expected_old: None,
1309                    force: false,
1310                },
1311                PushCommand {
1312                    src: Some(second),
1313                    dst: "refs/heads/topic".into(),
1314                    expected_old: Some(first),
1315                    force: true,
1316                },
1317            ],
1318            default_options(),
1319        );
1320
1321        assert_eq!(plan.pack_objects, vec![first, second]);
1322        assert!(!plan.commands[0].force);
1323        assert!(plan.commands[1].force);
1324    }
1325
1326    #[test]
1327    fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1328        let repo = temp_repo("action-plan-delete-roots");
1329        let old = write_commit(&repo, Vec::new(), "old");
1330        let new = write_commit(&repo, vec![old], "new");
1331
1332        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1333            vec![
1334                PushCommand {
1335                    src: None,
1336                    dst: "refs/heads/remove".into(),
1337                    expected_old: Some(old),
1338                    force: false,
1339                },
1340                PushCommand {
1341                    src: Some(new),
1342                    dst: "refs/heads/keep".into(),
1343                    expected_old: Some(old),
1344                    force: false,
1345                },
1346            ],
1347            default_options(),
1348        );
1349
1350        assert_eq!(plan.pack_objects, vec![new]);
1351    }
1352
1353    #[test]
1354    fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1355        let repo = temp_repo("action-plan-dedupe-roots");
1356        let first = write_commit(&repo, Vec::new(), "first");
1357        let second = write_commit(&repo, Vec::new(), "second");
1358
1359        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1360            vec![
1361                PushCommand {
1362                    src: Some(second),
1363                    dst: "refs/heads/second".into(),
1364                    expected_old: None,
1365                    force: false,
1366                },
1367                PushCommand {
1368                    src: Some(first),
1369                    dst: "refs/heads/first".into(),
1370                    expected_old: None,
1371                    force: false,
1372                },
1373                PushCommand {
1374                    src: Some(second),
1375                    dst: "refs/tags/second".into(),
1376                    expected_old: None,
1377                    force: false,
1378                },
1379                PushCommand {
1380                    src: Some(first),
1381                    dst: "refs/tags/first".into(),
1382                    expected_old: None,
1383                    force: false,
1384                },
1385            ],
1386            default_options(),
1387        );
1388
1389        assert_eq!(plan.pack_objects, vec![second, first]);
1390    }
1391
1392    fn push_local_actions(
1393        local: &Path,
1394        remote: &Path,
1395        plan: &PushActionPlan,
1396    ) -> Result<PushOutcome> {
1397        let destination = PushDestination::Local {
1398            git_dir: remote.to_path_buf(),
1399            common_git_dir: remote.to_path_buf(),
1400        };
1401        let config = GitConfig::default();
1402        let mut credentials = NoCredentials;
1403        let mut progress = SilentProgress;
1404        push_actions(
1405            PushActionRequest {
1406                git_dir: local,
1407                common_git_dir: local,
1408                format: ObjectFormat::Sha1,
1409                config: &config,
1410                remote: "origin",
1411                destination: &destination,
1412                plan,
1413            },
1414            PushServices {
1415                credentials: &mut credentials,
1416                progress: &mut progress,
1417            },
1418        )
1419    }
1420
1421    #[test]
1422    fn local_push_returns_success_report_status_and_updates_ref() {
1423        let local = temp_repo("local-success");
1424        let remote = temp_repo("remote-success");
1425        let base = write_commit(&local, Vec::new(), "base");
1426        let tip = write_commit(&local, vec![base], "tip");
1427        set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1428        set_ref(
1429            &local,
1430            "HEAD",
1431            RefTarget::Symbolic("refs/heads/main".into()),
1432        );
1433        let destination = PushDestination::Local {
1434            git_dir: remote.clone(),
1435            common_git_dir: remote.clone(),
1436        };
1437        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1438        let options = default_options();
1439        let request = PushRequest {
1440            git_dir: &local,
1441            common_git_dir: &local,
1442            format: ObjectFormat::Sha1,
1443            config: &GitConfig::default(),
1444            remote: "origin",
1445            destination: &destination,
1446            refspecs: &refspecs,
1447            options: &options,
1448        };
1449        let mut credentials = NoCredentials;
1450        let mut progress = SilentProgress;
1451
1452        let outcome = push(
1453            request,
1454            PushServices {
1455                credentials: &mut credentials,
1456                progress: &mut progress,
1457            },
1458        )
1459        .expect("push should succeed");
1460
1461        assert_eq!(outcome.commands.len(), 1);
1462        let report = outcome.report.expect("local receive-pack reports status");
1463        assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1464        assert!(matches!(
1465            report.commands.as_slice(),
1466            [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1467        ));
1468        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1469        assert_eq!(
1470            remote_refs
1471                .read_ref("refs/heads/main")
1472                .expect("remote ref should read"),
1473            Some(RefTarget::Direct(tip))
1474        );
1475    }
1476
1477    #[test]
1478    fn local_push_actions_preserves_exact_old_new_update() {
1479        let local = temp_repo("actions-update-local");
1480        let remote = temp_repo("actions-update-remote");
1481        let base = write_commit(&local, Vec::new(), "base");
1482        let remote_base = write_commit(&remote, Vec::new(), "base");
1483        assert_eq!(remote_base, base);
1484        let tip = write_commit(&local, vec![base], "tip");
1485        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1486        let plan = PushActionPlan::from_actions(
1487            vec![PushAction::Update {
1488                dst: "refs/heads/main".into(),
1489                old: base,
1490                new: tip,
1491            }],
1492            default_options(),
1493        );
1494
1495        let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
1496
1497        assert_eq!(outcome.commands.len(), 1);
1498        assert_eq!(outcome.commands[0].old_id, base);
1499        assert_eq!(outcome.commands[0].new_id, tip);
1500        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1501        assert_eq!(
1502            remote_refs
1503                .read_ref("refs/heads/main")
1504                .expect("remote ref should read"),
1505            Some(RefTarget::Direct(tip))
1506        );
1507    }
1508
1509    #[test]
1510    fn local_push_actions_honors_per_command_force() {
1511        let local = temp_repo("actions-command-force-local");
1512        let remote = temp_repo("actions-command-force-remote");
1513        let base = write_commit(&local, Vec::new(), "base");
1514        let remote_base = write_commit(&remote, Vec::new(), "base");
1515        assert_eq!(remote_base, base);
1516        let unrelated = write_commit(&local, Vec::new(), "unrelated");
1517        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1518
1519        let unforced = PushActionPlan::from_commands(
1520            vec![PushCommand {
1521                src: Some(unrelated),
1522                dst: "refs/heads/main".into(),
1523                expected_old: Some(base),
1524                force: false,
1525            }],
1526            default_options(),
1527        );
1528        let err = push_local_actions(&local, &remote, &unforced)
1529            .expect_err("non-fast-forward should reject without command force");
1530        assert!(err.to_string().contains("non-fast-forward"));
1531
1532        let forced = PushActionPlan::from_commands(
1533            vec![PushCommand {
1534                src: Some(unrelated),
1535                dst: "refs/heads/main".into(),
1536                expected_old: Some(base),
1537                force: true,
1538            }],
1539            default_options(),
1540        );
1541        let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
1542
1543        assert_eq!(outcome.commands.len(), 1);
1544        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1545        assert_eq!(
1546            remote_refs
1547                .read_ref("refs/heads/main")
1548                .expect("remote ref should read"),
1549            Some(RefTarget::Direct(unrelated))
1550        );
1551    }
1552
1553    #[test]
1554    fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
1555        let local = temp_repo("actions-command-force-precise-local");
1556        let remote = temp_repo("actions-command-force-precise-remote");
1557        let base = write_commit(&local, Vec::new(), "base");
1558        let remote_base = write_commit(&remote, Vec::new(), "base");
1559        assert_eq!(remote_base, base);
1560        let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
1561        let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
1562        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1563        set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
1564        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1565            vec![
1566                PushCommand {
1567                    src: Some(forced_unrelated),
1568                    dst: "refs/heads/main".into(),
1569                    expected_old: Some(base),
1570                    force: true,
1571                },
1572                PushCommand {
1573                    src: Some(unforced_unrelated),
1574                    dst: "refs/heads/topic".into(),
1575                    expected_old: Some(base),
1576                    force: false,
1577                },
1578            ],
1579            default_options(),
1580        );
1581
1582        let err = push_local_actions(&local, &remote, &plan)
1583            .expect_err("only the forced command should bypass non-fast-forward validation");
1584
1585        assert!(err.to_string().contains("non-fast-forward update to topic"));
1586        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1587        assert_eq!(
1588            remote_refs
1589                .read_ref("refs/heads/main")
1590                .expect("remote ref should read"),
1591            Some(RefTarget::Direct(base))
1592        );
1593        assert_eq!(
1594            remote_refs
1595                .read_ref("refs/heads/topic")
1596                .expect("remote ref should read"),
1597            Some(RefTarget::Direct(base))
1598        );
1599    }
1600
1601    #[test]
1602    fn local_push_actions_stale_update_old_rejects_without_mutating() {
1603        let local = temp_repo("actions-stale-local");
1604        let remote = temp_repo("actions-stale-remote");
1605        let base = write_commit(&local, Vec::new(), "base");
1606        let remote_base = write_commit(&remote, Vec::new(), "base");
1607        assert_eq!(remote_base, base);
1608        let tip = write_commit(&local, vec![base], "tip");
1609        let concurrent = write_commit(&remote, vec![base], "concurrent");
1610        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1611        let plan = PushActionPlan::from_actions(
1612            vec![PushAction::Update {
1613                dst: "refs/heads/main".into(),
1614                old: base,
1615                new: tip,
1616            }],
1617            default_options(),
1618        );
1619
1620        let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
1621
1622        assert!(err.to_string().contains("expected ref refs/heads/main"));
1623        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1624        assert_eq!(
1625            remote_refs
1626                .read_ref("refs/heads/main")
1627                .expect("remote ref should read"),
1628            Some(RefTarget::Direct(concurrent))
1629        );
1630    }
1631
1632    #[test]
1633    fn local_push_actions_stale_delete_old_rejects_without_mutating() {
1634        let local = temp_repo("actions-delete-local");
1635        let remote = temp_repo("actions-delete-remote");
1636        let base = write_commit(&local, Vec::new(), "base");
1637        let remote_base = write_commit(&remote, Vec::new(), "base");
1638        assert_eq!(remote_base, base);
1639        let concurrent = write_commit(&remote, vec![base], "concurrent");
1640        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1641        let plan = PushActionPlan::from_actions(
1642            vec![PushAction::Delete {
1643                dst: "refs/heads/main".into(),
1644                old: Some(base),
1645            }],
1646            default_options(),
1647        );
1648
1649        let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
1650
1651        assert!(err.to_string().contains("expected ref refs/heads/main"));
1652        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1653        assert_eq!(
1654            remote_refs
1655                .read_ref("refs/heads/main")
1656                .expect("remote ref should read"),
1657            Some(RefTarget::Direct(concurrent))
1658        );
1659    }
1660
1661    #[test]
1662    fn local_push_actions_create_rejects_existing_ref() {
1663        let local = temp_repo("actions-create-local");
1664        let remote = temp_repo("actions-create-remote");
1665        let base = write_commit(&local, Vec::new(), "base");
1666        let remote_base = write_commit(&remote, Vec::new(), "base");
1667        assert_eq!(remote_base, base);
1668        let tip = write_commit(&local, vec![base], "tip");
1669        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1670        let plan = PushActionPlan::from_actions(
1671            vec![PushAction::Create {
1672                dst: "refs/heads/main".into(),
1673                new: tip,
1674            }],
1675            default_options(),
1676        );
1677
1678        let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
1679
1680        assert!(
1681            err.to_string()
1682                .contains("expected ref refs/heads/main to not already exist")
1683        );
1684        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1685        assert_eq!(
1686            remote_refs
1687                .read_ref("refs/heads/main")
1688                .expect("remote ref should read"),
1689            Some(RefTarget::Direct(base))
1690        );
1691    }
1692
1693    #[test]
1694    fn report_status_rejection_is_an_error() {
1695        let report = ReceivePackReportStatus {
1696            unpack: ReceivePackUnpackStatus::Ok,
1697            commands: vec![ReceivePackCommandStatus::Ng {
1698                name: "refs/heads/main".into(),
1699                message: "hook declined".into(),
1700            }],
1701        };
1702
1703        let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
1704
1705        assert!(err.to_string().contains("hook declined"));
1706    }
1707
1708    #[test]
1709    fn failed_local_push_does_not_partially_mutate_remote_ref() {
1710        let local = temp_repo("local-rejected");
1711        let remote = temp_repo("remote-rejected");
1712        let base = write_commit(&local, Vec::new(), "base");
1713        let planned = write_commit(&local, vec![base], "planned");
1714        let concurrent = write_commit(&local, vec![base], "concurrent");
1715        set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
1716        set_ref(
1717            &local,
1718            "HEAD",
1719            RefTarget::Symbolic("refs/heads/main".into()),
1720        );
1721        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1722        let destination = PushDestination::Local {
1723            git_dir: remote.clone(),
1724            common_git_dir: remote.clone(),
1725        };
1726        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1727        let options = default_options();
1728        let request = PushRequest {
1729            git_dir: &local,
1730            common_git_dir: &local,
1731            format: ObjectFormat::Sha1,
1732            config: &GitConfig::default(),
1733            remote: "origin",
1734            destination: &destination,
1735            refspecs: &refspecs,
1736            options: &options,
1737        };
1738        let mut credentials = NoCredentials;
1739        let mut progress = SilentProgress;
1740        let mut services = PushServices {
1741            credentials: &mut credentials,
1742            progress: &mut progress,
1743        };
1744        let plan = plan_push(request, &mut services).expect("push should plan");
1745
1746        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1747        let _err = execute_push_plan(request, &mut services, plan)
1748            .expect_err("stale old id should reject the ref update");
1749
1750        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1751        assert_eq!(
1752            remote_refs
1753                .read_ref("refs/heads/main")
1754                .expect("remote ref should read"),
1755            Some(RefTarget::Direct(concurrent))
1756        );
1757    }
1758}