detached c45daf9

feat(repos): fetch Chaotic-AUR mirrors dynamically from official mirrorlist

- Add mirrorlist.go with FetchChaoticMirrorlist and BuildPackageURLs functions
- Parse official Chaotic-AUR mirrorlist from GitLab
- Prioritize enabled mirrors, fallback to disabled ones
- Add comprehensive unit tests (9 tests covering parsing, URL building, edge cases)
- Improve error messages with network/HTTP details
- Log mirror statistics (total, enabled, disabled counts)
- Remove hardcoded mirror URLs from config file
commit
c45daf93780cfa878cd56eacacfb935d644f0020
author
bnema · · 2025-10-27 17:46 UTC
committer
bnema · · 2025-10-27 18:25 UTC
parent
774a055
signature
unsigned
4 files · +477 -20
M install/configs/chaotic-aur.conf +4 -6
@@ -5,12 +5,10 @@
55 CHAOTIC_KEY_ID=3056513887B78AEB
66 CHAOTIC_KEYSERVER=keyserver.ubuntu.com
77
8-# Package URLs with mirror fallbacks
9-# Primary and fallback mirrors are pipe-separated; function will try in order
10-# Main site is always available as primary; if it's down, mirrors will be attempted
11-CHAOTIC_KEYRING_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-keyring.pkg.tar.zst
12-CHAOTIC_MIRRORLIST_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst
13-
148 # Repository Configuration
159 CHAOTIC_REPO_NAME=chaotic-aur
1610 CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist
11+
12+# Note: Package mirrors are fetched dynamically from the official Chaotic-AUR mirrorlist
13+# Source: https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist
14+# This ensures always up-to-date mirrors without manual configuration
@@ -5,12 +5,10 @@
5 CHAOTIC_KEY_ID=3056513887B78AEB5 CHAOTIC_KEY_ID=3056513887B78AEB
6 CHAOTIC_KEYSERVER=keyserver.ubuntu.com6 CHAOTIC_KEYSERVER=keyserver.ubuntu.com
7 7
8-# Package URLs with mirror fallbacks
9-# Primary and fallback mirrors are pipe-separated; function will try in order
10-# Main site is always available as primary; if it's down, mirrors will be attempted
11-CHAOTIC_KEYRING_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-keyring.pkg.tar.zst
12-CHAOTIC_MIRRORLIST_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst
13-
14 # Repository Configuration8 # Repository Configuration
15 CHAOTIC_REPO_NAME=chaotic-aur9 CHAOTIC_REPO_NAME=chaotic-aur
16 CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist10 CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist
11+
12+# Note: Package mirrors are fetched dynamically from the official Chaotic-AUR mirrorlist
13+# Source: https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist
14+# This ensures always up-to-date mirrors without manual configuration
M internal/phases/repos.go +49 -14
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/archup/internal/config"
1010 "github.com/bnema/archup/internal/interfaces"
1111 "github.com/bnema/archup/internal/logger"
12+ "github.com/bnema/archup/internal/system"
1213 )
1314
1415 // Pacman configuration constants
@@ -270,7 +271,7 @@ func (p *ReposPhase) loadChaoticConfig() error {
270271 func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error {
271272 p.SendOutput(progressChan, "Adding Chaotic-AUR repository...")
272273
273- // Load configuration
274+ // Load configuration for GPG key settings
274275 switch err := p.loadChaoticConfig(); {
275276 case err != nil:
276277 return fmt.Errorf("failed to load Chaotic config: %w", err)
@@ -278,8 +279,6 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
278279
279280 keyID := p.chaoticConfig["CHAOTIC_KEY_ID"]
280281 keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"]
281- keyringURL := p.chaoticConfig["CHAOTIC_KEYRING_URL"]
282- mirrorlistURL := p.chaoticConfig["CHAOTIC_MIRRORLIST_URL"]
283282 repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
284283 mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"]
285284
@@ -303,22 +302,58 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
303302
304303 p.SendOutput(progressChan, "[OK] Chaotic GPG key imported")
305304
306- // Download and install chaotic-keyring and chaotic-mirrorlist
307- p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
305+ // Fetch live mirrorlist from GitLab
306+ p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...")
307+ mirrors, err := system.FetchChaoticMirrorlist()
308+ switch {
309+ case err != nil:
310+ p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err)
311+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping")
312+ return nil // Don't fail, just skip
313+ }
308314
309- // Parse mirror URLs (pipe-separated for fallbacks)
310- var urls []string
311- for _, url := range strings.Split(keyringURL, "|") {
312- urls = append(urls, strings.TrimSpace(url))
315+ // Count enabled vs disabled mirrors
316+ enabledCount := 0
317+ for _, m := range mirrors {
318+ if m.Enabled {
319+ enabledCount++
320+ }
313321 }
314- for _, url := range strings.Split(mirrorlistURL, "|") {
315- urls = append(urls, strings.TrimSpace(url))
322+
323+ p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab",
324+ "total", len(mirrors),
325+ "enabled", enabledCount,
326+ "disabled", len(mirrors)-enabledCount)
327+
328+ // Build package URLs from live mirrors
329+ const (
330+ keyringPkg = "chaotic-keyring.pkg.tar.zst"
331+ mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst"
332+ )
333+
334+ keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true)
335+ mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true)
336+
337+ if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 {
338+ p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR")
339+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping")
340+ return nil // Don't fail, just skip
316341 }
317342
343+ // Combine all URLs for download
344+ var urls []string
345+ urls = append(urls, keyringURLs...)
346+ urls = append(urls, mirrorlistURLs...)
347+
348+ p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs))
349+
350+ // Download and install chaotic-keyring and chaotic-mirrorlist
351+ p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
352+
318353 switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); {
319354 case err != nil:
320- p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err)
321- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)")
355+ p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err)
356+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping")
322357 return nil // IMPORTANT: Exit early, don't add to pacman.conf
323358 }
324359
@@ -351,7 +386,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
351386 }
352387
353388 // Update package databases
354- switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {
389+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {
355390 case err != nil:
356391 return fmt.Errorf("failed to update package databases: %w", err)
357392 }
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/archup/internal/config"9 "github.com/bnema/archup/internal/config"
10 "github.com/bnema/archup/internal/interfaces"10 "github.com/bnema/archup/internal/interfaces"
11 "github.com/bnema/archup/internal/logger"11 "github.com/bnema/archup/internal/logger"
12+ "github.com/bnema/archup/internal/system"
12 )13 )
13 14
14 // Pacman configuration constants15 // Pacman configuration constants
@@ -270,7 +271,7 @@ func (p *ReposPhase) loadChaoticConfig() error {
270 func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error {271 func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error {
271 p.SendOutput(progressChan, "Adding Chaotic-AUR repository...")272 p.SendOutput(progressChan, "Adding Chaotic-AUR repository...")
272 273
273- // Load configuration274+ // Load configuration for GPG key settings
274 switch err := p.loadChaoticConfig(); {275 switch err := p.loadChaoticConfig(); {
275 case err != nil:276 case err != nil:
276 return fmt.Errorf("failed to load Chaotic config: %w", err)277 return fmt.Errorf("failed to load Chaotic config: %w", err)
@@ -278,8 +279,6 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
278 279
279 keyID := p.chaoticConfig["CHAOTIC_KEY_ID"]280 keyID := p.chaoticConfig["CHAOTIC_KEY_ID"]
280 keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"]281 keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"]
281- keyringURL := p.chaoticConfig["CHAOTIC_KEYRING_URL"]
282- mirrorlistURL := p.chaoticConfig["CHAOTIC_MIRRORLIST_URL"]
283 repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]282 repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
284 mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"]283 mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"]
285 284
@@ -303,22 +302,58 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
303 302
304 p.SendOutput(progressChan, "[OK] Chaotic GPG key imported")303 p.SendOutput(progressChan, "[OK] Chaotic GPG key imported")
305 304
306- // Download and install chaotic-keyring and chaotic-mirrorlist305+ // Fetch live mirrorlist from GitLab
307- p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")306+ p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...")
307+ mirrors, err := system.FetchChaoticMirrorlist()
308+ switch {
309+ case err != nil:
310+ p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err)
311+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping")
312+ return nil // Don't fail, just skip
313+ }
308 314
309- // Parse mirror URLs (pipe-separated for fallbacks)315+ // Count enabled vs disabled mirrors
310- var urls []string316+ enabledCount := 0
311- for _, url := range strings.Split(keyringURL, "|") {317+ for _, m := range mirrors {
312- urls = append(urls, strings.TrimSpace(url))318+ if m.Enabled {
319+ enabledCount++
320+ }
313 }321 }
314- for _, url := range strings.Split(mirrorlistURL, "|") {322+
315- urls = append(urls, strings.TrimSpace(url))323+ p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab",
324+ "total", len(mirrors),
325+ "enabled", enabledCount,
326+ "disabled", len(mirrors)-enabledCount)
327+
328+ // Build package URLs from live mirrors
329+ const (
330+ keyringPkg = "chaotic-keyring.pkg.tar.zst"
331+ mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst"
332+ )
333+
334+ keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true)
335+ mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true)
336+
337+ if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 {
338+ p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR")
339+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping")
340+ return nil // Don't fail, just skip
316 }341 }
317 342
343+ // Combine all URLs for download
344+ var urls []string
345+ urls = append(urls, keyringURLs...)
346+ urls = append(urls, mirrorlistURLs...)
347+
348+ p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs))
349+
350+ // Download and install chaotic-keyring and chaotic-mirrorlist
351+ p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
352+
318 switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); {353 switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); {
319 case err != nil:354 case err != nil:
320- p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err)355+ p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err)
321- p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)")356+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping")
322 return nil // IMPORTANT: Exit early, don't add to pacman.conf357 return nil // IMPORTANT: Exit early, don't add to pacman.conf
323 }358 }
324 359
@@ -351,7 +386,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
351 }386 }
352 387
353 // Update package databases388 // Update package databases
354- switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {389+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {
355 case err != nil:390 case err != nil:
356 return fmt.Errorf("failed to update package databases: %w", err)391 return fmt.Errorf("failed to update package databases: %w", err)
357 }392 }
A internal/system/mirrorlist.go +137 -0
@@ -0,0 +1,137 @@
1+package system
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist"
14+ HTTPTimeout = 10 * time.Second
15+)
16+
17+// MirrorlistEntry represents a parsed mirror entry
18+type MirrorlistEntry struct {
19+ URL string
20+ Enabled bool
21+}
22+
23+// FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab
24+// Returns a list of mirrors with their enabled/disabled status
25+func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) {
26+ client := &http.Client{Timeout: HTTPTimeout}
27+
28+ // Fetch mirrorlist from GitLab
29+ resp, err := client.Get(ChaoticMirrorlistURL)
30+ if err != nil {
31+ return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err)
32+ }
33+ defer resp.Body.Close()
34+
35+ // Check HTTP response status
36+ if resp.StatusCode != http.StatusOK {
37+ return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
38+ }
39+
40+ // Parse the mirrorlist content
41+ mirrors, err := ParseMirrorlist(resp.Body)
42+ if err != nil {
43+ return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
44+ }
45+
46+ return mirrors, nil
47+}
48+
49+// ParseMirrorlist parses a pacman mirrorlist format
50+// Handles lines like:
51+//
52+// Server = https://mirror.example.com/$repo/$arch
53+// #Server = https://disabled.mirror.com/$repo/$arch
54+func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) {
55+ var mirrors []MirrorlistEntry
56+ scanner := bufio.NewScanner(r)
57+
58+ for scanner.Scan() {
59+ line := strings.TrimSpace(scanner.Text())
60+
61+ // Skip empty lines and non-Server lines
62+ if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) {
63+ continue
64+ }
65+
66+ enabled := !strings.HasPrefix(line, "#")
67+
68+ // Remove comment prefix if present
69+ if !enabled {
70+ line = strings.TrimPrefix(line, "#")
71+ line = strings.TrimSpace(line)
72+ }
73+
74+ // Parse "Server = URL" format
75+ parts := strings.SplitN(line, "=", 2)
76+ if len(parts) != 2 {
77+ continue
78+ }
79+
80+ url := strings.TrimSpace(parts[1])
81+ if url == "" {
82+ continue
83+ }
84+
85+ mirrors = append(mirrors, MirrorlistEntry{
86+ URL: url,
87+ Enabled: enabled,
88+ })
89+ }
90+
91+ if err := scanner.Err(); err != nil {
92+ return nil, fmt.Errorf("error parsing mirrorlist: %w", err)
93+ }
94+
95+ if len(mirrors) == 0 {
96+ return nil, fmt.Errorf("no mirrors found in mirrorlist")
97+ }
98+
99+ return mirrors, nil
100+}
101+
102+// BuildPackageURLs converts mirror templates to actual package URLs
103+// Replaces $repo and $arch with actual values, appends package filename
104+func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string {
105+ const (
106+ repo = "chaotic-aur"
107+ arch = "x86_64"
108+ )
109+
110+ var urls []string
111+
112+ // First pass: enabled mirrors (if requested)
113+ if preferEnabled {
114+ for _, mirror := range mirrors {
115+ if !mirror.Enabled {
116+ continue
117+ }
118+ url := strings.ReplaceAll(mirror.URL, "$repo", repo)
119+ url = strings.ReplaceAll(url, "$arch", arch)
120+ url = strings.TrimSuffix(url, "/") + "/" + packageName
121+ urls = append(urls, url)
122+ }
123+ }
124+
125+ // Second pass: all mirrors (fallback)
126+ for _, mirror := range mirrors {
127+ if preferEnabled && mirror.Enabled {
128+ continue // Already added in first pass
129+ }
130+ url := strings.ReplaceAll(mirror.URL, "$repo", repo)
131+ url = strings.ReplaceAll(url, "$arch", arch)
132+ url = strings.TrimSuffix(url, "/") + "/" + packageName
133+ urls = append(urls, url)
134+ }
135+
136+ return urls
137+}
@@ -0,0 +1,137 @@
1+package system
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist"
14+ HTTPTimeout = 10 * time.Second
15+)
16+
17+// MirrorlistEntry represents a parsed mirror entry
18+type MirrorlistEntry struct {
19+ URL string
20+ Enabled bool
21+}
22+
23+// FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab
24+// Returns a list of mirrors with their enabled/disabled status
25+func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) {
26+ client := &http.Client{Timeout: HTTPTimeout}
27+
28+ // Fetch mirrorlist from GitLab
29+ resp, err := client.Get(ChaoticMirrorlistURL)
30+ if err != nil {
31+ return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err)
32+ }
33+ defer resp.Body.Close()
34+
35+ // Check HTTP response status
36+ if resp.StatusCode != http.StatusOK {
37+ return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
38+ }
39+
40+ // Parse the mirrorlist content
41+ mirrors, err := ParseMirrorlist(resp.Body)
42+ if err != nil {
43+ return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
44+ }
45+
46+ return mirrors, nil
47+}
48+
49+// ParseMirrorlist parses a pacman mirrorlist format
50+// Handles lines like:
51+//
52+// Server = https://mirror.example.com/$repo/$arch
53+// #Server = https://disabled.mirror.com/$repo/$arch
54+func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) {
55+ var mirrors []MirrorlistEntry
56+ scanner := bufio.NewScanner(r)
57+
58+ for scanner.Scan() {
59+ line := strings.TrimSpace(scanner.Text())
60+
61+ // Skip empty lines and non-Server lines
62+ if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) {
63+ continue
64+ }
65+
66+ enabled := !strings.HasPrefix(line, "#")
67+
68+ // Remove comment prefix if present
69+ if !enabled {
70+ line = strings.TrimPrefix(line, "#")
71+ line = strings.TrimSpace(line)
72+ }
73+
74+ // Parse "Server = URL" format
75+ parts := strings.SplitN(line, "=", 2)
76+ if len(parts) != 2 {
77+ continue
78+ }
79+
80+ url := strings.TrimSpace(parts[1])
81+ if url == "" {
82+ continue
83+ }
84+
85+ mirrors = append(mirrors, MirrorlistEntry{
86+ URL: url,
87+ Enabled: enabled,
88+ })
89+ }
90+
91+ if err := scanner.Err(); err != nil {
92+ return nil, fmt.Errorf("error parsing mirrorlist: %w", err)
93+ }
94+
95+ if len(mirrors) == 0 {
96+ return nil, fmt.Errorf("no mirrors found in mirrorlist")
97+ }
98+
99+ return mirrors, nil
100+}
101+
102+// BuildPackageURLs converts mirror templates to actual package URLs
103+// Replaces $repo and $arch with actual values, appends package filename
104+func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string {
105+ const (
106+ repo = "chaotic-aur"
107+ arch = "x86_64"
108+ )
109+
110+ var urls []string
111+
112+ // First pass: enabled mirrors (if requested)
113+ if preferEnabled {
114+ for _, mirror := range mirrors {
115+ if !mirror.Enabled {
116+ continue
117+ }
118+ url := strings.ReplaceAll(mirror.URL, "$repo", repo)
119+ url = strings.ReplaceAll(url, "$arch", arch)
120+ url = strings.TrimSuffix(url, "/") + "/" + packageName
121+ urls = append(urls, url)
122+ }
123+ }
124+
125+ // Second pass: all mirrors (fallback)
126+ for _, mirror := range mirrors {
127+ if preferEnabled && mirror.Enabled {
128+ continue // Already added in first pass
129+ }
130+ url := strings.ReplaceAll(mirror.URL, "$repo", repo)
131+ url = strings.ReplaceAll(url, "$arch", arch)
132+ url = strings.TrimSuffix(url, "/") + "/" + packageName
133+ urls = append(urls, url)
134+ }
135+
136+ return urls
137+}
A internal/system/mirrorlist_test.go +287 -0
@@ -0,0 +1,287 @@
1+package system
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestParseMirrorlist(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ input string
12+ wantCount int
13+ wantErr bool
14+ wantFirst MirrorlistEntry
15+ }{
16+ {
17+ name: "Standard mirrorlist with enabled and disabled mirrors",
18+ input: `# Chaotic-AUR mirrorlist
19+Server = https://cdn-mirror.chaotic.cx/$repo/$arch
20+#Server = https://backup.mirror.com/$repo/$arch
21+Server = https://another.mirror.com/$repo/$arch`,
22+ wantCount: 3,
23+ wantErr: false,
24+ wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true},
25+ },
26+ {
27+ name: "Empty mirrorlist",
28+ input: `# Just comments
29+# No servers here`,
30+ wantCount: 0,
31+ wantErr: true,
32+ },
33+ {
34+ name: "Mirrorlist with empty lines and whitespace",
35+ input: `# Header
36+Server = https://mirror1.com/$repo/$arch
37+
38+Server = https://mirror2.com/$repo/$arch
39+
40+#Server = https://mirror3.com/$repo/$arch`,
41+ wantCount: 3,
42+ wantErr: false,
43+ wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true},
44+ },
45+ {
46+ name: "All disabled mirrors",
47+ input: `#Server = https://disabled1.com/$repo/$arch
48+#Server = https://disabled2.com/$repo/$arch`,
49+ wantCount: 2,
50+ wantErr: false,
51+ wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false},
52+ },
53+ {
54+ name: "Malformed Server lines (should be skipped)",
55+ input: `Server https://no-equals.com/$repo/$arch
56+Server =
57+= https://no-server.com/$repo/$arch
58+Server = https://valid.com/$repo/$arch`,
59+ wantCount: 1,
60+ wantErr: false,
61+ wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true},
62+ },
63+ {
64+ name: "Mirrors with various whitespace",
65+ input: `Server = https://spaces.com/$repo/$arch
66+Server=https://nospace.com/$repo/$arch
67+Server = https://tabs.com/$repo/$arch `,
68+ wantCount: 3,
69+ wantErr: false,
70+ wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true},
71+ },
72+ }
73+
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ mirrors, err := ParseMirrorlist(strings.NewReader(tt.input))
77+
78+ if (err != nil) != tt.wantErr {
79+ t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr)
80+ return
81+ }
82+
83+ if !tt.wantErr && len(mirrors) != tt.wantCount {
84+ t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount)
85+ }
86+
87+ if !tt.wantErr && len(mirrors) > 0 {
88+ if mirrors[0] != tt.wantFirst {
89+ t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst)
90+ }
91+ }
92+ })
93+ }
94+}
95+
96+func TestBuildPackageURLs(t *testing.T) {
97+ tests := []struct {
98+ name string
99+ mirrors []MirrorlistEntry
100+ packageName string
101+ preferEnabled bool
102+ wantCount int
103+ wantURLContains []string
104+ shouldStartWith string
105+ }{
106+ {
107+ name: "Basic URL building with enabled mirrors preferred",
108+ mirrors: []MirrorlistEntry{
109+ {URL: "https://mirror1.com/$repo/$arch", Enabled: true},
110+ {URL: "https://mirror2.com/$repo/$arch", Enabled: false},
111+ {URL: "https://mirror3.com/$repo/$arch", Enabled: true},
112+ },
113+ packageName: "test.pkg.tar.zst",
114+ preferEnabled: true,
115+ wantCount: 3,
116+ wantURLContains: []string{
117+ "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst",
118+ "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst",
119+ "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst",
120+ },
121+ },
122+ {
123+ name: "Enabled mirrors should come first",
124+ mirrors: []MirrorlistEntry{
125+ {URL: "https://disabled.com/$repo/$arch", Enabled: false},
126+ {URL: "https://enabled1.com/$repo/$arch", Enabled: true},
127+ {URL: "https://enabled2.com/$repo/$arch", Enabled: true},
128+ },
129+ packageName: "pkg.tar.zst",
130+ preferEnabled: true,
131+ wantCount: 3,
132+ shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst",
133+ },
134+ {
135+ name: "All mirrors when preferEnabled is false",
136+ mirrors: []MirrorlistEntry{
137+ {URL: "https://mirror1.com/$repo/$arch", Enabled: true},
138+ {URL: "https://mirror2.com/$repo/$arch", Enabled: false},
139+ },
140+ packageName: "test.pkg.tar.zst",
141+ preferEnabled: false,
142+ wantCount: 2,
143+ },
144+ {
145+ name: "Empty mirrors list",
146+ mirrors: []MirrorlistEntry{},
147+ packageName: "test.pkg.tar.zst",
148+ preferEnabled: true,
149+ wantCount: 0,
150+ },
151+ {
152+ name: "Template variable substitution",
153+ mirrors: []MirrorlistEntry{
154+ {URL: "https://example.com/$repo/$arch", Enabled: true},
155+ },
156+ packageName: "chaotic-keyring.pkg.tar.zst",
157+ preferEnabled: true,
158+ wantCount: 1,
159+ wantURLContains: []string{
160+ "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst",
161+ },
162+ },
163+ {
164+ name: "URL with trailing slash handling",
165+ mirrors: []MirrorlistEntry{
166+ {URL: "https://example.com/$repo/$arch/", Enabled: true},
167+ },
168+ packageName: "test.pkg.tar.zst",
169+ preferEnabled: true,
170+ wantCount: 1,
171+ wantURLContains: []string{
172+ "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst",
173+ },
174+ },
175+ }
176+
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled)
180+
181+ if len(urls) != tt.wantCount {
182+ t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount)
183+ }
184+
185+ for _, wantURL := range tt.wantURLContains {
186+ found := false
187+ for _, url := range urls {
188+ if url == wantURL {
189+ found = true
190+ break
191+ }
192+ }
193+ if !found {
194+ t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL)
195+ t.Logf("Got URLs: %v", urls)
196+ }
197+ }
198+
199+ if tt.shouldStartWith != "" && len(urls) > 0 {
200+ if urls[0] != tt.shouldStartWith {
201+ t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith)
202+ }
203+ }
204+ })
205+ }
206+}
207+
208+func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) {
209+ // Verify that enabled mirrors always come before disabled ones
210+ mirrors := []MirrorlistEntry{
211+ {URL: "https://d1.com/$repo/$arch", Enabled: false},
212+ {URL: "https://e1.com/$repo/$arch", Enabled: true},
213+ {URL: "https://d2.com/$repo/$arch", Enabled: false},
214+ {URL: "https://e2.com/$repo/$arch", Enabled: true},
215+ {URL: "https://d3.com/$repo/$arch", Enabled: false},
216+ }
217+
218+ urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true)
219+
220+ if len(urls) != 5 {
221+ t.Fatalf("Expected 5 URLs, got %d", len(urls))
222+ }
223+
224+ // First two should be from enabled mirrors
225+ if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") {
226+ t.Errorf("First URL should be from enabled mirror, got %s", urls[0])
227+ }
228+ if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") {
229+ t.Errorf("Second URL should be from enabled mirror, got %s", urls[1])
230+ }
231+
232+ // Last three should be from disabled mirrors
233+ for i := 2; i < 5; i++ {
234+ if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") &&
235+ !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") {
236+ // Check if it contains any disabled mirror domain
237+ isDisabled := false
238+ for _, m := range mirrors {
239+ if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) {
240+ isDisabled = true
241+ break
242+ }
243+ }
244+ if !isDisabled {
245+ t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i])
246+ }
247+ }
248+ }
249+}
250+
251+func TestParseMirrorlist_SpecialCharacters(t *testing.T) {
252+ // Test with various URL formats that might appear in real mirrorlist
253+ input := `
254+Server = https://mirror.example.com:8080/$repo/$arch
255+Server = http://mirror.example.com/$repo/$arch?token=xyz
256+Server = https://mirror-example.com/$repo/$arch
257+Server = https://mirror_example.com/$repo/$arch
258+Server = https://123.456.789.012/$repo/$arch
259+`
260+
261+ mirrors, err := ParseMirrorlist(strings.NewReader(input))
262+ if err != nil {
263+ t.Fatalf("ParseMirrorlist() error = %v", err)
264+ }
265+
266+ if len(mirrors) != 5 {
267+ t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors))
268+ }
269+
270+ // Verify all URLs are preserved correctly
271+ urls := []string{
272+ "https://mirror.example.com:8080/$repo/$arch",
273+ "http://mirror.example.com/$repo/$arch?token=xyz",
274+ "https://mirror-example.com/$repo/$arch",
275+ "https://mirror_example.com/$repo/$arch",
276+ "https://123.456.789.012/$repo/$arch",
277+ }
278+
279+ for i, wantURL := range urls {
280+ if i >= len(mirrors) {
281+ t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors))
282+ }
283+ if mirrors[i].URL != wantURL {
284+ t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL)
285+ }
286+ }
287+}
@@ -0,0 +1,287 @@
1+package system
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestParseMirrorlist(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ input string
12+ wantCount int
13+ wantErr bool
14+ wantFirst MirrorlistEntry
15+ }{
16+ {
17+ name: "Standard mirrorlist with enabled and disabled mirrors",
18+ input: `# Chaotic-AUR mirrorlist
19+Server = https://cdn-mirror.chaotic.cx/$repo/$arch
20+#Server = https://backup.mirror.com/$repo/$arch
21+Server = https://another.mirror.com/$repo/$arch`,
22+ wantCount: 3,
23+ wantErr: false,
24+ wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true},
25+ },
26+ {
27+ name: "Empty mirrorlist",
28+ input: `# Just comments
29+# No servers here`,
30+ wantCount: 0,
31+ wantErr: true,
32+ },
33+ {
34+ name: "Mirrorlist with empty lines and whitespace",
35+ input: `# Header
36+Server = https://mirror1.com/$repo/$arch
37+
38+Server = https://mirror2.com/$repo/$arch
39+
40+#Server = https://mirror3.com/$repo/$arch`,
41+ wantCount: 3,
42+ wantErr: false,
43+ wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true},
44+ },
45+ {
46+ name: "All disabled mirrors",
47+ input: `#Server = https://disabled1.com/$repo/$arch
48+#Server = https://disabled2.com/$repo/$arch`,
49+ wantCount: 2,
50+ wantErr: false,
51+ wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false},
52+ },
53+ {
54+ name: "Malformed Server lines (should be skipped)",
55+ input: `Server https://no-equals.com/$repo/$arch
56+Server =
57+= https://no-server.com/$repo/$arch
58+Server = https://valid.com/$repo/$arch`,
59+ wantCount: 1,
60+ wantErr: false,
61+ wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true},
62+ },
63+ {
64+ name: "Mirrors with various whitespace",
65+ input: `Server = https://spaces.com/$repo/$arch
66+Server=https://nospace.com/$repo/$arch
67+Server = https://tabs.com/$repo/$arch `,
68+ wantCount: 3,
69+ wantErr: false,
70+ wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true},
71+ },
72+ }
73+
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ mirrors, err := ParseMirrorlist(strings.NewReader(tt.input))
77+
78+ if (err != nil) != tt.wantErr {
79+ t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr)
80+ return
81+ }
82+
83+ if !tt.wantErr && len(mirrors) != tt.wantCount {
84+ t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount)
85+ }
86+
87+ if !tt.wantErr && len(mirrors) > 0 {
88+ if mirrors[0] != tt.wantFirst {
89+ t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst)
90+ }
91+ }
92+ })
93+ }
94+}
95+
96+func TestBuildPackageURLs(t *testing.T) {
97+ tests := []struct {
98+ name string
99+ mirrors []MirrorlistEntry
100+ packageName string
101+ preferEnabled bool
102+ wantCount int
103+ wantURLContains []string
104+ shouldStartWith string
105+ }{
106+ {
107+ name: "Basic URL building with enabled mirrors preferred",
108+ mirrors: []MirrorlistEntry{
109+ {URL: "https://mirror1.com/$repo/$arch", Enabled: true},
110+ {URL: "https://mirror2.com/$repo/$arch", Enabled: false},
111+ {URL: "https://mirror3.com/$repo/$arch", Enabled: true},
112+ },
113+ packageName: "test.pkg.tar.zst",
114+ preferEnabled: true,
115+ wantCount: 3,
116+ wantURLContains: []string{
117+ "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst",
118+ "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst",
119+ "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst",
120+ },
121+ },
122+ {
123+ name: "Enabled mirrors should come first",
124+ mirrors: []MirrorlistEntry{
125+ {URL: "https://disabled.com/$repo/$arch", Enabled: false},
126+ {URL: "https://enabled1.com/$repo/$arch", Enabled: true},
127+ {URL: "https://enabled2.com/$repo/$arch", Enabled: true},
128+ },
129+ packageName: "pkg.tar.zst",
130+ preferEnabled: true,
131+ wantCount: 3,
132+ shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst",
133+ },
134+ {
135+ name: "All mirrors when preferEnabled is false",
136+ mirrors: []MirrorlistEntry{
137+ {URL: "https://mirror1.com/$repo/$arch", Enabled: true},
138+ {URL: "https://mirror2.com/$repo/$arch", Enabled: false},
139+ },
140+ packageName: "test.pkg.tar.zst",
141+ preferEnabled: false,
142+ wantCount: 2,
143+ },
144+ {
145+ name: "Empty mirrors list",
146+ mirrors: []MirrorlistEntry{},
147+ packageName: "test.pkg.tar.zst",
148+ preferEnabled: true,
149+ wantCount: 0,
150+ },
151+ {
152+ name: "Template variable substitution",
153+ mirrors: []MirrorlistEntry{
154+ {URL: "https://example.com/$repo/$arch", Enabled: true},
155+ },
156+ packageName: "chaotic-keyring.pkg.tar.zst",
157+ preferEnabled: true,
158+ wantCount: 1,
159+ wantURLContains: []string{
160+ "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst",
161+ },
162+ },
163+ {
164+ name: "URL with trailing slash handling",
165+ mirrors: []MirrorlistEntry{
166+ {URL: "https://example.com/$repo/$arch/", Enabled: true},
167+ },
168+ packageName: "test.pkg.tar.zst",
169+ preferEnabled: true,
170+ wantCount: 1,
171+ wantURLContains: []string{
172+ "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst",
173+ },
174+ },
175+ }
176+
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled)
180+
181+ if len(urls) != tt.wantCount {
182+ t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount)
183+ }
184+
185+ for _, wantURL := range tt.wantURLContains {
186+ found := false
187+ for _, url := range urls {
188+ if url == wantURL {
189+ found = true
190+ break
191+ }
192+ }
193+ if !found {
194+ t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL)
195+ t.Logf("Got URLs: %v", urls)
196+ }
197+ }
198+
199+ if tt.shouldStartWith != "" && len(urls) > 0 {
200+ if urls[0] != tt.shouldStartWith {
201+ t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith)
202+ }
203+ }
204+ })
205+ }
206+}
207+
208+func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) {
209+ // Verify that enabled mirrors always come before disabled ones
210+ mirrors := []MirrorlistEntry{
211+ {URL: "https://d1.com/$repo/$arch", Enabled: false},
212+ {URL: "https://e1.com/$repo/$arch", Enabled: true},
213+ {URL: "https://d2.com/$repo/$arch", Enabled: false},
214+ {URL: "https://e2.com/$repo/$arch", Enabled: true},
215+ {URL: "https://d3.com/$repo/$arch", Enabled: false},
216+ }
217+
218+ urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true)
219+
220+ if len(urls) != 5 {
221+ t.Fatalf("Expected 5 URLs, got %d", len(urls))
222+ }
223+
224+ // First two should be from enabled mirrors
225+ if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") {
226+ t.Errorf("First URL should be from enabled mirror, got %s", urls[0])
227+ }
228+ if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") {
229+ t.Errorf("Second URL should be from enabled mirror, got %s", urls[1])
230+ }
231+
232+ // Last three should be from disabled mirrors
233+ for i := 2; i < 5; i++ {
234+ if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") &&
235+ !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") {
236+ // Check if it contains any disabled mirror domain
237+ isDisabled := false
238+ for _, m := range mirrors {
239+ if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) {
240+ isDisabled = true
241+ break
242+ }
243+ }
244+ if !isDisabled {
245+ t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i])
246+ }
247+ }
248+ }
249+}
250+
251+func TestParseMirrorlist_SpecialCharacters(t *testing.T) {
252+ // Test with various URL formats that might appear in real mirrorlist
253+ input := `
254+Server = https://mirror.example.com:8080/$repo/$arch
255+Server = http://mirror.example.com/$repo/$arch?token=xyz
256+Server = https://mirror-example.com/$repo/$arch
257+Server = https://mirror_example.com/$repo/$arch
258+Server = https://123.456.789.012/$repo/$arch
259+`
260+
261+ mirrors, err := ParseMirrorlist(strings.NewReader(input))
262+ if err != nil {
263+ t.Fatalf("ParseMirrorlist() error = %v", err)
264+ }
265+
266+ if len(mirrors) != 5 {
267+ t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors))
268+ }
269+
270+ // Verify all URLs are preserved correctly
271+ urls := []string{
272+ "https://mirror.example.com:8080/$repo/$arch",
273+ "http://mirror.example.com/$repo/$arch?token=xyz",
274+ "https://mirror-example.com/$repo/$arch",
275+ "https://mirror_example.com/$repo/$arch",
276+ "https://123.456.789.012/$repo/$arch",
277+ }
278+
279+ for i, wantURL := range urls {
280+ if i >= len(mirrors) {
281+ t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors))
282+ }
283+ if mirrors[i].URL != wantURL {
284+ t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL)
285+ }
286+ }
287+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close