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