Skip to main content

grit_lib/remote/
push_plan.rs

1//! Resolve remotes and refspecs for [`Remote::push`](crate::remote::Remote::push).
2//!
3//! [`resolve_push`] turns a [`PushRequest`] (CLI flags and positional refspecs)
4//! into a [`ResolvedPush`] of concrete [`PushRefSpec`] entries, matching Git's
5//! push refspec and remote-selection rules.
6//!
7//! A typical push classifies its positionals with [`resolve_push_invocation`], loads the remote's
8//! advertisement with [`load_push_plan_context`], plans with [`resolve_push`], and sends the
9//! result through the [`Remote`] returned by [`open_push_destination`].
10
11use std::collections::HashSet;
12use std::path::Path;
13
14use thiserror::Error;
15
16use crate::branch_tracking::{apply_push_set_upstream, remote_tracking_ref_for_remote_ref};
17use crate::config::{parse_git_config_maybe_bool, ConfigSet};
18use crate::merge_base::is_ancestor;
19use crate::objects::{ObjectId, ObjectKind};
20use crate::push_report::PushRefResult;
21use crate::refs;
22use crate::refspec::{parse_push_refspec, RefspecItem};
23use crate::remote::{HttpClientFactory, Remote, RemoteError, RemoteRef};
24use crate::repo::Repository;
25use crate::repo_handles::has_remote_section;
26use crate::rev_parse::{resolve_push_full_ref_for_branch, resolve_revision_for_verify};
27use crate::state::{resolve_head, HeadState};
28use crate::transfer::{resolve_push_delete_ref, PushRefSpec};
29
30pub use crate::remote::DEFAULT_REMOTE;
31
32/// How non-fast-forward updates are allowed during a push.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub enum ForceMode {
35    /// Reject non-fast-forward updates (default).
36    #[default]
37    None,
38    /// Unconditional force (`--force`).
39    Force,
40    /// Guarded force (`--force-with-lease`).
41    Lease(LeaseExpectation),
42}
43
44/// What a force-with-lease push compares the remote ref against.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum LeaseExpectation {
47    /// Use the local remote-tracking ref mapped from the push destination.
48    Tracking,
49    /// Use an explicit object id (`--force-with-lease=<rev>`); only valid for a single ref.
50    Value(ObjectId),
51}
52
53/// What the caller wants to push (remote override, refspecs, tags, deletions).
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct PushRequest {
56    /// Explicit remote name from `--remote` or the first positional argument.
57    pub remote: Option<String>,
58    /// Positional refspec strings (`src`, `src:dst`, `:dst`, `+src:dst`, …).
59    pub refspecs: Vec<String>,
60    /// Push all local tags (`--tags`).
61    pub tags: bool,
62    /// Remote refs to delete (`--delete`).
63    pub delete: Vec<String>,
64    /// Force or force-with-lease mode from CLI flags.
65    pub force: ForceMode,
66    /// Push annotated tags reachable from the commits being pushed (`--follow-tags`).
67    pub follow_tags: bool,
68    /// After a successful branch push, write `branch.<name>.remote` / `.merge` (`-u`).
69    pub set_upstream: bool,
70}
71
72/// A fully resolved push target.
73#[derive(Debug, Clone)]
74pub struct ResolvedPush {
75    /// Remote name to push to.
76    pub remote: String,
77    /// Concrete ref updates to send.
78    pub specs: Vec<PushRefSpec>,
79    /// Short branch label when the push maps to one local branch (for display).
80    pub branch_label: Option<String>,
81    /// High-level kind for JSON (`branch`, `tags`, `delete`, `refspecs`).
82    pub kind: PushPlanKind,
83}
84
85/// JSON / CLI classification of what was pushed.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum PushPlanKind {
88    /// Default push of the current branch (or a single mapped branch).
89    Branch,
90    /// Push all local tags (`--tags`).
91    Tags,
92    /// Delete one or more remote refs (`--delete`).
93    Delete,
94    /// Explicit refspecs from the command line or remote `push` config.
95    Refspecs,
96}
97
98/// Extra inputs needed to DWIM delete targets and short push destinations.
99#[derive(Debug, Clone, Default)]
100pub struct PushPlanContext {
101    /// Advertised ref names on the remote (for delete mapping and dst guessing).
102    pub remote_ref_names: HashSet<String>,
103    /// Tips of `refs/heads/*` on the remote (reserved for push planning; follow-tags
104    /// uses commit-graph ancestor queries instead of materializing reachable sets).
105    pub remote_branch_tips: HashSet<ObjectId>,
106    /// Tip of every advertised ref, or `None` when the advertisement was not read.
107    ///
108    /// Pass it on as [`PushOptions::remote_ref_tips`](crate::transfer::PushOptions) so a local
109    /// push does not scan the remote's refs a second time. Only pass it to a push to the same
110    /// remote: [`load_push_plan_context`] and [`resolve_push`] select the same remote when given
111    /// the same override.
112    pub remote_tips: Option<Vec<ObjectId>>,
113}
114
115/// Errors while planning a push (typed; no stringly matching in callers).
116#[derive(Debug, Error, PartialEq, Eq)]
117pub enum PushPlanError {
118    /// Detached `HEAD` with no refspec on the command line.
119    #[error("HEAD is detached; specify a refspec (for example HEAD:refs/heads/<name>)")]
120    DetachedHeadNoRefspec,
121    /// Current branch exists but has no commits.
122    #[error("no commits yet to push")]
123    NoCommits,
124    /// `--tags` was given but there are no local tags.
125    #[error("no tags to push")]
126    NoTags,
127    /// `push.default = nothing` blocks an empty refspec push.
128    #[error("push.default is nothing; no push destination")]
129    PushDefaultNothing,
130    /// No remote was named and none is configured: the branch has no push remote or remote,
131    /// `remote.pushDefault` is unset and there is no `origin` with a URL.
132    #[error("no remote to push to")]
133    NoRemote,
134    /// Named remote is missing from config.
135    #[error("unknown remote '{0}'")]
136    UnknownRemote(String),
137    /// Malformed refspec string.
138    #[error("invalid refspec '{spec}': {reason}")]
139    InvalidRefspec {
140        /// Original refspec text.
141        spec: String,
142        /// Parse or validation detail.
143        reason: String,
144    },
145    /// Source side did not match any local ref or object.
146    #[error("src refspec {spec} does not match any ref")]
147    SrcRefspecNoMatch {
148        /// Source pattern or name.
149        spec: String,
150    },
151    /// Source side matched more than one local ref.
152    #[error("src refspec {spec} matches more than one ref")]
153    SrcRefspecAmbiguous {
154        /// Source pattern or name.
155        spec: String,
156    },
157    /// Short destination matched multiple remote refs.
158    #[error("dst refspec {spec} matches more than one remote ref")]
159    DstRefspecAmbiguous {
160        /// Destination pattern or name.
161        spec: String,
162    },
163    /// Revision text could not be resolved to an object.
164    #[error("could not resolve revision '{spec}'")]
165    UnknownRevision {
166        /// Revision expression.
167        spec: String,
168    },
169    /// `--force-with-lease=<rev>` with more than one ref update.
170    #[error("--force-with-lease=<rev> requires pushing exactly one ref")]
171    LeaseValueRequiresSingleRef,
172    /// Other planning failure.
173    #[error("{0}")]
174    Message(String),
175}
176
177type PlanResult<T> = std::result::Result<T, PushPlanError>;
178
179/// Parse `--force` / `--force-with-lease[=<rev>]` into [`ForceMode`].
180pub fn parse_force_mode(
181    repo: &Repository,
182    force: bool,
183    force_with_lease: Option<Option<String>>,
184) -> PlanResult<ForceMode> {
185    if force {
186        return Ok(ForceMode::Force);
187    }
188    let Some(flag) = force_with_lease else {
189        return Ok(ForceMode::None);
190    };
191    match flag {
192        None => Ok(ForceMode::Lease(LeaseExpectation::Tracking)),
193        Some(rev) => {
194            let oid = crate::rev_parse::resolve_revision_for_verify(repo, &rev)
195                .map_err(|_| PushPlanError::UnknownRevision { spec: rev.clone() })?;
196            Ok(ForceMode::Lease(LeaseExpectation::Value(oid)))
197        }
198    }
199}
200
201/// Resolve `request` into a remote name and [`PushRefSpec`] list.
202///
203/// When `ctx.remote_ref_names` is empty, delete/ref DWIM still uses local refs
204/// and falls back to Git's default `refs/heads/<name>` for deletes.
205pub fn resolve_push(
206    repo: &Repository,
207    config: &ConfigSet,
208    request: &PushRequest,
209    ctx: &PushPlanContext,
210) -> PlanResult<ResolvedPush> {
211    if !request.delete.is_empty() {
212        return resolve_delete_push(repo, config, request, ctx);
213    }
214    if request.tags {
215        return resolve_tags_push(repo, config, request);
216    }
217
218    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
219    validate_remote(config, &remote)?;
220
221    let follow_tags = request.follow_tags || push_follow_tags_config(config);
222
223    let mut resolved = if request.refspecs.is_empty() {
224        resolve_default_push(repo, config, &remote, ctx)?
225    } else {
226        let mut specs = Vec::new();
227        for spec in &request.refspecs {
228            specs.extend(resolve_cli_refspec(repo, config, &remote, spec, ctx)?);
229        }
230        let branch_label = branch_label_from_specs(repo, &specs);
231        ResolvedPush {
232            remote,
233            specs,
234            branch_label,
235            kind: PushPlanKind::Refspecs,
236        }
237    };
238
239    if follow_tags {
240        let extra = follow_tag_push_specs(repo, &resolved.specs, ctx)?;
241        resolved.specs.extend(extra);
242    }
243
244    apply_force_mode(
245        repo,
246        config,
247        &resolved.remote,
248        &mut resolved.specs,
249        &request.force,
250    )?;
251    Ok(resolved)
252}
253
254/// Whether `push.followTags` is enabled in config (default false).
255#[must_use]
256pub fn push_follow_tags_config(config: &ConfigSet) -> bool {
257    config
258        .get("push.followTags")
259        .or_else(|| config.get("push.followtags"))
260        .and_then(|v| parse_git_config_maybe_bool(&v))
261        .unwrap_or(false)
262}
263
264/// Append annotated tag refspecs reachable from the updates in `specs`.
265///
266/// Matches `git push --follow-tags`: lightweight tags are skipped, tags already on the remote
267/// are skipped, and reachability uses commit-graph ancestor checks from pushed tips (any ref
268/// namespace). Tag refspecs and specs whose source is a tag object do not seed the walk.
269pub fn follow_tag_push_specs(
270    repo: &Repository,
271    specs: &[PushRefSpec],
272    ctx: &PushPlanContext,
273) -> PlanResult<Vec<PushRefSpec>> {
274    let tips = push_follow_tip_commits(repo, specs)?;
275    if tips.is_empty() {
276        return Ok(Vec::new());
277    }
278
279    let existing: HashSet<&str> = specs.iter().map(|s| s.dst.as_str()).collect();
280    let odb = repo.odb();
281    let mut out = Vec::new();
282    for (tag_name, tag_oid) in list_local_refs(repo.git_dir(), "refs/tags/")? {
283        if ctx.remote_ref_names.contains(&tag_name) || existing.contains(tag_name.as_str()) {
284            continue;
285        }
286        let Ok(obj) = odb.read(&tag_oid) else {
287            continue;
288        };
289        if obj.kind != ObjectKind::Tag {
290            continue;
291        }
292        let Ok((peeled, _)) = odb.peel_tags(tag_oid) else {
293            continue;
294        };
295        if !tag_reachable_from_push_tips(repo, peeled, &tips)? {
296            continue;
297        }
298        out.push(push_spec(
299            Some(tag_oid),
300            Some(tag_name.clone()),
301            tag_name,
302            false,
303            false,
304        ));
305    }
306    Ok(out)
307}
308
309/// After a successful push, apply `-u` / `--set-upstream` when [`PushRequest::set_upstream`] was set.
310///
311/// Returns the short names of branches whose upstream config was written. No-op when `set_upstream`
312/// is false or `dry_run` is true.
313pub fn apply_set_upstream_after_push(
314    repo: &Repository,
315    remote: &str,
316    set_upstream: bool,
317    dry_run: bool,
318    specs: &[PushRefSpec],
319    results: &[PushRefResult],
320) -> Result<Vec<String>, crate::error::Error> {
321    if !set_upstream || dry_run {
322        return Ok(Vec::new());
323    }
324    apply_push_set_upstream(repo, remote, specs, results)
325}
326
327fn push_follow_tip_commits(repo: &Repository, specs: &[PushRefSpec]) -> PlanResult<Vec<ObjectId>> {
328    let mut tips = Vec::new();
329    for spec in specs {
330        if spec.delete || is_tag_push_spec(repo, spec) {
331            continue;
332        }
333        if let Some(commit) = peel_push_src_to_commit(repo, spec) {
334            tips.push(commit);
335        }
336    }
337    Ok(tips)
338}
339
340fn is_tag_push_spec(repo: &Repository, spec: &PushRefSpec) -> bool {
341    if spec.dst.starts_with("refs/tags/") {
342        return true;
343    }
344    let Some(src) = spec.src else {
345        return false;
346    };
347    let Ok(obj) = repo.odb().read(&src) else {
348        return false;
349    };
350    obj.kind == ObjectKind::Tag
351}
352
353fn peel_push_src_to_commit(repo: &Repository, spec: &PushRefSpec) -> Option<ObjectId> {
354    let src = spec.src?;
355    let odb = repo.odb();
356    let Ok(obj) = odb.read(&src) else {
357        return None;
358    };
359    match obj.kind {
360        ObjectKind::Commit => Some(src),
361        ObjectKind::Tag => odb.peel_tags(src).ok().map(|(commit, _)| commit),
362        _ => None,
363    }
364}
365
366fn tag_reachable_from_push_tips(
367    repo: &Repository,
368    peeled: ObjectId,
369    tips: &[ObjectId],
370) -> PlanResult<bool> {
371    for &tip in tips {
372        if peeled == tip {
373            return Ok(true);
374        }
375        if is_ancestor(repo, peeled, tip).map_err(|e| PushPlanError::Message(e.to_string()))? {
376            return Ok(true);
377        }
378    }
379    Ok(false)
380}
381
382fn resolve_delete_push(
383    repo: &Repository,
384    config: &ConfigSet,
385    request: &PushRequest,
386    ctx: &PushPlanContext,
387) -> PlanResult<ResolvedPush> {
388    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
389    validate_remote(config, &remote)?;
390    let specs = request
391        .delete
392        .iter()
393        .map(|name| {
394            let dst = resolve_push_delete_ref(repo.git_dir(), &ctx.remote_ref_names, name);
395            Ok(push_spec(None, None, dst, false, true))
396        })
397        .collect::<PlanResult<Vec<_>>>()?;
398    let branch_label = delete_branch_label(&specs);
399    let mut resolved = ResolvedPush {
400        remote,
401        specs,
402        branch_label,
403        kind: PushPlanKind::Delete,
404    };
405    apply_force_mode(
406        repo,
407        config,
408        &resolved.remote,
409        &mut resolved.specs,
410        &request.force,
411    )?;
412    Ok(resolved)
413}
414
415fn resolve_tags_push(
416    repo: &Repository,
417    config: &ConfigSet,
418    request: &PushRequest,
419) -> PlanResult<ResolvedPush> {
420    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
421    validate_remote(config, &remote)?;
422    let entries = refs::list_refs(repo.git_dir(), "refs/tags/")
423        .map_err(|e| PushPlanError::Message(e.to_string()))?;
424    if entries.is_empty() {
425        return Err(PushPlanError::NoTags);
426    }
427    let specs = entries
428        .into_iter()
429        .map(|(refname, oid)| push_spec(Some(oid), Some(refname.clone()), refname, false, false))
430        .collect();
431    let mut resolved = ResolvedPush {
432        remote,
433        specs,
434        branch_label: None,
435        kind: PushPlanKind::Tags,
436    };
437    apply_force_mode(
438        repo,
439        config,
440        &resolved.remote,
441        &mut resolved.specs,
442        &request.force,
443    )?;
444    Ok(resolved)
445}
446
447fn resolve_default_push(
448    repo: &Repository,
449    config: &ConfigSet,
450    remote: &str,
451    ctx: &PushPlanContext,
452) -> PlanResult<ResolvedPush> {
453    let push_specs = remote_push_refspec_strings(config, remote);
454    if !push_specs.is_empty() {
455        let mut positive_specs = Vec::new();
456        let mut negative_items = Vec::new();
457        for spec in push_specs {
458            let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
459                spec: spec.clone(),
460                reason: e.to_string(),
461            })?;
462            if item.negative {
463                negative_items.push(item);
464            } else {
465                positive_specs.push(spec);
466            }
467        }
468        let mut specs = Vec::new();
469        for spec in positive_specs {
470            specs.extend(expand_configured_push_refspec(repo, spec, ctx)?);
471        }
472        for item in negative_items {
473            specs.retain(|s| !push_spec_excluded_by_negative(&item, s));
474        }
475        let branch_label = branch_label_from_specs(repo, &specs);
476        let kind = if specs.len() == 1 {
477            PushPlanKind::Branch
478        } else {
479            PushPlanKind::Refspecs
480        };
481        return Ok(ResolvedPush {
482            remote: remote.to_owned(),
483            specs,
484            branch_label,
485            kind,
486        });
487    }
488
489    let push_default = config
490        .get("push.default")
491        .unwrap_or_else(|| "simple".to_owned());
492    if push_default == "matching" {
493        return resolve_matching_default_push(repo, remote, ctx);
494    }
495
496    let (short_name, oid) = current_branch_oid(repo)?;
497    let src_ref = format!("refs/heads/{short_name}");
498    let dst = default_push_destination(repo, config, remote, &short_name, &src_ref)?;
499    let spec = push_spec(Some(oid), Some(src_ref.clone()), dst, false, false);
500    Ok(ResolvedPush {
501        remote: remote.to_owned(),
502        specs: vec![spec],
503        branch_label: Some(short_name),
504        kind: PushPlanKind::Branch,
505    })
506}
507
508/// `push.default = matching`: push every local branch whose name exists on the remote.
509fn resolve_matching_default_push(
510    repo: &Repository,
511    remote: &str,
512    ctx: &PushPlanContext,
513) -> PlanResult<ResolvedPush> {
514    let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
515    let mut specs = Vec::new();
516    for (name, oid) in heads {
517        if ctx.remote_ref_names.contains(&name) {
518            specs.push(push_spec(
519                Some(oid),
520                Some(name.clone()),
521                name.clone(),
522                false,
523                false,
524            ));
525        }
526    }
527    let branch_label = branch_label_from_specs(repo, &specs);
528    let kind = if specs.len() == 1 {
529        PushPlanKind::Branch
530    } else {
531        PushPlanKind::Refspecs
532    };
533    Ok(ResolvedPush {
534        remote: remote.to_owned(),
535        specs,
536        branch_label,
537        kind,
538    })
539}
540
541fn current_branch_oid(repo: &Repository) -> PlanResult<(String, ObjectId)> {
542    match resolve_head(repo.git_dir()).map_err(|e| PushPlanError::Message(e.to_string()))? {
543        HeadState::Branch {
544            short_name,
545            oid: Some(oid),
546            ..
547        } => Ok((short_name, oid)),
548        HeadState::Branch { .. } => Err(PushPlanError::NoCommits),
549        HeadState::Detached { .. } => Err(PushPlanError::DetachedHeadNoRefspec),
550        HeadState::Invalid => Err(PushPlanError::Message("HEAD is in an unknown state".into())),
551    }
552}
553
554/// Remote selection for push: explicit, `branch.*.pushRemote`, `remote.pushDefault`,
555/// `branch.*.remote`, then `origin`.
556///
557/// # Errors
558///
559/// [`PushPlanError::NoRemote`] when nothing selects a remote and no `remote.origin.*` section
560/// is configured.
561pub fn configured_remote_for_push(
562    repo: &Repository,
563    config: &ConfigSet,
564    remote_override: Option<&str>,
565) -> PlanResult<String> {
566    if let Some(remote) = remote_override.filter(|r| !r.trim().is_empty()) {
567        return Ok(remote.to_owned());
568    }
569    if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
570        if let Some(remote) = config
571            .get(&format!("branch.{short_name}.pushremote"))
572            .or_else(|| config.get(&format!("branch.{short_name}.pushRemote")))
573            .filter(|r| !r.trim().is_empty())
574        {
575            return Ok(remote);
576        }
577    }
578    if let Some(remote) = config
579        .get("remote.pushdefault")
580        .or_else(|| config.get("remote.pushDefault"))
581        .filter(|r| !r.trim().is_empty())
582    {
583        return Ok(remote);
584    }
585    if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
586        if let Some(remote) = config
587            .get(&format!("branch.{short_name}.remote"))
588            .filter(|r| !r.trim().is_empty() && r != ".")
589        {
590            return Ok(remote);
591        }
592    }
593    if !has_remote_section(config, DEFAULT_REMOTE) {
594        return Err(PushPlanError::NoRemote);
595    }
596    Ok(DEFAULT_REMOTE.to_owned())
597}
598
599/// Remote and refspec strings after classifying `grit push [<remote>] [<refspec>...]`.
600#[derive(Debug, Clone, Default, PartialEq, Eq)]
601pub struct PushInvocation {
602    /// Remote name, URL or path; `None` selects the remote with [`configured_remote_for_push`].
603    pub remote: Option<String>,
604    /// Refspec strings for [`PushRequest::refspecs`].
605    pub refspecs: Vec<String>,
606}
607
608/// Classify push positionals into a remote and refspecs.
609///
610/// With `remote_flag` set, every positional is a refspec. Otherwise the first positional is the
611/// remote when it has a configured `remote.<name>.url` or looks like a literal URL or path
612/// (`scheme://`, `*.git`, `/abs/path`). A single other positional is a refspec pushed to the
613/// default remote; with more positionals the first one must name a remote.
614///
615/// # Errors
616///
617/// [`PushPlanError::UnknownRemote`] when several positionals are given and the first is neither a
618/// configured remote nor a literal URL or path.
619pub fn resolve_push_invocation(
620    config: &ConfigSet,
621    remote_flag: Option<&str>,
622    positionals: &[String],
623) -> PlanResult<PushInvocation> {
624    if let Some(remote) = remote_flag {
625        return Ok(PushInvocation {
626            remote: Some(remote.to_owned()),
627            refspecs: positionals.to_vec(),
628        });
629    }
630    let Some((first, rest)) = positionals.split_first() else {
631        return Ok(PushInvocation::default());
632    };
633    if has_remote_url(config, first) || looks_like_literal_remote(first) {
634        return Ok(PushInvocation {
635            remote: Some(first.clone()),
636            refspecs: rest.to_vec(),
637        });
638    }
639    if !rest.is_empty() {
640        return Err(PushPlanError::UnknownRemote(first.clone()));
641    }
642    Ok(PushInvocation {
643        remote: None,
644        refspecs: positionals.to_vec(),
645    })
646}
647
648/// Open the [`Remote`] a resolved push sends to ([`ResolvedPush::remote`]).
649///
650/// Literal URLs and paths (see [`resolve_push_invocation`]) become anonymous remotes; any other
651/// name is read from `remote.<name>.*` config.
652///
653/// # Errors
654///
655/// [`RemoteError::NoUrl`] when `name` has no configured URL, or a URL parse error.
656pub fn open_push_destination(config: &ConfigSet, name: &str) -> Result<Remote, RemoteError> {
657    if looks_like_literal_remote(name) {
658        Remote::from_url(name)
659    } else {
660        Remote::from_config(config, name)
661    }
662}
663
664impl PushPlanContext {
665    /// Build a context from a remote's ref advertisement (peeled `^{}` entries are skipped).
666    #[must_use]
667    pub fn from_advertised_refs(refs: impl IntoIterator<Item = RemoteRef>) -> Self {
668        let mut ctx = Self::default();
669        let mut tips = Vec::new();
670        for remote_ref in refs {
671            if remote_ref.peeled_tag().is_some() {
672                continue;
673            }
674            if remote_ref.name.starts_with("refs/heads/") {
675                ctx.remote_branch_tips.insert(remote_ref.oid);
676            }
677            tips.push(remote_ref.oid);
678            ctx.remote_ref_names.insert(remote_ref.name);
679        }
680        ctx.remote_tips = Some(tips);
681        ctx
682    }
683}
684
685/// Load the [`PushPlanContext`] for the remote a push would select.
686///
687/// The remote is chosen with [`configured_remote_for_push`], opened with
688/// [`open_push_destination`] (so literal URLs and paths are listed like configured remotes) and
689/// listed with [`Remote::list_push_refs`]. The context is best-effort: a remote without a URL, or one whose refs cannot be listed,
690/// yields an empty context, and the push itself reports any connection failure.
691///
692/// # Errors
693///
694/// Only errors from [`configured_remote_for_push`].
695pub fn load_push_plan_context(
696    repo: &Repository,
697    config: &ConfigSet,
698    remote_override: Option<&str>,
699    http_factory: Option<&dyn HttpClientFactory>,
700) -> PlanResult<PushPlanContext> {
701    let remote = configured_remote_for_push(repo, config, remote_override)?;
702    let Ok(handle) = open_push_destination(config, &remote) else {
703        return Ok(PushPlanContext::default());
704    };
705    let Ok(advertised) = handle.list_push_refs(repo, http_factory) else {
706        return Ok(PushPlanContext::default());
707    };
708    Ok(PushPlanContext::from_advertised_refs(advertised))
709}
710
711fn looks_like_literal_remote(name: &str) -> bool {
712    name.contains("://") || name.ends_with(".git") || name.starts_with('/')
713}
714
715fn has_remote_url(config: &ConfigSet, name: &str) -> bool {
716    config
717        .get(&format!("remote.{name}.url"))
718        .is_some_and(|u| !u.trim().is_empty())
719}
720
721fn validate_remote(config: &ConfigSet, name: &str) -> PlanResult<()> {
722    if has_remote_url(config, name) {
723        Ok(())
724    } else if name == DEFAULT_REMOTE || looks_like_literal_remote(name) {
725        // Literal URL / path remotes are resolved by the transport layer.
726        Ok(())
727    } else {
728        Err(PushPlanError::UnknownRemote(name.to_owned()))
729    }
730}
731
732fn remote_push_refspec_strings(config: &ConfigSet, remote: &str) -> Vec<String> {
733    config
734        .get_all(&format!("remote.{remote}.push"))
735        .into_iter()
736        .filter(|s| !s.trim().is_empty())
737        .collect()
738}
739
740fn default_push_destination(
741    repo: &Repository,
742    config: &ConfigSet,
743    remote: &str,
744    branch_short: &str,
745    _src_ref: &str,
746) -> PlanResult<String> {
747    let push_default = config
748        .get("push.default")
749        .unwrap_or_else(|| "simple".to_owned());
750
751    if push_default == "nothing" {
752        return Err(PushPlanError::PushDefaultNothing);
753    }
754
755    if push_default.as_str() == "upstream" {
756        let merge = config
757            .get(&format!("branch.{branch_short}.merge"))
758            .filter(|m| m.starts_with("refs/"));
759        return merge.ok_or_else(|| {
760            PushPlanError::Message(format!(
761                "branch '{branch_short}' has no upstream for push.default upstream"
762            ))
763        });
764    }
765
766    // `simple`, `current`, and unspecified: grit keeps the historical default of
767    // `branch.<name>.merge` when set, else same branch name on the remote.
768    if push_default.as_str() == "simple" {
769        let branch_remote = config.get(&format!("branch.{branch_short}.remote"));
770        if branch_remote.as_deref() == Some(remote) {
771            if let Some(merge) = config
772                .get(&format!("branch.{branch_short}.merge"))
773                .filter(|m| m.starts_with("refs/"))
774            {
775                let _merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(merge.as_str());
776                let same_name_dst = format!("refs/heads/{branch_short}");
777                if merge != same_name_dst {
778                    // Git `simple` dies when upstream name differs; grit uses merge ref when same remote.
779                    return Ok(merge);
780                }
781                return Ok(same_name_dst);
782            }
783        }
784    }
785
786    let _ = resolve_push_full_ref_for_branch(repo, branch_short);
787    Ok(format!("refs/heads/{branch_short}"))
788}
789
790fn resolve_cli_refspec(
791    repo: &Repository,
792    config: &ConfigSet,
793    remote: &str,
794    spec: &str,
795    ctx: &PushPlanContext,
796) -> PlanResult<Vec<PushRefSpec>> {
797    if !spec.contains(':') {
798        return resolve_shorthand_refspec(repo, config, remote, spec, ctx);
799    }
800    let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
801        spec: spec.to_owned(),
802        reason: e.to_string(),
803    })?;
804    resolve_parsed_refspec(repo, &item, ctx)
805}
806
807fn resolve_shorthand_refspec(
808    repo: &Repository,
809    config: &ConfigSet,
810    remote: &str,
811    spec: &str,
812    ctx: &PushPlanContext,
813) -> PlanResult<Vec<PushRefSpec>> {
814    let local_heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
815    let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
816    let mut all = local_heads;
817    all.extend(local_tags);
818
819    let count = count_refname_match(spec, &all);
820    if count == 1 {
821        let Some(matched) = refname_match(spec, &all) else {
822            return Err(PushPlanError::SrcRefspecNoMatch {
823                spec: spec.to_owned(),
824            });
825        };
826        if let Some(mapped) = map_through_remote_push_refspec(config, remote, &matched.0) {
827            let item = parse_push_refspec(&mapped).map_err(|e| PushPlanError::InvalidRefspec {
828                spec: mapped.clone(),
829                reason: e.to_string(),
830            })?;
831            return resolve_parsed_refspec(repo, &item, ctx);
832        }
833        let item = RefspecItem {
834            force: false,
835            src: Some(matched.0.clone()),
836            dst: Some(matched.0.clone()),
837            ..Default::default()
838        };
839        return resolve_parsed_refspec(repo, &item, ctx);
840    }
841    if count == 0 {
842        // Treat as explicit refspec string (Git refspec_append).
843        let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
844            spec: spec.to_owned(),
845            reason: e.to_string(),
846        })?;
847        return resolve_parsed_refspec(repo, &item, ctx);
848    }
849    Err(PushPlanError::SrcRefspecAmbiguous {
850        spec: spec.to_owned(),
851    })
852}
853
854fn map_through_remote_push_refspec(
855    config: &ConfigSet,
856    remote: &str,
857    local_full_ref: &str,
858) -> Option<String> {
859    for spec in remote_push_refspec_strings(config, remote) {
860        let Ok(item) = parse_push_refspec(&spec) else {
861            continue;
862        };
863        if item.negative || item.matching {
864            continue;
865        }
866        if let Some(dst) = refspec_map_src(&item, local_full_ref) {
867            let force = if item.force { "+" } else { "" };
868            return Some(format!("{force}{local_full_ref}:{dst}"));
869        }
870    }
871    None
872}
873
874fn expand_configured_push_refspec(
875    repo: &Repository,
876    spec: String,
877    ctx: &PushPlanContext,
878) -> PlanResult<Vec<PushRefSpec>> {
879    let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
880        spec: spec.clone(),
881        reason: e.to_string(),
882    })?;
883    if item.matching {
884        let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
885        let mut out = Vec::new();
886        for (name, oid) in heads {
887            if !ctx.remote_ref_names.contains(&name) {
888                continue;
889            }
890            out.push(push_spec(
891                Some(oid),
892                Some(name.clone()),
893                name,
894                item.force,
895                false,
896            ));
897        }
898        return Ok(out);
899    }
900    if item.pattern {
901        let prefix = item.src.as_deref().unwrap_or("");
902        let local = if prefix.starts_with("refs/tags/") {
903            list_local_refs(repo.git_dir(), "refs/tags/")?
904        } else {
905            list_local_refs(repo.git_dir(), "refs/heads/")?
906        };
907        let mut out = Vec::new();
908        for (name, _) in local {
909            if let Some(dst) = refspec_map_src(&item, &name) {
910                let oid = resolve_src_ref(repo, &name)?;
911                out.push(push_spec(
912                    Some(oid),
913                    Some(name.clone()),
914                    dst,
915                    item.force,
916                    false,
917                ));
918            }
919        }
920        return Ok(out);
921    }
922    resolve_parsed_refspec(repo, &item, ctx)
923}
924
925fn resolve_parsed_refspec(
926    repo: &Repository,
927    item: &RefspecItem,
928    ctx: &PushPlanContext,
929) -> PlanResult<Vec<PushRefSpec>> {
930    if item.negative {
931        return Ok(Vec::new());
932    }
933    if item.matching {
934        return expand_configured_push_refspec(
935            repo,
936            if item.force {
937                "+:".to_owned()
938            } else {
939                ":".to_owned()
940            },
941            ctx,
942        );
943    }
944
945    let src_empty = item.src.as_deref().is_some_and(|s| s.is_empty());
946    let src_name = item.src.as_deref().unwrap_or("");
947
948    if src_empty || (src_name.is_empty() && item.dst.is_some()) {
949        let dst = item
950            .dst
951            .as_ref()
952            .ok_or_else(|| PushPlanError::InvalidRefspec {
953                spec: String::new(),
954                reason: "delete refspec missing destination".into(),
955            })?;
956        let resolved_dst = resolve_dst_name(repo, dst, None, ctx)?;
957        return Ok(vec![push_spec(None, None, resolved_dst, item.force, true)]);
958    }
959
960    let src_oid = if item.exact_sha1 {
961        ObjectId::from_hex(src_name).map_err(|_| PushPlanError::UnknownRevision {
962            spec: src_name.to_owned(),
963        })?
964    } else if src_name.is_empty() {
965        resolve_src_ref(repo, "HEAD")?
966    } else {
967        match resolve_src_ref(repo, src_name) {
968            Ok(oid) => oid,
969            Err(_) => {
970                let local = list_local_refs(repo.git_dir(), "refs/heads/")?;
971                let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
972                let mut all = local;
973                all.extend(local_tags);
974                match count_refname_match(src_name, &all) {
975                    0 => {
976                        return Err(PushPlanError::SrcRefspecNoMatch {
977                            spec: src_name.to_owned(),
978                        });
979                    }
980                    1 => {
981                        let Some((name, oid)) = refname_match(src_name, &all) else {
982                            return Err(PushPlanError::SrcRefspecNoMatch {
983                                spec: src_name.to_owned(),
984                            });
985                        };
986                        let dst = resolve_dst_for_src(repo, item, &name, ctx)?;
987                        return Ok(vec![push_spec(
988                            Some(oid),
989                            Some(name.clone()),
990                            dst,
991                            item.force,
992                            false,
993                        )]);
994                    }
995                    _ => {
996                        return Err(PushPlanError::SrcRefspecAmbiguous {
997                            spec: src_name.to_owned(),
998                        });
999                    }
1000                }
1001            }
1002        }
1003    };
1004
1005    let src_full = if item.exact_sha1 {
1006        None
1007    } else {
1008        Some(full_src_ref_name(repo, src_name)?)
1009    };
1010    let src_label = src_full.clone();
1011    let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1012    Ok(vec![push_spec(
1013        Some(src_oid),
1014        src_label,
1015        dst,
1016        item.force,
1017        false,
1018    )])
1019}
1020
1021fn resolve_dst_for_src(
1022    repo: &Repository,
1023    item: &RefspecItem,
1024    src_full: &str,
1025    ctx: &PushPlanContext,
1026) -> PlanResult<String> {
1027    if let Some(dst) = &item.dst {
1028        if dst.is_empty() {
1029            return Err(PushPlanError::InvalidRefspec {
1030                spec: String::new(),
1031                reason: "empty destination".into(),
1032            });
1033        }
1034        return resolve_dst_name(repo, dst, Some(src_full), ctx);
1035    }
1036    // Missing dst: use same ref name on remote (symref target for branches).
1037    if src_full.starts_with("refs/heads/") {
1038        return Ok(src_full.to_owned());
1039    }
1040    if src_full.starts_with("refs/tags/") {
1041        return Ok(src_full.to_owned());
1042    }
1043    Ok(src_full.to_owned())
1044}
1045
1046fn resolve_dst_name(
1047    _repo: &Repository,
1048    dst: &str,
1049    src_full: Option<&str>,
1050    ctx: &PushPlanContext,
1051) -> PlanResult<String> {
1052    if dst.starts_with("refs/") {
1053        return Ok(dst.to_owned());
1054    }
1055    let remote_list = remote_ref_list(ctx);
1056    match count_refname_match(dst, &remote_list) {
1057        0 => {}
1058        1 => {
1059            let Some((name, _)) = refname_match(dst, &remote_list) else {
1060                return Err(PushPlanError::InvalidRefspec {
1061                    spec: dst.to_owned(),
1062                    reason: "remote ref match failed".into(),
1063                });
1064            };
1065            return Ok(name);
1066        }
1067        _ => {
1068            return Err(PushPlanError::DstRefspecAmbiguous {
1069                spec: dst.to_owned(),
1070            });
1071        }
1072    }
1073    if let Some(src) = src_full {
1074        if let Some(guessed) = guess_qualified_dst(src, dst) {
1075            return Ok(guessed);
1076        }
1077    }
1078    Ok(format!("refs/heads/{dst}"))
1079}
1080
1081fn remote_ref_list(ctx: &PushPlanContext) -> Vec<(String, ObjectId)> {
1082    ctx.remote_ref_names
1083        .iter()
1084        .filter_map(|n| {
1085            // oid unused for matching; use zero placeholder.
1086            ObjectId::from_hex("0".repeat(40).as_str())
1087                .ok()
1088                .map(|z| (n.clone(), z))
1089        })
1090        .collect()
1091}
1092
1093fn guess_qualified_dst(src_full: &str, short_dst: &str) -> Option<String> {
1094    if src_full.starts_with("refs/heads/") {
1095        Some(format!("refs/heads/{short_dst}"))
1096    } else if src_full.starts_with("refs/tags/") {
1097        Some(format!("refs/tags/{short_dst}"))
1098    } else {
1099        None
1100    }
1101}
1102
1103/// Fill `force`, `expected_old`, and `expect_absent` on planned specs from [`ForceMode`].
1104fn apply_force_mode(
1105    repo: &Repository,
1106    config: &ConfigSet,
1107    remote: &str,
1108    specs: &mut [PushRefSpec],
1109    mode: &ForceMode,
1110) -> PlanResult<()> {
1111    match mode {
1112        ForceMode::None => {}
1113        ForceMode::Force => {
1114            for spec in specs.iter_mut() {
1115                spec.force = true;
1116                spec.expected_old = None;
1117                spec.expect_absent = false;
1118            }
1119        }
1120        ForceMode::Lease(LeaseExpectation::Tracking) => {
1121            for spec in specs.iter_mut() {
1122                apply_tracking_lease(repo, config, remote, spec);
1123            }
1124        }
1125        ForceMode::Lease(LeaseExpectation::Value(expected)) => {
1126            if specs.len() != 1 {
1127                return Err(PushPlanError::LeaseValueRequiresSingleRef);
1128            }
1129            for spec in specs.iter_mut() {
1130                spec.force = true;
1131                spec.expected_old = Some(*expected);
1132                spec.expect_absent = false;
1133            }
1134        }
1135    }
1136    Ok(())
1137}
1138
1139fn apply_tracking_lease(
1140    repo: &Repository,
1141    config: &ConfigSet,
1142    remote: &str,
1143    spec: &mut PushRefSpec,
1144) {
1145    spec.force = true;
1146    spec.expected_old = None;
1147    spec.expect_absent = false;
1148    if let Some(tracking) = remote_tracking_ref_for_remote_ref(config, remote, &spec.dst) {
1149        match refs::resolve_ref(repo.git_dir(), &tracking) {
1150            Ok(oid) => spec.expected_old = Some(oid),
1151            Err(_) => spec.expect_absent = true,
1152        }
1153    } else {
1154        spec.expect_absent = true;
1155    }
1156}
1157
1158fn push_spec(
1159    src: Option<ObjectId>,
1160    src_ref: Option<String>,
1161    dst: String,
1162    force: bool,
1163    delete: bool,
1164) -> PushRefSpec {
1165    PushRefSpec {
1166        src,
1167        src_ref,
1168        dst,
1169        force,
1170        delete,
1171        ..Default::default()
1172    }
1173}
1174
1175fn resolve_src_ref(repo: &Repository, spec: &str) -> PlanResult<ObjectId> {
1176    resolve_revision_for_verify(repo, spec).map_err(|_| PushPlanError::UnknownRevision {
1177        spec: spec.to_owned(),
1178    })
1179}
1180
1181fn full_src_ref_name(repo: &Repository, spec: &str) -> PlanResult<String> {
1182    if spec.eq_ignore_ascii_case("HEAD") {
1183        if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
1184            return Ok(format!("refs/heads/{short_name}"));
1185        }
1186    }
1187    if spec.starts_with("refs/") {
1188        return Ok(spec.to_owned());
1189    }
1190    let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
1191    if let Some((name, _)) = refname_match(spec, &heads) {
1192        return Ok(name);
1193    }
1194    let tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1195    if let Some((name, _)) = refname_match(spec, &tags) {
1196        return Ok(name);
1197    }
1198    Ok(spec.to_owned())
1199}
1200
1201fn list_local_refs(git_dir: &Path, prefix: &str) -> PlanResult<Vec<(String, ObjectId)>> {
1202    refs::list_refs(git_dir, prefix).map_err(|e| PushPlanError::Message(e.to_string()))
1203}
1204
1205/// Port of Git `count_refspec_match` / `refname_match` for local refs.
1206fn count_refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> usize {
1207    let mut strong = 0usize;
1208    let mut weak = 0usize;
1209    let patlen = pattern.len();
1210    for (name, _) in refs {
1211        if !refname_match_one(pattern, name) {
1212            continue;
1213        }
1214        let namelen = name.len();
1215        if namelen != patlen
1216            && patlen != namelen.saturating_sub(5)
1217            && !name.starts_with("refs/heads/")
1218            && !name.starts_with("refs/tags/")
1219        {
1220            weak += 1;
1221        } else {
1222            strong += 1;
1223        }
1224    }
1225    if strong == 0 {
1226        weak
1227    } else {
1228        strong
1229    }
1230}
1231
1232fn refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> Option<(String, ObjectId)> {
1233    let mut strong: Option<(String, ObjectId)> = None;
1234    let mut weak: Option<(String, ObjectId)> = None;
1235    let patlen = pattern.len();
1236    for (name, oid) in refs {
1237        if !refname_match_one(pattern, name) {
1238            continue;
1239        }
1240        let namelen = name.len();
1241        if namelen != patlen
1242            && patlen != namelen.saturating_sub(5)
1243            && !name.starts_with("refs/heads/")
1244            && !name.starts_with("refs/tags/")
1245        {
1246            weak = Some((name.clone(), *oid));
1247        } else {
1248            strong = Some((name.clone(), *oid));
1249        }
1250    }
1251    strong.or(weak)
1252}
1253
1254fn refname_match_one(pattern: &str, refname: &str) -> bool {
1255    if pattern == refname {
1256        return true;
1257    }
1258    if refname.ends_with(pattern) {
1259        // `main` matches `refs/heads/main`
1260        if refname == format!("refs/heads/{pattern}") || refname == format!("refs/tags/{pattern}") {
1261            return true;
1262        }
1263        if pattern.len() + 5 == refname.len() && refname.starts_with("refs/heads/") {
1264            return refname.strip_prefix("refs/heads/") == Some(pattern);
1265        }
1266        if pattern.len() + 5 == refname.len() && refname.starts_with("refs/tags/") {
1267            return refname.strip_prefix("refs/tags/") == Some(pattern);
1268        }
1269    }
1270    false
1271}
1272
1273fn refspec_map_src(item: &RefspecItem, src_ref: &str) -> Option<String> {
1274    let src_pat = item.src.as_deref()?;
1275    let dst_pat = item.dst.as_deref()?;
1276    match_refname_with_pattern(src_pat, src_ref, dst_pat)
1277}
1278
1279/// Whether a planned push ref should be dropped by a configured `^` refspec.
1280fn push_spec_excluded_by_negative(item: &RefspecItem, spec: &PushRefSpec) -> bool {
1281    if !item.negative {
1282        return false;
1283    }
1284    let Some(pat) = item.src.as_deref() else {
1285        return false;
1286    };
1287    let local = spec.src_ref.as_deref().or_else(|| {
1288        spec.dst
1289            .starts_with("refs/heads/")
1290            .then_some(spec.dst.as_str())
1291    });
1292    let Some(local) = local else {
1293        return false;
1294    };
1295    if item.pattern {
1296        return match_refname_with_pattern(pat, local, pat).is_some();
1297    }
1298    pat == local || refname_match_one(pat, local)
1299}
1300
1301fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
1302    match (pattern.find('*'), replacement.find('*')) {
1303        (Some(pstar), Some(vstar)) => {
1304            let kprefix = &pattern[..pstar];
1305            let ksuffix = &pattern[pstar + 1..];
1306            if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
1307                return None;
1308            }
1309            if refname.len() < kprefix.len() + ksuffix.len() {
1310                return None;
1311            }
1312            let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
1313            Some(format!(
1314                "{}{}{}",
1315                &replacement[..vstar],
1316                middle,
1317                &replacement[vstar + 1..]
1318            ))
1319        }
1320        (None, None) if pattern == refname => Some(replacement.to_owned()),
1321        _ => None,
1322    }
1323}
1324
1325fn delete_branch_label(specs: &[PushRefSpec]) -> Option<String> {
1326    if specs.len() != 1 {
1327        return None;
1328    }
1329    specs[0].dst.strip_prefix("refs/heads/").map(str::to_owned)
1330}
1331
1332fn branch_label_from_specs(_repo: &Repository, specs: &[PushRefSpec]) -> Option<String> {
1333    if specs.len() != 1 || specs[0].delete {
1334        return None;
1335    }
1336    let dst = &specs[0].dst;
1337    dst.strip_prefix("refs/heads/")
1338        .or_else(|| dst.strip_prefix("refs/tags/"))
1339        .map(str::to_owned)
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345    use crate::config::environment::Environment;
1346    use crate::repo::{InitOptions, Repository};
1347
1348    fn init_repo() -> (tempfile::TempDir, Repository) {
1349        let tmp = tempfile::tempdir().expect("tempdir");
1350        let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
1351        (tmp, repo)
1352    }
1353
1354    #[test]
1355    fn push_remote_precedence_pushremote_over_remote() {
1356        let (_tmp, repo) = init_repo();
1357        let git_dir = repo.git_dir();
1358        std::fs::write(
1359            git_dir.join("config"),
1360            r#"[remote "origin"]
1361	url = /tmp/o.git
1362[remote "upstream"]
1363	url = /tmp/u.git
1364[branch "main"]
1365	remote = origin
1366	pushRemote = upstream
1367"#,
1368        )
1369        .expect("write config");
1370        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1371        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1372        let remote = configured_remote_for_push(&repo, &config, None).expect("remote");
1373        assert_eq!(remote, "upstream");
1374    }
1375
1376    #[test]
1377    fn map_through_skips_malformed_remote_push_refspec() {
1378        let (_tmp, repo) = init_repo();
1379        let git_dir = repo.git_dir();
1380        std::fs::write(
1381            git_dir.join("config"),
1382            r#"[remote "origin"]
1383	url = /tmp/o.git
1384	push = not a valid refspec at all
1385	push = refs/heads/*:refs/remotes/origin/*
1386"#,
1387        )
1388        .expect("write config");
1389        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1390        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1391        let mapped = map_through_remote_push_refspec(&config, "origin", "refs/heads/main");
1392        assert_eq!(
1393            mapped.as_deref(),
1394            Some("refs/heads/main:refs/remotes/origin/main")
1395        );
1396    }
1397
1398    #[test]
1399    fn force_with_lease_tracking_sets_expected_old() {
1400        let (_tmp, repo) = init_repo();
1401        let git_dir = repo.git_dir();
1402        std::fs::write(
1403            git_dir.join("config"),
1404            r#"[remote "origin"]
1405	url = /tmp/o.git
1406	fetch = +refs/heads/*:refs/remotes/origin/*
1407[branch "main"]
1408	remote = origin
1409	merge = refs/heads/main
1410"#,
1411        )
1412        .expect("config");
1413        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1414        let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1415        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads dir");
1416        std::fs::write(
1417            git_dir.join("refs/heads/main"),
1418            format!("{}\n", oid.to_hex()),
1419        )
1420        .expect("main");
1421        std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).expect("rt dir");
1422        std::fs::write(
1423            git_dir.join("refs/remotes/origin/main"),
1424            format!("{}\n", oid.to_hex()),
1425        )
1426        .expect("tracking");
1427        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1428        let ctx = PushPlanContext::default();
1429        let resolved = resolve_push(
1430            &repo,
1431            &config,
1432            &PushRequest {
1433                force: ForceMode::Lease(LeaseExpectation::Tracking),
1434                ..Default::default()
1435            },
1436            &ctx,
1437        )
1438        .expect("resolve");
1439        assert_eq!(resolved.specs.len(), 1);
1440        assert_eq!(resolved.specs[0].expected_old, Some(oid));
1441        assert!(resolved.specs[0].force);
1442    }
1443
1444    #[test]
1445    fn negative_push_refspec_filters_planned_spec() {
1446        let item = parse_push_refspec("^refs/heads/private").expect("parse");
1447        let kept = push_spec(
1448            Some(ObjectId::from_hex("0".repeat(40).as_str()).expect("oid")),
1449            Some("refs/heads/main".into()),
1450            "refs/heads/main".into(),
1451            false,
1452            false,
1453        );
1454        let dropped = push_spec(
1455            Some(ObjectId::from_hex("1".repeat(40).as_str()).expect("oid")),
1456            Some("refs/heads/private".into()),
1457            "refs/heads/private".into(),
1458            false,
1459            false,
1460        );
1461        assert!(!push_spec_excluded_by_negative(&item, &kept));
1462        assert!(push_spec_excluded_by_negative(&item, &dropped));
1463    }
1464
1465    fn config_from(text: &str) -> ConfigSet {
1466        let file = crate::config::ConfigFile::parse(
1467            Path::new("config"),
1468            text,
1469            crate::config::ConfigScope::Local,
1470        )
1471        .expect("parse config");
1472        let mut config = ConfigSet::new();
1473        config.merge(&file);
1474        config
1475    }
1476
1477    fn strings(items: &[&str]) -> Vec<String> {
1478        items.iter().map(|s| (*s).to_owned()).collect()
1479    }
1480
1481    #[test]
1482    fn push_invocation_remote_flag_keeps_every_positional_as_refspec() {
1483        let config = ConfigSet::new();
1484        let invocation =
1485            resolve_push_invocation(&config, Some("upstream"), &strings(&["origin", "main"]))
1486                .expect("invocation");
1487        assert_eq!(
1488            invocation,
1489            PushInvocation {
1490                remote: Some("upstream".into()),
1491                refspecs: strings(&["origin", "main"]),
1492            }
1493        );
1494    }
1495
1496    #[test]
1497    fn push_invocation_without_positionals_uses_default_remote() {
1498        let invocation = resolve_push_invocation(&ConfigSet::new(), None, &[]).expect("invocation");
1499        assert_eq!(invocation, PushInvocation::default());
1500    }
1501
1502    #[test]
1503    fn push_invocation_first_positional_is_configured_remote() {
1504        let config = config_from("[remote \"upstream\"]\n\turl = /tmp/u.git\n");
1505        for positionals in [strings(&["upstream"]), strings(&["upstream", "a", "b:c"])] {
1506            let invocation =
1507                resolve_push_invocation(&config, None, &positionals).expect("invocation");
1508            assert_eq!(invocation.remote.as_deref(), Some("upstream"));
1509            assert_eq!(invocation.refspecs, positionals[1..]);
1510        }
1511    }
1512
1513    #[test]
1514    fn push_invocation_first_positional_is_literal_url_or_path() {
1515        let config = ConfigSet::new();
1516        for remote in ["https://example.com/r", "../sibling.git", "/srv/repo"] {
1517            let invocation = resolve_push_invocation(&config, None, &strings(&[remote, "main"]))
1518                .expect("invocation");
1519            assert_eq!(invocation.remote.as_deref(), Some(remote));
1520            assert_eq!(invocation.refspecs, strings(&["main"]));
1521        }
1522    }
1523
1524    #[test]
1525    fn push_invocation_single_unknown_positional_is_a_refspec() {
1526        let config = config_from("[remote \"blank\"]\n\turl =   \n");
1527        for token in ["main", "HEAD:refs/heads/x", "blank"] {
1528            let invocation =
1529                resolve_push_invocation(&config, None, &strings(&[token])).expect("invocation");
1530            assert_eq!(invocation.remote, None, "{token}");
1531            assert_eq!(invocation.refspecs, strings(&[token]));
1532        }
1533    }
1534
1535    #[test]
1536    fn push_without_any_remote_is_no_remote() {
1537        let (_tmp, repo) = init_repo();
1538        assert_eq!(
1539            configured_remote_for_push(&repo, &ConfigSet::new(), None),
1540            Err(PushPlanError::NoRemote)
1541        );
1542        assert_eq!(PushPlanError::NoRemote.to_string(), "no remote to push to");
1543        assert_eq!(
1544            load_push_plan_context(&repo, &ConfigSet::new(), None, None).map(|_| ()),
1545            Err(PushPlanError::NoRemote)
1546        );
1547        let named = configured_remote_for_push(&repo, &ConfigSet::new(), Some("upstream"));
1548        assert_eq!(named.as_deref(), Ok("upstream"));
1549        let url_less_origin = config_from("[remote \"origin\"]\n\tpushurl = /tmp/o.git\n");
1550        assert_eq!(
1551            configured_remote_for_push(&repo, &url_less_origin, None).as_deref(),
1552            Ok("origin")
1553        );
1554    }
1555
1556    #[test]
1557    fn push_invocation_unknown_remote_before_refspecs_is_rejected() {
1558        let err = resolve_push_invocation(&ConfigSet::new(), None, &strings(&["nosuch", "main"]))
1559            .expect_err("unknown remote");
1560        assert_eq!(err, PushPlanError::UnknownRemote("nosuch".into()));
1561    }
1562
1563    #[test]
1564    fn open_push_destination_reads_config_or_treats_literal_as_url() {
1565        let config = config_from(
1566            "[remote \"origin\"]\n\turl = /tmp/o.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1567        );
1568        let origin = open_push_destination(&config, "origin").expect("origin");
1569        assert_eq!(origin.name.as_deref(), Some("origin"));
1570        let literal = open_push_destination(&config, "/srv/other.git").expect("literal");
1571        assert_eq!(literal.name, None);
1572        assert!(matches!(
1573            open_push_destination(&config, "nourl"),
1574            Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1575        ));
1576    }
1577
1578    #[test]
1579    fn plan_context_from_advertisement_skips_peeled_entries() {
1580        let oid = |c: &str| ObjectId::from_hex(&c.repeat(40)).expect("oid");
1581        let remote_ref = |name: &str, hex: &str| RemoteRef {
1582            name: name.to_owned(),
1583            oid: oid(hex),
1584            symref_target: None,
1585        };
1586        let ctx = PushPlanContext::from_advertised_refs([
1587            remote_ref("HEAD", "1"),
1588            remote_ref("refs/heads/main", "1"),
1589            remote_ref("refs/heads/topic", "2"),
1590            remote_ref("refs/tags/v1", "3"),
1591            remote_ref("refs/tags/v1^{}", "4"),
1592        ]);
1593        let mut names: Vec<_> = ctx.remote_ref_names.iter().map(String::as_str).collect();
1594        names.sort_unstable();
1595        assert_eq!(
1596            names,
1597            [
1598                "HEAD",
1599                "refs/heads/main",
1600                "refs/heads/topic",
1601                "refs/tags/v1"
1602            ]
1603        );
1604        assert_eq!(ctx.remote_branch_tips, HashSet::from([oid("1"), oid("2")]));
1605        assert_eq!(
1606            ctx.remote_tips,
1607            Some(vec![oid("1"), oid("1"), oid("2"), oid("3")])
1608        );
1609    }
1610
1611    #[test]
1612    fn plan_context_from_empty_advertisement_knows_remote_has_no_tips() {
1613        let ctx = PushPlanContext::from_advertised_refs([]);
1614        assert_eq!(ctx.remote_tips, Some(Vec::new()));
1615    }
1616
1617    #[test]
1618    fn plan_context_is_empty_for_unconfigured_remote() {
1619        let (_tmp, repo) = init_repo();
1620        let ctx = load_push_plan_context(&repo, &ConfigSet::new(), Some("nosuch"), None)
1621            .expect("context");
1622        assert!(ctx.remote_ref_names.is_empty());
1623        assert!(ctx.remote_branch_tips.is_empty());
1624        assert_eq!(ctx.remote_tips, None);
1625    }
1626
1627    #[test]
1628    fn plan_context_for_missing_path_assumes_no_remote_objects() {
1629        let (tmp, repo) = init_repo();
1630        let missing = tmp.path().join("missing.git");
1631        let missing = missing.to_str().expect("utf-8 path");
1632        let ctx =
1633            load_push_plan_context(&repo, &ConfigSet::new(), Some(missing), None).expect("context");
1634        assert!(ctx.remote_ref_names.is_empty());
1635        assert!(ctx.remote_tips.as_ref().is_none_or(Vec::is_empty));
1636    }
1637
1638    #[test]
1639    fn tag_push_branch_label_is_tag_name_not_current_branch() {
1640        let (_tmp, repo) = init_repo();
1641        let git_dir = repo.git_dir();
1642        std::fs::write(
1643            git_dir.join("config"),
1644            r#"[remote "origin"]
1645	url = /tmp/o.git
1646"#,
1647        )
1648        .expect("config");
1649        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1650        let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1651        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
1652        std::fs::write(
1653            git_dir.join("refs/heads/main"),
1654            format!("{}\n", oid.to_hex()),
1655        )
1656        .expect("main");
1657        std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
1658        std::fs::write(git_dir.join("refs/tags/v1"), format!("{}\n", oid.to_hex())).expect("tag");
1659        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1660        let ctx = PushPlanContext::default();
1661        let resolved = resolve_push(
1662            &repo,
1663            &config,
1664            &PushRequest {
1665                remote: Some("origin".to_owned()),
1666                refspecs: vec!["v1".to_owned()],
1667                ..Default::default()
1668            },
1669            &ctx,
1670        )
1671        .expect("resolve tag push");
1672        assert_eq!(resolved.branch_label.as_deref(), Some("v1"));
1673        assert_eq!(resolved.kind, PushPlanKind::Refspecs);
1674        assert_eq!(resolved.specs[0].dst, "refs/tags/v1");
1675    }
1676}