1use std::collections::{BTreeMap, BTreeSet, HashSet};
4
5use crate::branch_tracking::remote_tracking_ref_for_remote_ref;
6use crate::config::ConfigSet;
7use crate::error::Result;
8use crate::objects::ObjectId;
9use crate::refs;
10use crate::refspec::{parse_fetch_refspec, RefspecItem};
11use crate::remote::{HttpClientFactory, ListRefsOptions, Remote, RemoteError, RemoteRef};
12use crate::repo::Repository;
13use crate::repo_handles::has_remote_section;
14use crate::transfer::{match_positive, ref_excluded, tracking_refs_gone_in};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct InspectOptions {
19 pub query: bool,
21 pub limit: Option<usize>,
23}
24
25impl Default for InspectOptions {
26 fn default() -> Self {
27 Self {
28 query: true,
29 limit: None,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum RemoteBranchStateKind {
37 Tracked,
39 New,
41 Stale,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RemoteBranchState {
48 pub name: String,
50 pub state: RemoteBranchStateKind,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RemotePullBranch {
57 pub local: String,
59 pub merges: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RemoteSummary {
66 pub name: String,
68 pub fetch_url: String,
70 pub push_url: String,
72 pub head_branch: Option<String>,
74 pub branches: Vec<RemoteBranchState>,
76 pub pull: Vec<RemotePullBranch>,
78}
79
80struct FetchRefspecs {
81 positive: Vec<RefspecItem>,
82 negative: Vec<RefspecItem>,
83}
84
85impl FetchRefspecs {
86 fn load(config: &ConfigSet, remote_name: &str) -> Self {
87 let (negative, mut positive): (Vec<RefspecItem>, Vec<RefspecItem>) = config
88 .get_all(&format!("remote.{remote_name}.fetch"))
89 .iter()
90 .filter_map(|spec| parse_fetch_refspec(spec).ok())
91 .partition(|item| item.negative);
92 if positive.is_empty() {
93 if let Ok(default) =
94 parse_fetch_refspec(&format!("+refs/heads/*:refs/remotes/{remote_name}/*"))
95 {
96 positive.push(default);
97 }
98 }
99 Self { positive, negative }
100 }
101}
102
103pub fn inspect_remote(
111 repo: &Repository,
112 name: &str,
113 options: &InspectOptions,
114 http_factory: Option<&dyn HttpClientFactory>,
115) -> std::result::Result<RemoteSummary, RemoteError> {
116 let config = repo.config().map_err(RemoteError::Library)?;
117 if !has_remote_section(config.as_ref(), name) {
118 return Err(RemoteError::UnknownRemote(name.to_owned()));
119 }
120 let remote = Remote::from_config(config.as_ref(), name)?;
121 let fetch_url = remote.fetch_url().to_url_string();
122 let push_url = remote.push_url().to_url_string();
123
124 let (head_branch, branches) = if options.query {
125 let refspecs = FetchRefspecs::load(config.as_ref(), name);
126 let list_opts = ListRefsOptions {
127 symrefs: true,
128 ..Default::default()
129 };
130 let advertised = remote.list_refs(Some(repo), &list_opts, http_factory)?;
131 let head_branch = head_branch_from_advertisement(&advertised);
132 let branches =
133 branch_states_from_query(repo, config.as_ref(), name, &refspecs, &advertised)?;
134 (head_branch, branches)
135 } else {
136 let refspecs = FetchRefspecs::load(config.as_ref(), name);
137 let head_branch = local_remote_head_branch(repo, name)?;
138 let branches = local_tracking_branch_states(repo, name, &refspecs);
139 (head_branch, branches)
140 };
141
142 let branches = apply_branch_limit(branches, options.limit);
143 let pull = collect_pull_branches(config.as_ref(), name);
144
145 Ok(RemoteSummary {
146 name: name.to_owned(),
147 fetch_url,
148 push_url,
149 head_branch,
150 branches,
151 pull,
152 })
153}
154
155fn head_branch_from_advertisement(refs: &[RemoteRef]) -> Option<String> {
156 refs.iter()
157 .find(|r| r.name == "HEAD")
158 .and_then(|r| r.symref_target.as_ref())
159 .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned())
160}
161
162fn local_remote_head_branch(repo: &Repository, remote_name: &str) -> Result<Option<String>> {
163 let head_ref = format!("refs/remotes/{remote_name}/HEAD");
164 let Some(target) = refs::read_symbolic_ref_for_repository(repo, &head_ref)? else {
165 return Ok(None);
166 };
167 Ok(target
168 .strip_prefix(&format!("refs/remotes/{remote_name}/"))
169 .or_else(|| target.strip_prefix("refs/heads/"))
170 .map(str::to_owned))
171}
172
173fn local_tracking_branch_states(
174 repo: &Repository,
175 remote_name: &str,
176 refspecs: &FetchRefspecs,
177) -> Vec<RemoteBranchState> {
178 local_tracked_head_short_names(repo, remote_name, refspecs)
179 .into_iter()
180 .map(|name| RemoteBranchState {
181 name,
182 state: RemoteBranchStateKind::Tracked,
183 })
184 .collect()
185}
186
187fn local_tracked_head_short_names(
189 repo: &Repository,
190 remote_name: &str,
191 refspecs: &FetchRefspecs,
192) -> Vec<String> {
193 let mut names: BTreeSet<String> = BTreeSet::new();
194 for item in &refspecs.positive {
195 let Some(dst) = item.dst.as_deref() else {
196 continue;
197 };
198 if let Some(star) = dst.find('*') {
199 let prefix = &dst[..star];
200 let Ok(entries) = refs::list_refs_in(repo, prefix) else {
201 continue;
202 };
203 for (refname, _) in entries {
204 if refname.ends_with("/HEAD") {
205 continue;
206 }
207 if let Some(suffix) = refname.strip_prefix(prefix) {
208 names.insert(suffix.to_owned());
209 }
210 }
211 } else if refs::resolve_ref_in(repo, dst).is_ok() {
212 if let Some(src) = item.src.as_deref() {
213 if let Some(short) = src.strip_prefix("refs/heads/") {
214 names.insert(short.to_owned());
215 }
216 }
217 }
218 }
219 if names.is_empty() {
220 let prefix = format!("refs/remotes/{remote_name}/");
221 if let Ok(entries) = refs::list_refs_in(repo, &prefix) {
222 for (refname, _) in entries {
223 if refname == format!("{prefix}HEAD") {
224 continue;
225 }
226 if let Some(short) = refname.strip_prefix(&prefix) {
227 names.insert(short.to_owned());
228 }
229 }
230 }
231 }
232 names.into_iter().collect()
233}
234
235struct LocalTrackingRefs {
244 prefixes: Vec<String>,
245 names: HashSet<String>,
246}
247
248impl LocalTrackingRefs {
249 fn load(repo: &Repository, refspecs: &FetchRefspecs) -> Result<Self> {
250 let mut prefixes: Vec<String> = refspecs
251 .positive
252 .iter()
253 .filter_map(|item| item.dst.as_deref())
254 .filter_map(|dst| dst.find('*').map(|star| &dst[..star]))
255 .filter(|prefix| prefix.ends_with('/'))
256 .map(str::to_owned)
257 .collect();
258 prefixes.sort();
259 prefixes.dedup();
260 let mut names = HashSet::new();
261 for prefix in &prefixes {
262 names.extend(
263 refs::list_refs_in(repo, prefix)?
264 .into_iter()
265 .map(|(name, _)| name),
266 );
267 }
268 Ok(Self { prefixes, names })
269 }
270
271 fn resolves(&self, repo: &Repository, tracking: &str) -> bool {
274 if self
275 .prefixes
276 .iter()
277 .any(|p| tracking.starts_with(p.as_str()))
278 {
279 return self.names.contains(tracking);
280 }
281 refs::resolve_ref_in(repo, tracking).is_ok()
282 }
283}
284
285fn remote_head_is_tracked(
286 repo: &Repository,
287 local: &LocalTrackingRefs,
288 config: &ConfigSet,
289 remote_name: &str,
290 head_short: &str,
291) -> bool {
292 let remote_ref = format!("refs/heads/{head_short}");
293 remote_tracking_ref_for_remote_ref(config, remote_name, &remote_ref)
294 .is_some_and(|tracking| local.resolves(repo, &tracking))
295}
296
297fn remote_head_branch_names(
298 refspecs: &FetchRefspecs,
299 remote_refs: &[RemoteRef],
300) -> BTreeSet<String> {
301 let mut names = BTreeSet::new();
302 for entry in remote_refs {
303 if !entry.name.starts_with("refs/heads/") {
304 continue;
305 }
306 if ref_excluded(&entry.name, &refspecs.negative) {
307 continue;
308 }
309 if match_positive(&entry.name, &refspecs.positive).is_some() {
310 if let Some(short) = entry.name.strip_prefix("refs/heads/") {
311 names.insert(short.to_owned());
312 }
313 }
314 }
315 names
316}
317
318fn branch_states_from_query(
319 repo: &Repository,
320 config: &ConfigSet,
321 remote_name: &str,
322 refspecs: &FetchRefspecs,
323 advertised: &[RemoteRef],
324) -> Result<Vec<RemoteBranchState>> {
325 let remote_heads: BTreeSet<String> = remote_head_branch_names(refspecs, advertised);
326
327 let remote_pairs: Vec<(String, ObjectId)> =
328 advertised.iter().map(|r| (r.name.clone(), r.oid)).collect();
329 let stale_full = tracking_refs_gone_in(repo, &refspecs.positive, &remote_pairs)?;
330 let stale_names: BTreeSet<String> = stale_full
331 .into_iter()
332 .filter_map(|(full, _)| head_short_name_for_tracking_ref(refspecs, remote_name, &full))
333 .collect();
334
335 let local = LocalTrackingRefs::load(repo, refspecs)?;
336 let mut by_name: BTreeMap<String, RemoteBranchStateKind> = BTreeMap::new();
337 for name in &remote_heads {
338 let state = if remote_head_is_tracked(repo, &local, config, remote_name, name) {
339 RemoteBranchStateKind::Tracked
340 } else {
341 RemoteBranchStateKind::New
342 };
343 by_name.insert(name.clone(), state);
344 }
345 for name in stale_names {
346 by_name.entry(name).or_insert(RemoteBranchStateKind::Stale);
347 }
348 Ok(by_name
349 .into_iter()
350 .map(|(name, state)| RemoteBranchState { name, state })
351 .collect())
352}
353
354fn head_short_name_for_tracking_ref(
355 refspecs: &FetchRefspecs,
356 remote_name: &str,
357 tracking_ref: &str,
358) -> Option<String> {
359 for item in &refspecs.positive {
360 let Some(dst) = item.dst.as_deref() else {
361 continue;
362 };
363 if let Some(star) = dst.find('*') {
364 let prefix = &dst[..star];
365 if tracking_ref.starts_with(prefix) {
366 let suffix = tracking_ref.strip_prefix(prefix)?;
367 if suffix.is_empty() || suffix.ends_with("/HEAD") {
368 continue;
369 }
370 return Some(suffix.to_owned());
371 }
372 } else if dst == tracking_ref {
373 return item
374 .src
375 .as_deref()
376 .and_then(|s| s.strip_prefix("refs/heads/"))
377 .map(str::to_owned);
378 }
379 }
380 let prefix = format!("refs/remotes/{remote_name}/");
381 tracking_ref.strip_prefix(&prefix).map(str::to_owned)
382}
383
384fn apply_branch_limit(
385 mut branches: Vec<RemoteBranchState>,
386 limit: Option<usize>,
387) -> Vec<RemoteBranchState> {
388 if let Some(n) = limit {
389 branches.truncate(n);
390 }
391 branches
392}
393
394fn collect_pull_branches(config: &ConfigSet, remote_name: &str) -> Vec<RemotePullBranch> {
395 let mut by_branch: BTreeMap<String, RemotePullBranch> = BTreeMap::new();
396 for entry in config.entries() {
397 let Some(rest) = entry.key.strip_prefix("branch.") else {
398 continue;
399 };
400 let Some((branch, key)) = rest.rsplit_once('.') else {
401 continue;
402 };
403 if key != "remote" || entry.value.as_deref() != Some(remote_name) {
404 continue;
405 }
406 let merge = config
407 .get(&format!("branch.{branch}.merge"))
408 .unwrap_or_default();
409 by_branch.insert(
410 branch.to_owned(),
411 RemotePullBranch {
412 local: branch.to_owned(),
413 merges: merge,
414 },
415 );
416 }
417 by_branch.into_values().collect()
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
424 use crate::repo::{InitOptions, Repository};
425
426 fn config_set(text: &str) -> ConfigSet {
427 let file = ConfigFile::parse(std::path::Path::new("cfg"), text, ConfigScope::Local)
428 .expect("parse");
429 let mut set = ConfigSet::new();
430 set.merge(&file);
431 set
432 }
433
434 #[test]
435 fn collect_pull_branches_reads_branch_config() {
436 let set = config_set(
437 "[remote \"origin\"]\n\turl = /tmp/r.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n[branch \"dev\"]\n\tremote = upstream\n\tmerge = refs/heads/dev\n[branch \"feature\"]\n\tremote = origin\n\tmerge = refs/heads/feature\n",
438 );
439 let pull = collect_pull_branches(&set, "origin");
440 assert_eq!(pull.len(), 2);
441 assert!(pull
442 .iter()
443 .any(|p| p.local == "main" && p.merges == "refs/heads/main"));
444 assert!(pull
445 .iter()
446 .any(|p| p.local == "feature" && p.merges == "refs/heads/feature"));
447 }
448
449 #[test]
450 fn apply_branch_limit_truncates() {
451 let branches = vec![
452 RemoteBranchState {
453 name: "a".into(),
454 state: RemoteBranchStateKind::Tracked,
455 },
456 RemoteBranchState {
457 name: "b".into(),
458 state: RemoteBranchStateKind::New,
459 },
460 ];
461 let limited = apply_branch_limit(branches, Some(1));
462 assert_eq!(limited.len(), 1);
463 assert_eq!(limited[0].name, "a");
464 }
465
466 #[test]
467 fn inspect_unknown_remote_errors() {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
470 let err = inspect_remote(&repo, "missing", &InspectOptions::default(), None)
471 .expect_err("unknown");
472 assert!(matches!(err, RemoteError::UnknownRemote(name) if name == "missing"));
473 }
474}