1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
//! Remotes and refspecs.
//!
//! A [`Refspec`] maps ref names on one side of a fetch/push to the other, e.g.
//! `+refs/heads/*:refs/remotes/origin/*`. A [`Remote`] bundles a named remote's
//! URL with its fetch refspecs, loaded from `[remote "<name>"]` in the config.
//!
//! This is the data model and matching logic; wiring it into fetch (creating
//! remote-tracking refs) is a separate step.
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::config::Config;
use crate::error::{Error, Result};
/// A single refspec: an optional `+` (force), a source pattern, and a
/// destination pattern. Either side may end in `*` to match/rewrite a suffix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refspec {
/// Whether the update is forced (`+` prefix): allowed to be non-fast-forward.
pub force: bool,
/// The source side (e.g. `refs/heads/*`).
pub src: String,
/// The destination side (e.g. `refs/remotes/origin/*`); empty if the refspec
/// had no `:` (a bare source, used to name refs to fetch without mapping).
pub dst: String,
}
impl Refspec {
/// Parses a refspec string. Rejects a spec where exactly one side is a
/// wildcard (both sides must use `*`, or neither), or with more than one `*`
/// on a side.
pub fn parse(s: &str) -> Result<Self> {
let (force, rest) = match s.strip_prefix('+') {
Some(r) => (true, r),
None => (false, s),
};
let (src, dst) = match rest.split_once(':') {
Some((a, b)) => (a.to_string(), b.to_string()),
None => (rest.to_string(), String::new()),
};
if src.matches('*').count() > 1 || dst.matches('*').count() > 1 {
return Err(Error::Config(alloc::format!(
"refspec {s:?}: at most one '*' per side"
)));
}
// A wildcard on one side requires a wildcard on the other (unless the
// destination is empty, i.e. a bare source pattern).
if !dst.is_empty() && src.contains('*') != dst.contains('*') {
return Err(Error::Config(alloc::format!(
"refspec {s:?}: '*' must appear on both sides or neither"
)));
}
Ok(Refspec { force, src, dst })
}
/// If `name` matches the source side, returns the mapped destination ref
/// name. A wildcard source captures the suffix and substitutes it into the
/// destination wildcard; a literal source maps to the literal destination
/// (or to itself when the destination is empty).
pub fn dst_for(&self, name: &str) -> Option<String> {
match self.src.strip_suffix('*') {
Some(prefix) => {
let tail = name.strip_prefix(prefix)?;
let dprefix = self.dst.strip_suffix('*')?;
Some(alloc::format!("{dprefix}{tail}"))
}
None if self.src == name => {
if self.dst.is_empty() {
Some(name.to_string())
} else {
Some(self.dst.clone())
}
}
None => None,
}
}
/// Serializes back to the `[+]src[:dst]` wire/config form.
pub fn to_string_form(&self) -> String {
let plus = if self.force { "+" } else { "" };
if self.dst.is_empty() {
alloc::format!("{plus}{}", self.src)
} else {
alloc::format!("{plus}{}:{}", self.src, self.dst)
}
}
}
/// A configured remote: its name, URL, and fetch refspecs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Remote {
/// The remote's short name (e.g. `origin`).
pub name: String,
/// The fetch/push URL (`remote.<name>.url`).
pub url: String,
/// The fetch refspecs (`remote.<name>.fetch`, possibly repeated).
pub fetch: Vec<Refspec>,
}
impl Remote {
/// Loads `[remote "<name>"]` from `config`, or `None` if it has no URL.
/// Malformed fetch refspecs are skipped.
pub fn from_config(config: &Config, name: &str) -> Option<Remote> {
let url = config.get("remote", Some(name), "url")?.to_string();
let fetch = config
.get_all("remote", Some(name), "fetch")
.iter()
.filter_map(|s| Refspec::parse(s).ok())
.collect();
Some(Remote {
name: name.to_string(),
url,
fetch,
})
}
/// The default fetch layout for a freshly-cloned remote:
/// `+refs/heads/*:refs/remotes/<name>/*`.
pub fn default_fetch(name: &str) -> Refspec {
Refspec {
force: true,
src: "refs/heads/*".to_string(),
dst: alloc::format!("refs/remotes/{name}/*"),
}
}
/// Maps a remote ref name to its local remote-tracking ref under the first
/// matching fetch refspec, or `None` if none matches.
pub fn map_fetch(&self, name: &str) -> Option<String> {
self.fetch.iter().find_map(|spec| spec.dst_for(name))
}
}
/// The upstream tracking configuration for a local branch: `(remote, merge)`
/// read from `branch.<name>.remote` and `branch.<name>.merge`. Returns `None`
/// unless both are set. This is what tells `pull`/`fetch` which remote and
/// remote ref a branch follows.
pub fn branch_tracking(config: &Config, branch: &str) -> Option<(String, String)> {
let remote = config.get("branch", Some(branch), "remote")?.to_string();
let merge = config.get("branch", Some(branch), "merge")?.to_string();
Some((remote, merge))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wildcard_refspec_maps_suffix() {
let s = Refspec::parse("+refs/heads/*:refs/remotes/origin/*").unwrap();
assert!(s.force);
assert_eq!(
s.dst_for("refs/heads/main").as_deref(),
Some("refs/remotes/origin/main")
);
assert_eq!(
s.dst_for("refs/heads/feature/x").as_deref(),
Some("refs/remotes/origin/feature/x")
);
assert_eq!(s.dst_for("refs/tags/v1"), None);
}
#[test]
fn literal_refspec_and_bare_source() {
let s = Refspec::parse("refs/heads/main:refs/heads/upstream").unwrap();
assert!(!s.force);
assert_eq!(
s.dst_for("refs/heads/main").as_deref(),
Some("refs/heads/upstream")
);
assert_eq!(s.dst_for("refs/heads/other"), None);
let bare = Refspec::parse("refs/heads/main").unwrap();
assert_eq!(
bare.dst_for("refs/heads/main").as_deref(),
Some("refs/heads/main")
);
assert_eq!(bare.to_string_form(), "refs/heads/main");
}
#[test]
fn mismatched_wildcards_rejected() {
assert!(Refspec::parse("refs/heads/*:refs/remotes/origin/main").is_err());
assert!(Refspec::parse("refs/**/*:x/*").is_err());
}
#[test]
fn loads_remote_from_config() {
let text = "[remote \"origin\"]\n\turl = https://example.com/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n";
let cfg = Config::parse(text).unwrap();
let remote = Remote::from_config(&cfg, "origin").unwrap();
assert_eq!(remote.url, "https://example.com/r.git");
assert_eq!(remote.fetch.len(), 1);
assert_eq!(
remote.map_fetch("refs/heads/main").as_deref(),
Some("refs/remotes/origin/main")
);
assert!(Remote::from_config(&cfg, "nonexistent").is_none());
}
#[test]
fn reads_branch_tracking() {
let text = "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n";
let cfg = Config::parse(text).unwrap();
assert_eq!(
branch_tracking(&cfg, "main"),
Some(("origin".to_string(), "refs/heads/main".to_string()))
);
assert_eq!(branch_tracking(&cfg, "dev"), None);
}
}