1use 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
56pub 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}