Skip to main content

sley_remote/
admin.rs

1//! Typed repository administration for configured remotes.
2//!
3//! Argument parsing and byte rendering belong to the CLI. This module owns the
4//! reusable configuration plans and ref transactions behind `git remote`.
5
6use std::collections::BTreeSet;
7use std::mem;
8
9use sley_config::{ConfigEntry, ConfigSection, GitConfig};
10use sley_core::Result;
11use sley_refs::{FileRefStore, RefTarget, RefUpdate};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RemoteMirror {
15    None,
16    Fetch,
17    Push,
18    Both,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RemoteTagMode {
23    Default,
24    All,
25    None,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct AddRemoteOptions {
30    pub name: String,
31    pub url: String,
32    pub branches: Vec<String>,
33    pub master: Option<String>,
34    pub tags: RemoteTagMode,
35    pub mirror: RemoteMirror,
36    pub fetch: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AddRemoteOutcome {
41    pub fetch: bool,
42    pub master_head: Option<RemoteHeadPlan>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum RemoteAdminError {
47    AlreadyExists,
48    NotFound,
49    NameSubset { existing: String },
50    NameSuperset { existing: String },
51    MasterWithMirror,
52    TrackingWithPushMirror,
53    RenameCollision,
54}
55
56/// Add a complete remote section and return post-config work as typed plans.
57pub fn add_remote(
58    config: &mut GitConfig,
59    options: &AddRemoteOptions,
60) -> std::result::Result<AddRemoteOutcome, RemoteAdminError> {
61    if options.mirror != RemoteMirror::None && options.master.is_some() {
62        return Err(RemoteAdminError::MasterWithMirror);
63    }
64    if options.mirror == RemoteMirror::Push && !options.branches.is_empty() {
65        return Err(RemoteAdminError::TrackingWithPushMirror);
66    }
67    for existing in sley_config::remotes::remote_names(config) {
68        if let Some(rest) = options.name.strip_prefix(&existing)
69            && rest.starts_with('/')
70        {
71            return Err(RemoteAdminError::NameSubset { existing });
72        }
73        if let Some(rest) = existing.strip_prefix(&options.name)
74            && rest.starts_with('/')
75        {
76            return Err(RemoteAdminError::NameSuperset { existing });
77        }
78    }
79    let mut entries = vec![ConfigEntry::new("url", Some(options.url.clone()))];
80    match options.mirror {
81        RemoteMirror::Fetch | RemoteMirror::Both => {
82            if options.branches.is_empty() {
83                entries.push(ConfigEntry::new("fetch", Some("+refs/*:refs/*".into())));
84            } else {
85                for branch in &options.branches {
86                    entries.push(ConfigEntry::new(
87                        "fetch",
88                        Some(mirror_fetch_refspec(branch)),
89                    ));
90                }
91            }
92        }
93        RemoteMirror::Push => entries.push(ConfigEntry::new("mirror", Some("true".into()))),
94        RemoteMirror::None => {
95            if options.branches.is_empty() {
96                entries.push(ConfigEntry::new(
97                    "fetch",
98                    Some(sley_config::remotes::default_fetch_refspec(&options.name)),
99                ));
100            } else {
101                for branch in &options.branches {
102                    entries.push(ConfigEntry::new(
103                        "fetch",
104                        Some(remote_branch_fetch_refspec(&options.name, branch)),
105                    ));
106                }
107            }
108        }
109    }
110    match options.tags {
111        RemoteTagMode::Default => {}
112        RemoteTagMode::All => entries.push(ConfigEntry::new("tagOpt", Some("--tags".into()))),
113        RemoteTagMode::None => entries.push(ConfigEntry::new("tagOpt", Some("--no-tags".into()))),
114    }
115    if options.mirror == RemoteMirror::Both {
116        entries.push(ConfigEntry::new("mirror", Some("true".into())));
117    }
118    sley_config::remotes::add_remote(config, &options.name, entries).map_err(
119        |error| match error {
120            sley_config::remotes::RemoteEditError::AlreadyExists => RemoteAdminError::AlreadyExists,
121            sley_config::remotes::RemoteEditError::NotFound => RemoteAdminError::NotFound,
122        },
123    )?;
124    Ok(AddRemoteOutcome {
125        fetch: options.fetch,
126        master_head: options
127            .master
128            .as_ref()
129            .map(|branch| RemoteHeadPlan::set(&options.name, branch)),
130    })
131}
132
133fn mirror_fetch_refspec(branch: &str) -> String {
134    if branch == "*" {
135        "+refs/*:refs/*".to_string()
136    } else {
137        format!("+refs/{branch}:refs/{branch}")
138    }
139}
140
141pub fn remote_branch_fetch_refspec(remote: &str, branch: &str) -> String {
142    format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}")
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct RemoveRemoteOutcome {
147    pub warn_local_branches: bool,
148    pub tracking_prefix: String,
149}
150
151pub fn remove_remote(
152    config: &mut GitConfig,
153    name: &str,
154) -> std::result::Result<RemoveRemoteOutcome, RemoteAdminError> {
155    let warn_local_branches = sley_config::remotes::remote_config_values(config, name, "fetch")
156        .iter()
157        .filter_map(|refspec| refspec.rsplit_once(':').map(|(_, destination)| destination))
158        .any(|destination| destination == "refs/*" || destination == "+refs/*");
159    sley_config::remotes::remove_remote(config, name).map_err(|error| match error {
160        sley_config::remotes::RemoteEditError::NotFound => RemoteAdminError::NotFound,
161        sley_config::remotes::RemoteEditError::AlreadyExists => RemoteAdminError::AlreadyExists,
162    })?;
163    Ok(RemoveRemoteOutcome {
164        warn_local_branches,
165        tracking_prefix: format!("refs/remotes/{name}/"),
166    })
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct RenameRemoteOutcome {
171    pub rename_tracking_refs: bool,
172}
173
174pub fn rename_remote(
175    config: &mut GitConfig,
176    old: &str,
177    new: &str,
178) -> std::result::Result<RenameRemoteOutcome, RemoteAdminError> {
179    let old_exists = sley_config::remotes::remote_exists(config, old);
180    if !old_exists {
181        return Err(RemoteAdminError::NotFound);
182    }
183    if old != new && sley_config::remotes::remote_exists(config, new) {
184        return Err(RemoteAdminError::RenameCollision);
185    }
186    let rename_tracking_refs = sley_config::remotes::remote_config_values(config, old, "fetch")
187        .iter()
188        .any(|refspec| fetch_refspec_targets_remote_tracking(refspec, old));
189    if old == new {
190        return Ok(RenameRemoteOutcome {
191            rename_tracking_refs: false,
192        });
193    }
194    for section in &mut config.sections {
195        if section.name == "remote" && section.subsection.as_deref() == Some(old) {
196            section.subsection = Some(new.to_string());
197            for entry in &mut section.entries {
198                if entry.key.eq_ignore_ascii_case("fetch")
199                    && let Some(value) = &mut entry.value
200                {
201                    *value = value.replace(
202                        &format!("refs/remotes/{old}/"),
203                        &format!("refs/remotes/{new}/"),
204                    );
205                }
206            }
207            move_fetch_entries_to_end(section);
208        } else if section.name == "branch" {
209            for entry in &mut section.entries {
210                if (entry.key.eq_ignore_ascii_case("remote")
211                    || entry.key.eq_ignore_ascii_case("pushRemote"))
212                    && entry.value.as_deref() == Some(old)
213                {
214                    entry.value = Some(new.to_string());
215                }
216            }
217        } else if section.name == "remote" && section.subsection.is_none() {
218            for entry in &mut section.entries {
219                if entry.key.eq_ignore_ascii_case("pushDefault")
220                    && entry.value.as_deref() == Some(old)
221                {
222                    entry.value = Some(new.to_string());
223                }
224            }
225        }
226    }
227    Ok(RenameRemoteOutcome {
228        rename_tracking_refs,
229    })
230}
231
232fn move_fetch_entries_to_end(section: &mut ConfigSection) {
233    let mut fetch_entries = Vec::new();
234    let entries = mem::take(&mut section.entries);
235    for entry in entries {
236        if entry.key.eq_ignore_ascii_case("fetch") {
237            fetch_entries.push(entry);
238        } else {
239            section.entries.push(entry);
240        }
241    }
242    section.entries.extend(fetch_entries);
243}
244
245fn fetch_refspec_targets_remote_tracking(refspec: &str, remote: &str) -> bool {
246    let Some((_, destination)) = refspec.strip_prefix('+').unwrap_or(refspec).split_once(':')
247    else {
248        return false;
249    };
250    destination.starts_with(&format!("refs/remotes/{remote}/"))
251}
252
253pub fn set_remote_branches(
254    config: &mut GitConfig,
255    name: &str,
256    branches: &[String],
257    add: bool,
258) -> std::result::Result<(), RemoteAdminError> {
259    let mirror_fetch = sley_config::remotes::remote_config_values(config, name, "fetch")
260        .iter()
261        .any(|value| value == "+refs/*:refs/*");
262    let Some(section) =
263        config.sections.iter_mut().rev().find(|section| {
264            section.name == "remote" && section.subsection.as_deref() == Some(name)
265        })
266    else {
267        return Err(RemoteAdminError::NotFound);
268    };
269    if !add {
270        section
271            .entries
272            .retain(|entry| !entry.key.eq_ignore_ascii_case("fetch"));
273    }
274    for branch in branches {
275        section.entries.push(ConfigEntry::new(
276            "fetch",
277            Some(if mirror_fetch {
278                mirror_fetch_refspec(branch)
279            } else {
280                remote_branch_fetch_refspec(name, branch)
281            }),
282        ));
283    }
284    Ok(())
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum RemoteHeadMutation {
289    Delete,
290    Set { target: String },
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct RemoteHeadPlan {
295    pub head: String,
296    pub mutation: RemoteHeadMutation,
297}
298
299impl RemoteHeadPlan {
300    pub fn set(remote: &str, branch: &str) -> Self {
301        Self {
302            head: format!("refs/remotes/{remote}/HEAD"),
303            mutation: RemoteHeadMutation::Set {
304                target: format!("refs/remotes/{remote}/{branch}"),
305            },
306        }
307    }
308
309    pub fn delete(remote: &str) -> Self {
310        Self {
311            head: format!("refs/remotes/{remote}/HEAD"),
312            mutation: RemoteHeadMutation::Delete,
313        }
314    }
315}
316
317pub fn apply_remote_head(store: &FileRefStore, plan: &RemoteHeadPlan) -> Result<()> {
318    match &plan.mutation {
319        RemoteHeadMutation::Delete => {
320            let _ = store.delete_symbolic_ref(&plan.head)?;
321            Ok(())
322        }
323        RemoteHeadMutation::Set { target } => {
324            let mut transaction = store.transaction();
325            transaction.update(RefUpdate {
326                name: plan.head.clone(),
327                expected: None,
328                new: RefTarget::Symbolic(target.clone()),
329                reflog: None,
330            });
331            transaction.commit()
332        }
333    }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct RemotePrunePlan {
338    pub remote: String,
339    pub stale_refs: Vec<String>,
340    pub head_target: Option<String>,
341}
342
343pub fn plan_remote_prune(
344    config: &GitConfig,
345    remote: &str,
346    remote_refs: &[sley_refs::Ref],
347    local_refs: &[sley_refs::Ref],
348    head_target: Option<String>,
349) -> RemotePrunePlan {
350    let mut stale = BTreeSet::new();
351    for spec in sley_config::remotes::remote_config_values(config, remote, "fetch") {
352        let Some((source_prefix, destination_prefix)) = fetch_refspec_prefixes(&spec) else {
353            continue;
354        };
355        let remote_names = branch_names_with_prefix(remote_refs, source_prefix)
356            .into_iter()
357            .collect::<BTreeSet<_>>();
358        for suffix in branch_names_with_prefix(local_refs, destination_prefix) {
359            if !remote_names.contains(&suffix) {
360                stale.insert(format!("{destination_prefix}{suffix}"));
361            }
362        }
363    }
364    RemotePrunePlan {
365        remote: remote.to_string(),
366        stale_refs: stale.into_iter().collect(),
367        head_target,
368    }
369}
370
371fn fetch_refspec_prefixes(spec: &str) -> Option<(&str, &str)> {
372    if spec.starts_with('^') {
373        return None;
374    }
375    let spec = spec.strip_prefix('+').unwrap_or(spec);
376    let (source, destination) = spec.split_once(':')?;
377    if source == "refs/*" && destination == "refs/*" {
378        return Some(("refs/heads/", "refs/heads/"));
379    }
380    Some((source.strip_suffix('*')?, destination.strip_suffix('*')?))
381}
382
383fn branch_names_with_prefix(refs: &[sley_refs::Ref], prefix: &str) -> Vec<String> {
384    refs.iter()
385        .filter_map(|reference| reference.name.strip_prefix(prefix))
386        .filter(|branch| *branch != "HEAD")
387        .map(str::to_string)
388        .collect::<BTreeSet<_>>()
389        .into_iter()
390        .collect()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn config(input: &str) -> GitConfig {
398        GitConfig::parse(input.as_bytes()).expect("parse config")
399    }
400
401    #[test]
402    fn add_plan_builds_default_fetch_and_master_head() {
403        let mut config = GitConfig::default();
404        let outcome = add_remote(
405            &mut config,
406            &AddRemoteOptions {
407                name: "origin".into(),
408                url: "../remote".into(),
409                branches: Vec::new(),
410                master: Some("main".into()),
411                tags: RemoteTagMode::Default,
412                mirror: RemoteMirror::None,
413                fetch: false,
414            },
415        )
416        .expect("add remote");
417        assert_eq!(
418            sley_config::remotes::remote_config_values(&config, "origin", "fetch"),
419            vec!["+refs/heads/*:refs/remotes/origin/*"]
420        );
421        assert_eq!(
422            outcome.master_head,
423            Some(RemoteHeadPlan::set("origin", "main"))
424        );
425    }
426
427    #[test]
428    fn rename_plan_updates_remote_and_branch_backreferences() {
429        let mut config = config(
430            "[remote \"old\"]\n\turl = x\n\tfetch = +refs/heads/*:refs/remotes/old/*\n\
431             [branch \"main\"]\n\tremote = old\n\tmerge = refs/heads/main\n",
432        );
433        let outcome = rename_remote(&mut config, "old", "new").expect("rename remote");
434        assert!(outcome.rename_tracking_refs);
435        assert!(sley_config::remotes::remote_exists(&config, "new"));
436        assert_eq!(config.get("branch", Some("main"), "remote"), Some("new"));
437    }
438
439    #[test]
440    fn prune_plan_maps_fetch_refspecs_without_filesystem_access() {
441        use sley_core::{ObjectFormat, ObjectId};
442
443        let oid = ObjectId::null(ObjectFormat::Sha1);
444        let config = config("[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n");
445        let remote_refs = vec![sley_refs::Ref {
446            name: "refs/heads/main".into(),
447            target: RefTarget::Direct(oid),
448        }];
449        let local_refs = vec![
450            sley_refs::Ref {
451                name: "refs/remotes/origin/main".into(),
452                target: RefTarget::Direct(oid),
453            },
454            sley_refs::Ref {
455                name: "refs/remotes/origin/gone".into(),
456                target: RefTarget::Direct(oid),
457            },
458            sley_refs::Ref {
459                name: "refs/remotes/origin/HEAD".into(),
460                target: RefTarget::Symbolic("refs/remotes/origin/main".into()),
461            },
462        ];
463        let plan = plan_remote_prune(&config, "origin", &remote_refs, &local_refs, None);
464        assert_eq!(plan.stale_refs, vec!["refs/remotes/origin/gone"]);
465    }
466}