detached 9a253ed

chore(cleanup): remove dead code from pre-DDD refactor

Delete packages entirely superseded by the domain layer (postboot,
shell, validation, verify, defaults) and trim internal/system/ down
to only what the TUI still calls: ListDisks, DetectCPUInfo,
DetectTimezoneSimple, ChrootExec*, Unmount, BeginSession,
DownloadAndInstallPackages.

Also drop internal/interfaces/cli/.gitkeep (empty placeholder).
commit
9a253ed3e0ba76e10d32c88d0f03c43ef29c886a
author
brice · · 2026-03-03 03:25 UTC
parent
59b3181
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +15 -1248
D internal/defaults/defaults.go +0 -32
@@ -1,32 +0,0 @@
1-package defaults
2-
3-import (
4- "embed"
5- "fmt"
6- "strings"
7-)
8-
9-//go:embed packages/*.packages
10-var packageLists embed.FS
11-
12-// ReadPackageList loads a named package list.
13-func ReadPackageList(name string) ([]string, error) {
14- path := fmt.Sprintf("packages/%s.packages", name)
15- data, err := packageLists.ReadFile(path)
16- if err != nil {
17- return nil, fmt.Errorf("read package list %s: %w", name, err)
18- }
19- return splitPackages(string(data)), nil
20-}
21-
22-func splitPackages(contents string) []string {
23- packages := []string{}
24- for _, line := range strings.Split(contents, "\n") {
25- trimmed := strings.TrimSpace(line)
26- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
27- continue
28- }
29- packages = append(packages, strings.Fields(trimmed)...)
30- }
31- return packages
32-}
@@ -1,32 +0,0 @@
1-package defaults
2-
3-import (
4- "embed"
5- "fmt"
6- "strings"
7-)
8-
9-//go:embed packages/*.packages
10-var packageLists embed.FS
11-
12-// ReadPackageList loads a named package list.
13-func ReadPackageList(name string) ([]string, error) {
14- path := fmt.Sprintf("packages/%s.packages", name)
15- data, err := packageLists.ReadFile(path)
16- if err != nil {
17- return nil, fmt.Errorf("read package list %s: %w", name, err)
18- }
19- return splitPackages(string(data)), nil
20-}
21-
22-func splitPackages(contents string) []string {
23- packages := []string{}
24- for _, line := range strings.Split(contents, "\n") {
25- trimmed := strings.TrimSpace(line)
26- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
27- continue
28- }
29- packages = append(packages, strings.Fields(trimmed)...)
30- }
31- return packages
32-}
D internal/defaults/packages/compositor-hyprland.packages +0 -1
@@ -1,1 +0,0 @@
1-hyprland xdg-desktop-portal-hyprland
@@ -1,1 +0,0 @@
1-hyprland xdg-desktop-portal-hyprland
D internal/defaults/packages/compositor-niri.packages +0 -1
@@ -1,1 +0,0 @@
1-niri xdg-desktop-portal-gnome
@@ -1,1 +0,0 @@
1-niri xdg-desktop-portal-gnome
D internal/defaults/packages/desktop-core.packages +0 -2
@@ -1,2 +0,0 @@
1-foot waybar fuzzel hypridle hyprlock swww mako nautilus
2-xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr
@@ -1,2 +0,0 @@
1-foot waybar fuzzel hypridle hyprlock swww mako nautilus
2-xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr
D internal/defaults/packages/gtk.packages +0 -1
@@ -1,1 +0,0 @@
1-gtk4 libadwaita adw-gtk3
@@ -1,1 +0,0 @@
1-gtk4 libadwaita adw-gtk3
D internal/defaults/packages/sddm.packages +0 -1
@@ -1,1 +0,0 @@
1-sddm qt6-svg qt6-virtualkeyboard
@@ -1,1 +0,0 @@
1-sddm qt6-svg qt6-virtualkeyboard
D internal/defaults/packages/video-accel.packages +0 -4
@@ -1,4 +0,0 @@
1-libva libva-utils nvtop vulkan-icd-loader vulkan-tools
2-gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad
3-gst-plugins-ugly gst-plugin-va gst-libav ffmpeg
4-pipewire pipewire-pulse pipewire-alsa wireplumber
@@ -1,4 +0,0 @@
1-libva libva-utils nvtop vulkan-icd-loader vulkan-tools
2-gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad
3-gst-plugins-ugly gst-plugin-va gst-libav ffmpeg
4-pipewire pipewire-pulse pipewire-alsa wireplumber
D internal/interfaces/cli/.gitkeep +0 -0

no textual change.

D internal/postboot/setup.go +0 -131
@@ -1,131 +0,0 @@
1-package postboot
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/archup/internal/config"
11- "github.com/bnema/archup/internal/interfaces"
12- "github.com/bnema/archup/internal/logger"
13-)
14-
15-// fetchFile returns the content of a repo-relative path.
16-// It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local),
17-// then falls back to downloading from GitHub.
18-func (s *Setup) fetchFile(repoPath string) ([]byte, error) {
19- localPath := filepath.Join(config.DefaultInstallDir, repoPath)
20- if content, err := s.fs.ReadFile(localPath); err == nil {
21- return content, nil
22- }
23- url := fmt.Sprintf("%s/%s", s.config.RawURL, repoPath)
24- resp, err := s.http.Get(url)
25- if err != nil {
26- return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err)
27- }
28- defer resp.Body.Close()
29- if resp.StatusCode != http.StatusOK {
30- return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode)
31- }
32- data, err := io.ReadAll(resp.Body)
33- if err != nil {
34- return nil, fmt.Errorf("failed to read %s: %w", repoPath, err)
35- }
36- return data, nil
37-}
38-
39-// SetupResult holds the result of post-boot setup
40-type SetupResult struct {
41- ScriptsDownloaded int
42- ServiceEnabled bool
43-}
44-
45-// Setup handles first-boot service configuration
46-type Setup struct {
47- fs interfaces.FileSystem
48- http interfaces.HTTPClient
49- chrExec interfaces.ChrootExecutor
50- config *config.Config
51- logger *logger.Logger
52-}
53-
54-// NewSetup creates a new post-boot setup handler
55-func NewSetup(
56- fs interfaces.FileSystem,
57- http interfaces.HTTPClient,
58- chrExec interfaces.ChrootExecutor,
59- cfg *config.Config,
60- log *logger.Logger,
61-) *Setup {
62- return &Setup{
63- fs: fs,
64- http: http,
65- chrExec: chrExec,
66- config: cfg,
67- logger: log,
68- }
69-}
70-
71-// Configure downloads and configures first-boot service
72-func (s *Setup) Configure() (SetupResult, error) {
73- result := SetupResult{
74- ScriptsDownloaded: 0,
75- ServiceEnabled: false,
76- }
77-
78- if err := s.fs.MkdirAll(config.PathMntPostBoot, 0755); err != nil {
79- return result, fmt.Errorf("failed to create post-boot directory: %w", err)
80- }
81-
82- // Fetch logo.txt (local-first, then remote)
83- logoData, err := s.fetchFile("logo.txt")
84- if err != nil {
85- return result, fmt.Errorf("failed to fetch logo.txt: %w", err)
86- }
87- logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
88- if err := s.fs.WriteFile(logoPath, logoData, 0644); err != nil {
89- return result, fmt.Errorf("failed to write logo.txt: %w", err)
90- }
91-
92- // Fetch all post-boot scripts (local-first, then remote)
93- for _, script := range config.PostBootScripts {
94- data, err := s.fetchFile(filepath.Join("install", "mandatory", "post-boot", script))
95- if err != nil {
96- return result, fmt.Errorf("failed to fetch %s: %w", script, err)
97- }
98-
99- scriptPath := filepath.Join(config.PathMntPostBoot, script)
100- if err := s.fs.WriteFile(scriptPath, data, 0755); err != nil {
101- return result, fmt.Errorf("failed to write %s: %w", script, err)
102- }
103-
104- if err := s.fs.Chmod(scriptPath, 0755); err != nil {
105- return result, fmt.Errorf("failed to set permissions on %s: %w", script, err)
106- }
107- }
108-
109- // Fetch service template (local-first, then remote)
110- templateBytes, err := s.fetchFile(config.PostBootServiceTemplate)
111- if err != nil {
112- return result, fmt.Errorf("failed to fetch service template: %w", err)
113- }
114-
115- serviceContent := string(templateBytes)
116- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", s.config.Email)
117- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", s.config.Username)
118-
119- servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)
120- if err := s.fs.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil {
121- return result, fmt.Errorf("failed to write service file: %w", err)
122- }
123-
124- if err := s.chrExec.ChrootSystemctl(s.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); err != nil {
125- return result, fmt.Errorf("failed to enable service: %w", err)
126- }
127-
128- result.ServiceEnabled = true
129- result.ScriptsDownloaded = len(config.PostBootScripts)
130- return result, nil
131-}
@@ -1,131 +0,0 @@
1-package postboot
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/archup/internal/config"
11- "github.com/bnema/archup/internal/interfaces"
12- "github.com/bnema/archup/internal/logger"
13-)
14-
15-// fetchFile returns the content of a repo-relative path.
16-// It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local),
17-// then falls back to downloading from GitHub.
18-func (s *Setup) fetchFile(repoPath string) ([]byte, error) {
19- localPath := filepath.Join(config.DefaultInstallDir, repoPath)
20- if content, err := s.fs.ReadFile(localPath); err == nil {
21- return content, nil
22- }
23- url := fmt.Sprintf("%s/%s", s.config.RawURL, repoPath)
24- resp, err := s.http.Get(url)
25- if err != nil {
26- return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err)
27- }
28- defer resp.Body.Close()
29- if resp.StatusCode != http.StatusOK {
30- return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode)
31- }
32- data, err := io.ReadAll(resp.Body)
33- if err != nil {
34- return nil, fmt.Errorf("failed to read %s: %w", repoPath, err)
35- }
36- return data, nil
37-}
38-
39-// SetupResult holds the result of post-boot setup
40-type SetupResult struct {
41- ScriptsDownloaded int
42- ServiceEnabled bool
43-}
44-
45-// Setup handles first-boot service configuration
46-type Setup struct {
47- fs interfaces.FileSystem
48- http interfaces.HTTPClient
49- chrExec interfaces.ChrootExecutor
50- config *config.Config
51- logger *logger.Logger
52-}
53-
54-// NewSetup creates a new post-boot setup handler
55-func NewSetup(
56- fs interfaces.FileSystem,
57- http interfaces.HTTPClient,
58- chrExec interfaces.ChrootExecutor,
59- cfg *config.Config,
60- log *logger.Logger,
61-) *Setup {
62- return &Setup{
63- fs: fs,
64- http: http,
65- chrExec: chrExec,
66- config: cfg,
67- logger: log,
68- }
69-}
70-
71-// Configure downloads and configures first-boot service
72-func (s *Setup) Configure() (SetupResult, error) {
73- result := SetupResult{
74- ScriptsDownloaded: 0,
75- ServiceEnabled: false,
76- }
77-
78- if err := s.fs.MkdirAll(config.PathMntPostBoot, 0755); err != nil {
79- return result, fmt.Errorf("failed to create post-boot directory: %w", err)
80- }
81-
82- // Fetch logo.txt (local-first, then remote)
83- logoData, err := s.fetchFile("logo.txt")
84- if err != nil {
85- return result, fmt.Errorf("failed to fetch logo.txt: %w", err)
86- }
87- logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
88- if err := s.fs.WriteFile(logoPath, logoData, 0644); err != nil {
89- return result, fmt.Errorf("failed to write logo.txt: %w", err)
90- }
91-
92- // Fetch all post-boot scripts (local-first, then remote)
93- for _, script := range config.PostBootScripts {
94- data, err := s.fetchFile(filepath.Join("install", "mandatory", "post-boot", script))
95- if err != nil {
96- return result, fmt.Errorf("failed to fetch %s: %w", script, err)
97- }
98-
99- scriptPath := filepath.Join(config.PathMntPostBoot, script)
100- if err := s.fs.WriteFile(scriptPath, data, 0755); err != nil {
101- return result, fmt.Errorf("failed to write %s: %w", script, err)
102- }
103-
104- if err := s.fs.Chmod(scriptPath, 0755); err != nil {
105- return result, fmt.Errorf("failed to set permissions on %s: %w", script, err)
106- }
107- }
108-
109- // Fetch service template (local-first, then remote)
110- templateBytes, err := s.fetchFile(config.PostBootServiceTemplate)
111- if err != nil {
112- return result, fmt.Errorf("failed to fetch service template: %w", err)
113- }
114-
115- serviceContent := string(templateBytes)
116- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", s.config.Email)
117- serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", s.config.Username)
118-
119- servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)
120- if err := s.fs.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil {
121- return result, fmt.Errorf("failed to write service file: %w", err)
122- }
123-
124- if err := s.chrExec.ChrootSystemctl(s.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); err != nil {
125- return result, fmt.Errorf("failed to enable service: %w", err)
126- }
127-
128- result.ServiceEnabled = true
129- result.ScriptsDownloaded = len(config.PostBootScripts)
130- return result, nil
131-}
D internal/shell/configurator.go +0 -25
@@ -1,25 +0,0 @@
1-package shell
2-
3-// ConfigResult holds the result of shell configuration
4-type ConfigResult struct {
5- ThemesApplied int
6- ThemesFailed []string
7- Warnings []string
8-}
9-
10-// Configurator handles shell and bash configuration setup
11-type Configurator struct{}
12-
13-// NewConfigurator creates a new shell configurator
14-func NewConfigurator() *Configurator {
15- return &Configurator{}
16-}
17-
18-// Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot.
19-func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) {
20- return ConfigResult{
21- ThemesApplied: 0,
22- ThemesFailed: []string{},
23- Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"},
24- }, nil
25-}
@@ -1,25 +0,0 @@
1-package shell
2-
3-// ConfigResult holds the result of shell configuration
4-type ConfigResult struct {
5- ThemesApplied int
6- ThemesFailed []string
7- Warnings []string
8-}
9-
10-// Configurator handles shell and bash configuration setup
11-type Configurator struct{}
12-
13-// NewConfigurator creates a new shell configurator
14-func NewConfigurator() *Configurator {
15- return &Configurator{}
16-}
17-
18-// Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot.
19-func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) {
20- return ConfigResult{
21- ThemesApplied: 0,
22- ThemesFailed: []string{},
23- Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"},
24- }, nil
25-}
M internal/system/chroot.go +0 -26
@@ -179,20 +179,6 @@ func ChrootSystemctl(logPath, mountPoint, action, service string) error {
179179 return nil
180180 }
181181
182-// Mount mounts a filesystem
183-func Mount(logPath, device, mountPoint string, options ...string) error {
184- args := []string{device, mountPoint}
185- if len(options) > 0 {
186- args = append([]string{"-o", strings.Join(options, ",")}, args...)
187- }
188-
189- result := RunLogged(logPath, "mount", args...)
190- if result.Error != nil {
191- return fmt.Errorf("failed to mount %s: %w", device, result.Error)
192- }
193- return nil
194-}
195-
196182 // Unmount unmounts a filesystem
197183 func Unmount(logPath, mountPoint string) error {
198184 result := RunLogged(logPath, "umount", "-R", mountPoint)
@@ -202,18 +188,6 @@ func Unmount(logPath, mountPoint string) error {
202188 return nil
203189 }
204190
205-// Genfstab generates fstab for the installed system
206-func Genfstab(logPath, mountPoint, fstabPath string) error {
207- result := RunLogged(logPath, "genfstab", "-U", mountPoint)
208- if result.Error != nil {
209- return fmt.Errorf("failed to generate fstab: %w", result.Error)
210- }
211-
212- // TODO: Write result.Output to fstabPath
213- // This should be done using file operations
214- return nil
215-}
216-
217191 // DefaultChrootSession represents a persistent chroot session
218192 type DefaultChrootSession struct {
219193 cmd *exec.Cmd
@@ -179,20 +179,6 @@ func ChrootSystemctl(logPath, mountPoint, action, service string) error {
179 return nil179 return nil
180 }180 }
181 181
182-// Mount mounts a filesystem
183-func Mount(logPath, device, mountPoint string, options ...string) error {
184- args := []string{device, mountPoint}
185- if len(options) > 0 {
186- args = append([]string{"-o", strings.Join(options, ",")}, args...)
187- }
188-
189- result := RunLogged(logPath, "mount", args...)
190- if result.Error != nil {
191- return fmt.Errorf("failed to mount %s: %w", device, result.Error)
192- }
193- return nil
194-}
195-
196 // Unmount unmounts a filesystem182 // Unmount unmounts a filesystem
197 func Unmount(logPath, mountPoint string) error {183 func Unmount(logPath, mountPoint string) error {
198 result := RunLogged(logPath, "umount", "-R", mountPoint)184 result := RunLogged(logPath, "umount", "-R", mountPoint)
@@ -202,18 +188,6 @@ func Unmount(logPath, mountPoint string) error {
202 return nil188 return nil
203 }189 }
204 190
205-// Genfstab generates fstab for the installed system
206-func Genfstab(logPath, mountPoint, fstabPath string) error {
207- result := RunLogged(logPath, "genfstab", "-U", mountPoint)
208- if result.Error != nil {
209- return fmt.Errorf("failed to generate fstab: %w", result.Error)
210- }
211-
212- // TODO: Write result.Output to fstabPath
213- // This should be done using file operations
214- return nil
215-}
216-
217 // DefaultChrootSession represents a persistent chroot session191 // DefaultChrootSession represents a persistent chroot session
218 type DefaultChrootSession struct {192 type DefaultChrootSession struct {
219 cmd *exec.Cmd193 cmd *exec.Cmd
M internal/system/disk.go +6 -85
@@ -3,7 +3,6 @@ package system
33 import (
44 "encoding/json"
55 "fmt"
6- "strings"
76 )
87
98 // Disk represents a storage device
@@ -17,28 +16,15 @@ type Disk struct {
1716 Path string `json:"path"`
1817 }
1918
20-// Partition represents a disk partition
21-type Partition struct {
22- Name string `json:"name"`
23- Size string `json:"size"`
24- Type string `json:"type"`
25- Mountpoint string `json:"mountpoint"`
26- UUID string `json:"uuid"`
27- Label string `json:"label"`
28-}
29-
3019 // lsblkOutput represents the JSON output from lsblk
3120 type lsblkOutput struct {
3221 BlockDevices []struct {
33- Name string `json:"name"`
34- Size string `json:"size"`
35- Type string `json:"type"`
36- Model string `json:"model"`
37- Serial string `json:"serial"`
38- Vendor string `json:"vendor"`
39- Mountpoint string `json:"mountpoint"`
40- UUID string `json:"uuid"`
41- Label string `json:"label"`
22+ Name string `json:"name"`
23+ Size string `json:"size"`
24+ Type string `json:"type"`
25+ Model string `json:"model"`
26+ Serial string `json:"serial"`
27+ Vendor string `json:"vendor"`
4228 } `json:"blockdevices"`
4329 }
4430
@@ -71,68 +57,3 @@ func ListDisks() ([]Disk, error) {
7157
7258 return disks, nil
7359 }
74-
75-// ListPartitions returns a list of partitions for a given disk
76-func ListPartitions(diskPath string) ([]Partition, error) {
77- diskName := strings.TrimPrefix(diskPath, "/dev/")
78-
79- result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,UUID,LABEL", diskName)
80- if result.Error != nil {
81- return nil, fmt.Errorf("failed to list partitions: %w", result.Error)
82- }
83-
84- var output lsblkOutput
85- if err := json.Unmarshal([]byte(result.Output), &output); err != nil {
86- return nil, fmt.Errorf("failed to parse lsblk output: %w", err)
87- }
88-
89- var partitions []Partition
90- for _, dev := range output.BlockDevices {
91- if dev.Type == "part" {
92- partitions = append(partitions, Partition{
93- Name: dev.Name,
94- Size: dev.Size,
95- Type: dev.Type,
96- Mountpoint: dev.Mountpoint,
97- UUID: dev.UUID,
98- Label: dev.Label,
99- })
100- }
101- }
102-
103- return partitions, nil
104-}
105-
106-// IsDiskMounted checks if any partition on a disk is mounted
107-func IsDiskMounted(diskPath string) (bool, error) {
108- partitions, err := ListPartitions(diskPath)
109- if err != nil {
110- return false, err
111- }
112-
113- for _, part := range partitions {
114- if part.Mountpoint != "" {
115- return true, nil
116- }
117- }
118-
119- return false, nil
120-}
121-
122-// WipeDisk wipes the partition table of a disk
123-func WipeDisk(diskPath string) error {
124- result := RunSimple("wipefs", "-a", diskPath)
125- if result.Error != nil {
126- return fmt.Errorf("failed to wipe disk: %w", result.Error)
127- }
128- return nil
129-}
130-
131-// CreateGPTPartitionTable creates a new GPT partition table
132-func CreateGPTPartitionTable(diskPath string) error {
133- result := RunSimple("parted", "-s", diskPath, "mklabel", "gpt")
134- if result.Error != nil {
135- return fmt.Errorf("failed to create GPT partition table: %w", result.Error)
136- }
137- return nil
138-}
@@ -3,7 +3,6 @@ package system
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6- "strings"
7 )6 )
8 7
9 // Disk represents a storage device8 // Disk represents a storage device
@@ -17,28 +16,15 @@ type Disk struct {
17 Path string `json:"path"`16 Path string `json:"path"`
18 }17 }
19 18
20-// Partition represents a disk partition
21-type Partition struct {
22- Name string `json:"name"`
23- Size string `json:"size"`
24- Type string `json:"type"`
25- Mountpoint string `json:"mountpoint"`
26- UUID string `json:"uuid"`
27- Label string `json:"label"`
28-}
29-
30 // lsblkOutput represents the JSON output from lsblk19 // lsblkOutput represents the JSON output from lsblk
31 type lsblkOutput struct {20 type lsblkOutput struct {
32 BlockDevices []struct {21 BlockDevices []struct {
33- Name string `json:"name"`22+ Name string `json:"name"`
34- Size string `json:"size"`23+ Size string `json:"size"`
35- Type string `json:"type"`24+ Type string `json:"type"`
36- Model string `json:"model"`25+ Model string `json:"model"`
37- Serial string `json:"serial"`26+ Serial string `json:"serial"`
38- Vendor string `json:"vendor"`27+ Vendor string `json:"vendor"`
39- Mountpoint string `json:"mountpoint"`
40- UUID string `json:"uuid"`
41- Label string `json:"label"`
42 } `json:"blockdevices"`28 } `json:"blockdevices"`
43 }29 }
44 30
@@ -71,68 +57,3 @@ func ListDisks() ([]Disk, error) {
71 57
72 return disks, nil58 return disks, nil
73 }59 }
74-
75-// ListPartitions returns a list of partitions for a given disk
76-func ListPartitions(diskPath string) ([]Partition, error) {
77- diskName := strings.TrimPrefix(diskPath, "/dev/")
78-
79- result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,UUID,LABEL", diskName)
80- if result.Error != nil {
81- return nil, fmt.Errorf("failed to list partitions: %w", result.Error)
82- }
83-
84- var output lsblkOutput
85- if err := json.Unmarshal([]byte(result.Output), &output); err != nil {
86- return nil, fmt.Errorf("failed to parse lsblk output: %w", err)
87- }
88-
89- var partitions []Partition
90- for _, dev := range output.BlockDevices {
91- if dev.Type == "part" {
92- partitions = append(partitions, Partition{
93- Name: dev.Name,
94- Size: dev.Size,
95- Type: dev.Type,
96- Mountpoint: dev.Mountpoint,
97- UUID: dev.UUID,
98- Label: dev.Label,
99- })
100- }
101- }
102-
103- return partitions, nil
104-}
105-
106-// IsDiskMounted checks if any partition on a disk is mounted
107-func IsDiskMounted(diskPath string) (bool, error) {
108- partitions, err := ListPartitions(diskPath)
109- if err != nil {
110- return false, err
111- }
112-
113- for _, part := range partitions {
114- if part.Mountpoint != "" {
115- return true, nil
116- }
117- }
118-
119- return false, nil
120-}
121-
122-// WipeDisk wipes the partition table of a disk
123-func WipeDisk(diskPath string) error {
124- result := RunSimple("wipefs", "-a", diskPath)
125- if result.Error != nil {
126- return fmt.Errorf("failed to wipe disk: %w", result.Error)
127- }
128- return nil
129-}
130-
131-// CreateGPTPartitionTable creates a new GPT partition table
132-func CreateGPTPartitionTable(diskPath string) error {
133- result := RunSimple("parted", "-s", diskPath, "mklabel", "gpt")
134- if result.Error != nil {
135- return fmt.Errorf("failed to create GPT partition table: %w", result.Error)
136- }
137- return nil
138-}
M internal/system/exec.go +0 -46
@@ -4,7 +4,6 @@ package system
44 import (
55 "bufio"
66 "fmt"
7- "io"
87 "os"
98 "os/exec"
109 "strings"
@@ -157,48 +156,3 @@ func RunLogged(logPath, command string, args ...string) CommandResult {
157156 LogPath: logPath,
158157 })
159158 }
160-
161-// RunWithOutput runs a command and captures output in real-time via a callback
162-func RunWithOutput(command string, args []string, callback func(line string)) error {
163- cmd := exec.Command(command, args...)
164-
165- stdout, err := cmd.StdoutPipe()
166- if err != nil {
167- return fmt.Errorf("failed to create stdout pipe: %w", err)
168- }
169-
170- stderr, err := cmd.StderrPipe()
171- if err != nil {
172- return fmt.Errorf("failed to create stderr pipe: %w", err)
173- }
174-
175- if err := cmd.Start(); err != nil {
176- return fmt.Errorf("failed to start command: %w", err)
177- }
178-
179- var wg sync.WaitGroup
180-
181- // Process stdout
182- wg.Add(1)
183- go func() {
184- defer wg.Done()
185- processOutput(stdout, callback)
186- }()
187-
188- // Process stderr
189- wg.Add(1)
190- go func() {
191- defer wg.Done()
192- processOutput(stderr, callback)
193- }()
194-
195- wg.Wait()
196- return cmd.Wait()
197-}
198-
199-func processOutput(reader io.Reader, callback func(string)) {
200- scanner := bufio.NewScanner(reader)
201- for scanner.Scan() {
202- callback(scanner.Text())
203- }
204-}
@@ -4,7 +4,6 @@ package system
4 import (4 import (
5 "bufio"5 "bufio"
6 "fmt"6 "fmt"
7- "io"
8 "os"7 "os"
9 "os/exec"8 "os/exec"
10 "strings"9 "strings"
@@ -157,48 +156,3 @@ func RunLogged(logPath, command string, args ...string) CommandResult {
157 LogPath: logPath,156 LogPath: logPath,
158 })157 })
159 }158 }
160-
161-// RunWithOutput runs a command and captures output in real-time via a callback
162-func RunWithOutput(command string, args []string, callback func(line string)) error {
163- cmd := exec.Command(command, args...)
164-
165- stdout, err := cmd.StdoutPipe()
166- if err != nil {
167- return fmt.Errorf("failed to create stdout pipe: %w", err)
168- }
169-
170- stderr, err := cmd.StderrPipe()
171- if err != nil {
172- return fmt.Errorf("failed to create stderr pipe: %w", err)
173- }
174-
175- if err := cmd.Start(); err != nil {
176- return fmt.Errorf("failed to start command: %w", err)
177- }
178-
179- var wg sync.WaitGroup
180-
181- // Process stdout
182- wg.Add(1)
183- go func() {
184- defer wg.Done()
185- processOutput(stdout, callback)
186- }()
187-
188- // Process stderr
189- wg.Add(1)
190- go func() {
191- defer wg.Done()
192- processOutput(stderr, callback)
193- }()
194-
195- wg.Wait()
196- return cmd.Wait()
197-}
198-
199-func processOutput(reader io.Reader, callback func(string)) {
200- scanner := bufio.NewScanner(reader)
201- for scanner.Scan() {
202- callback(scanner.Text())
203- }
204-}
D internal/system/mirrorlist.go +0 -146
@@ -1,146 +0,0 @@
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-
34- // Check HTTP response status
35- if resp.StatusCode != http.StatusOK {
36- if closeErr := resp.Body.Close(); closeErr != nil {
37- return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
38- }
39- return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
40- }
41-
42- // Parse the mirrorlist content
43- mirrors, err := ParseMirrorlist(resp.Body)
44- if err != nil {
45- if closeErr := resp.Body.Close(); closeErr != nil {
46- return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
47- }
48- return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
49- }
50-
51- if err := resp.Body.Close(); err != nil {
52- return nil, fmt.Errorf("failed to close mirrorlist response: %w", err)
53- }
54-
55- return mirrors, nil
56-}
57-
58-// ParseMirrorlist parses a pacman mirrorlist format
59-// Handles lines like:
60-//
61-// Server = https://mirror.example.com/$repo/$arch
62-// #Server = https://disabled.mirror.com/$repo/$arch
63-func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) {
64- var mirrors []MirrorlistEntry
65- scanner := bufio.NewScanner(r)
66-
67- for scanner.Scan() {
68- line := strings.TrimSpace(scanner.Text())
69-
70- // Skip empty lines and non-Server lines
71- if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) {
72- continue
73- }
74-
75- enabled := !strings.HasPrefix(line, "#")
76-
77- // Remove comment prefix if present
78- if !enabled {
79- line = strings.TrimPrefix(line, "#")
80- line = strings.TrimSpace(line)
81- }
82-
83- // Parse "Server = URL" format
84- parts := strings.SplitN(line, "=", 2)
85- if len(parts) != 2 {
86- continue
87- }
88-
89- url := strings.TrimSpace(parts[1])
90- if url == "" {
91- continue
92- }
93-
94- mirrors = append(mirrors, MirrorlistEntry{
95- URL: url,
96- Enabled: enabled,
97- })
98- }
99-
100- if err := scanner.Err(); err != nil {
101- return nil, fmt.Errorf("error parsing mirrorlist: %w", err)
102- }
103-
104- if len(mirrors) == 0 {
105- return nil, fmt.Errorf("no mirrors found in mirrorlist")
106- }
107-
108- return mirrors, nil
109-}
110-
111-// BuildPackageURLs converts mirror templates to actual package URLs
112-// Replaces $repo and $arch with actual values, appends package filename
113-func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string {
114- const (
115- repo = "chaotic-aur"
116- arch = "x86_64"
117- )
118-
119- var urls []string
120-
121- // First pass: enabled mirrors (if requested)
122- if preferEnabled {
123- for _, mirror := range mirrors {
124- if !mirror.Enabled {
125- continue
126- }
127- url := strings.ReplaceAll(mirror.URL, "$repo", repo)
128- url = strings.ReplaceAll(url, "$arch", arch)
129- url = strings.TrimSuffix(url, "/") + "/" + packageName
130- urls = append(urls, url)
131- }
132- }
133-
134- // Second pass: all mirrors (fallback)
135- for _, mirror := range mirrors {
136- if preferEnabled && mirror.Enabled {
137- continue // Already added in first pass
138- }
139- url := strings.ReplaceAll(mirror.URL, "$repo", repo)
140- url = strings.ReplaceAll(url, "$arch", arch)
141- url = strings.TrimSuffix(url, "/") + "/" + packageName
142- urls = append(urls, url)
143- }
144-
145- return urls
146-}
@@ -1,146 +0,0 @@
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-
34- // Check HTTP response status
35- if resp.StatusCode != http.StatusOK {
36- if closeErr := resp.Body.Close(); closeErr != nil {
37- return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
38- }
39- return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode))
40- }
41-
42- // Parse the mirrorlist content
43- mirrors, err := ParseMirrorlist(resp.Body)
44- if err != nil {
45- if closeErr := resp.Body.Close(); closeErr != nil {
46- return nil, fmt.Errorf("failed to close mirrorlist response: %w", closeErr)
47- }
48- return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err)
49- }
50-
51- if err := resp.Body.Close(); err != nil {
52- return nil, fmt.Errorf("failed to close mirrorlist response: %w", err)
53- }
54-
55- return mirrors, nil
56-}
57-
58-// ParseMirrorlist parses a pacman mirrorlist format
59-// Handles lines like:
60-//
61-// Server = https://mirror.example.com/$repo/$arch
62-// #Server = https://disabled.mirror.com/$repo/$arch
63-func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) {
64- var mirrors []MirrorlistEntry
65- scanner := bufio.NewScanner(r)
66-
67- for scanner.Scan() {
68- line := strings.TrimSpace(scanner.Text())
69-
70- // Skip empty lines and non-Server lines
71- if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) {
72- continue
73- }
74-
75- enabled := !strings.HasPrefix(line, "#")
76-
77- // Remove comment prefix if present
78- if !enabled {
79- line = strings.TrimPrefix(line, "#")
80- line = strings.TrimSpace(line)
81- }
82-
83- // Parse "Server = URL" format
84- parts := strings.SplitN(line, "=", 2)
85- if len(parts) != 2 {
86- continue
87- }
88-
89- url := strings.TrimSpace(parts[1])
90- if url == "" {
91- continue
92- }
93-
94- mirrors = append(mirrors, MirrorlistEntry{
95- URL: url,
96- Enabled: enabled,
97- })
98- }
99-
100- if err := scanner.Err(); err != nil {
101- return nil, fmt.Errorf("error parsing mirrorlist: %w", err)
102- }
103-
104- if len(mirrors) == 0 {
105- return nil, fmt.Errorf("no mirrors found in mirrorlist")
106- }
107-
108- return mirrors, nil
109-}
110-
111-// BuildPackageURLs converts mirror templates to actual package URLs
112-// Replaces $repo and $arch with actual values, appends package filename
113-func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string {
114- const (
115- repo = "chaotic-aur"
116- arch = "x86_64"
117- )
118-
119- var urls []string
120-
121- // First pass: enabled mirrors (if requested)
122- if preferEnabled {
123- for _, mirror := range mirrors {
124- if !mirror.Enabled {
125- continue
126- }
127- url := strings.ReplaceAll(mirror.URL, "$repo", repo)
128- url = strings.ReplaceAll(url, "$arch", arch)
129- url = strings.TrimSuffix(url, "/") + "/" + packageName
130- urls = append(urls, url)
131- }
132- }
133-
134- // Second pass: all mirrors (fallback)
135- for _, mirror := range mirrors {
136- if preferEnabled && mirror.Enabled {
137- continue // Already added in first pass
138- }
139- url := strings.ReplaceAll(mirror.URL, "$repo", repo)
140- url = strings.ReplaceAll(url, "$arch", arch)
141- url = strings.TrimSuffix(url, "/") + "/" + packageName
142- urls = append(urls, url)
143- }
144-
145- return urls
146-}
D internal/system/mirrorlist_test.go +0 -287
@@ -1,287 +0,0 @@
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-}
@@ -1,287 +0,0 @@
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-}
D internal/system/network.go +0 -36
@@ -1,36 +0,0 @@
1-package system
2-
3-import (
4- "fmt"
5- "net/http"
6- "time"
7-)
8-
9-const (
10- NetworkCheckURL = "https://raw.githubusercontent.com"
11- NetworkCheckTimeout = 5 * time.Second
12-)
13-
14-// CheckNetworkConnectivity verifies internet connectivity
15-// Returns nil if network is available, error otherwise
16-func CheckNetworkConnectivity() error {
17- client := &http.Client{Timeout: NetworkCheckTimeout}
18-
19- resp, err := client.Get(NetworkCheckURL)
20- if err != nil {
21- return fmt.Errorf("network connectivity check failed: %w", err)
22- }
23-
24- if resp.StatusCode != http.StatusOK {
25- if err := resp.Body.Close(); err != nil {
26- return fmt.Errorf("failed to close network response: %w", err)
27- }
28- return fmt.Errorf("network check returned HTTP %d", resp.StatusCode)
29- }
30-
31- if err := resp.Body.Close(); err != nil {
32- return fmt.Errorf("failed to close network response: %w", err)
33- }
34-
35- return nil
36-}
@@ -1,36 +0,0 @@
1-package system
2-
3-import (
4- "fmt"
5- "net/http"
6- "time"
7-)
8-
9-const (
10- NetworkCheckURL = "https://raw.githubusercontent.com"
11- NetworkCheckTimeout = 5 * time.Second
12-)
13-
14-// CheckNetworkConnectivity verifies internet connectivity
15-// Returns nil if network is available, error otherwise
16-func CheckNetworkConnectivity() error {
17- client := &http.Client{Timeout: NetworkCheckTimeout}
18-
19- resp, err := client.Get(NetworkCheckURL)
20- if err != nil {
21- return fmt.Errorf("network connectivity check failed: %w", err)
22- }
23-
24- if resp.StatusCode != http.StatusOK {
25- if err := resp.Body.Close(); err != nil {
26- return fmt.Errorf("failed to close network response: %w", err)
27- }
28- return fmt.Errorf("network check returned HTTP %d", resp.StatusCode)
29- }
30-
31- if err := resp.Body.Close(); err != nil {
32- return fmt.Errorf("failed to close network response: %w", err)
33- }
34-
35- return nil
36-}
M internal/system/pacman.go +5 -50
@@ -1,60 +1,17 @@
11 package system
22
3-import (
4- "fmt"
5-)
3+import "fmt"
64
7-// PacmanSync syncs the package database
8-func PacmanSync(logPath string) error {
9- result := RunLogged(logPath, "pacman", "-Sy", "--noconfirm")
10- if result.Error != nil {
11- return fmt.Errorf("failed to sync pacman database: %w", result.Error)
12- }
13- return nil
14-}
15-
16-// PacmanInstall installs packages
17-func PacmanInstall(logPath string, packages ...string) error {
18- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
19- result := RunLogged(logPath, "pacman", args...)
20- if result.Error != nil {
21- return fmt.Errorf("failed to install packages: %w", result.Error)
22- }
23- return nil
24-}
25-
26-// Pacstrap installs the base system to a mount point
27-func Pacstrap(logPath, mountPoint string, packages ...string) error {
28- args := append([]string{mountPoint}, packages...)
29- result := RunLogged(logPath, "pacstrap", args...)
30- if result.Error != nil {
31- return fmt.Errorf("failed to run pacstrap: %w", result.Error)
32- }
33- return nil
34-}
35-
36-// PacmanRemove removes packages
37-func PacmanRemove(logPath string, packages ...string) error {
38- args := append([]string{"-R", "--noconfirm"}, packages...)
39- result := RunLogged(logPath, "pacman", args...)
40- if result.Error != nil {
41- return fmt.Errorf("failed to remove packages: %w", result.Error)
42- }
43- return nil
44-}
45-
46-// DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot
47-// Supports mirror fallbacks: URLs with the same package name but different mirrors
5+// DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot.
6+// Supports mirror fallbacks: URLs with the same package name but different mirrors.
487 func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
498 if len(urls) == 0 {
509 return fmt.Errorf("no URLs provided")
5110 }
5211
5312 // Group URLs by package (same filename = same package, different mirrors)
54- // This allows fallback to alternative mirrors if primary fails
5513 packageMirrors := make(map[string][]string)
5614 for _, url := range urls {
57- // Extract package name from URL
5815 var pkgName string
5916 for i := len(url) - 1; i >= 0; i-- {
6017 if url[i] == '/' {
@@ -65,11 +22,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
6522 packageMirrors[pkgName] = append(packageMirrors[pkgName], url)
6623 }
6724
68- // Download with fallback: try each mirror for each package
25+ // Build command with mirror fallback per package
6926 cmd := "mkdir -p /tmp && "
7027 for i, pkgName := range getSortedKeys(packageMirrors) {
7128 mirrors := packageMirrors[pkgName]
72- // Try primary mirror first, fallback to others if it fails
7329 cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0])
7430 for _, mirror := range mirrors[1:] {
7531 cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror)
@@ -89,13 +45,12 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
8945 return nil
9046 }
9147
92-// getSortedKeys returns map keys in a stable order (important for reproducibility)
48+// getSortedKeys returns map keys in stable alphabetical order
9349 func getSortedKeys(m map[string][]string) []string {
9450 keys := make([]string, 0, len(m))
9551 for k := range m {
9652 keys = append(keys, k)
9753 }
98- // Simple stable sort (not alphabetical, but consistent)
9954 for i := 0; i < len(keys); i++ {
10055 for j := i + 1; j < len(keys); j++ {
10156 if keys[i] > keys[j] {
@@ -1,60 +1,17 @@
1 package system1 package system
2 2
3-import (3+import "fmt"
4- "fmt"
5-)
6 4
7-// PacmanSync syncs the package database5+// DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot.
8-func PacmanSync(logPath string) error {6+// Supports mirror fallbacks: URLs with the same package name but different mirrors.
9- result := RunLogged(logPath, "pacman", "-Sy", "--noconfirm")
10- if result.Error != nil {
11- return fmt.Errorf("failed to sync pacman database: %w", result.Error)
12- }
13- return nil
14-}
15-
16-// PacmanInstall installs packages
17-func PacmanInstall(logPath string, packages ...string) error {
18- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
19- result := RunLogged(logPath, "pacman", args...)
20- if result.Error != nil {
21- return fmt.Errorf("failed to install packages: %w", result.Error)
22- }
23- return nil
24-}
25-
26-// Pacstrap installs the base system to a mount point
27-func Pacstrap(logPath, mountPoint string, packages ...string) error {
28- args := append([]string{mountPoint}, packages...)
29- result := RunLogged(logPath, "pacstrap", args...)
30- if result.Error != nil {
31- return fmt.Errorf("failed to run pacstrap: %w", result.Error)
32- }
33- return nil
34-}
35-
36-// PacmanRemove removes packages
37-func PacmanRemove(logPath string, packages ...string) error {
38- args := append([]string{"-R", "--noconfirm"}, packages...)
39- result := RunLogged(logPath, "pacman", args...)
40- if result.Error != nil {
41- return fmt.Errorf("failed to remove packages: %w", result.Error)
42- }
43- return nil
44-}
45-
46-// DownloadAndInstallPackages downloads packages from URLs using curl and installs them in chroot
47-// Supports mirror fallbacks: URLs with the same package name but different mirrors
48 func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {7 func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
49 if len(urls) == 0 {8 if len(urls) == 0 {
50 return fmt.Errorf("no URLs provided")9 return fmt.Errorf("no URLs provided")
51 }10 }
52 11
53 // Group URLs by package (same filename = same package, different mirrors)12 // Group URLs by package (same filename = same package, different mirrors)
54- // This allows fallback to alternative mirrors if primary fails
55 packageMirrors := make(map[string][]string)13 packageMirrors := make(map[string][]string)
56 for _, url := range urls {14 for _, url := range urls {
57- // Extract package name from URL
58 var pkgName string15 var pkgName string
59 for i := len(url) - 1; i >= 0; i-- {16 for i := len(url) - 1; i >= 0; i-- {
60 if url[i] == '/' {17 if url[i] == '/' {
@@ -65,11 +22,10 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
65 packageMirrors[pkgName] = append(packageMirrors[pkgName], url)22 packageMirrors[pkgName] = append(packageMirrors[pkgName], url)
66 }23 }
67 24
68- // Download with fallback: try each mirror for each package25+ // Build command with mirror fallback per package
69 cmd := "mkdir -p /tmp && "26 cmd := "mkdir -p /tmp && "
70 for i, pkgName := range getSortedKeys(packageMirrors) {27 for i, pkgName := range getSortedKeys(packageMirrors) {
71 mirrors := packageMirrors[pkgName]28 mirrors := packageMirrors[pkgName]
72- // Try primary mirror first, fallback to others if it fails
73 cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0])29 cmd += fmt.Sprintf("(curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirrors[0])
74 for _, mirror := range mirrors[1:] {30 for _, mirror := range mirrors[1:] {
75 cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror)31 cmd += fmt.Sprintf(" || curl -L -f -o /tmp/pkg%d.tar.zst '%s'", i, mirror)
@@ -89,13 +45,12 @@ func DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) erro
89 return nil45 return nil
90 }46 }
91 47
92-// getSortedKeys returns map keys in a stable order (important for reproducibility)48+// getSortedKeys returns map keys in stable alphabetical order
93 func getSortedKeys(m map[string][]string) []string {49 func getSortedKeys(m map[string][]string) []string {
94 keys := make([]string, 0, len(m))50 keys := make([]string, 0, len(m))
95 for k := range m {51 for k := range m {
96 keys = append(keys, k)52 keys = append(keys, k)
97 }53 }
98- // Simple stable sort (not alphabetical, but consistent)
99 for i := 0; i < len(keys); i++ {54 for i := 0; i < len(keys); i++ {
100 for j := i + 1; j < len(keys); j++ {55 for j := i + 1; j < len(keys); j++ {
101 if keys[i] > keys[j] {56 if keys[i] > keys[j] {
D internal/system/system.go +0 -17
@@ -1,17 +0,0 @@
1-package system
2-
3-import (
4- "log/slog"
5-)
6-
7-// System provides system-level operations with logging support
8-type System struct {
9- log *slog.Logger
10-}
11-
12-// NewSystem creates a new System instance with the provided logger
13-func NewSystem(log *slog.Logger) *System {
14- return &System{
15- log: log,
16- }
17-}
@@ -1,17 +0,0 @@
1-package system
2-
3-import (
4- "log/slog"
5-)
6-
7-// System provides system-level operations with logging support
8-type System struct {
9- log *slog.Logger
10-}
11-
12-// NewSystem creates a new System instance with the provided logger
13-func NewSystem(log *slog.Logger) *System {
14- return &System{
15- log: log,
16- }
17-}
M internal/system/timezone.go +4 -114
@@ -1,7 +1,6 @@
11 package system
22
33 import (
4- "fmt"
54 "io"
65 "net/http"
76 "strings"
@@ -9,121 +8,12 @@ import (
98 )
109
1110 const (
12- timezoneAPIURL = "https://ipapi.co/timezone/"
13- timezoneAPITimeout = 5 * time.Second
14- timezoneRetryAttempt = 3
15- timezoneRetryDelay = 1 * time.Second
11+ timezoneAPIURL = "https://ipapi.co/timezone/"
12+ timezoneAPITimeout = 5 * time.Second
1613 )
1714
18-// DetectTimezone fetches timezone from ipapi.co API with retry logic
19-// Attempts up to 3 times with exponential backoff before falling back
20-// Returns detected timezone string or empty string on failure
21-// Logs details to the system logger
22-func (s *System) DetectTimezone() string {
23- var lastErr error
24-
25- for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ {
26- if attempt > 1 {
27- // Log retry attempt
28- s.log.Debug("Timezone detection retry",
29- "attempt", attempt,
30- "max_attempts", timezoneRetryAttempt,
31- "delay", timezoneRetryDelay.String())
32- time.Sleep(timezoneRetryDelay)
33- }
34-
35- client := &http.Client{
36- Timeout: timezoneAPITimeout,
37- }
38-
39- resp, err := client.Get(timezoneAPIURL)
40- if err != nil {
41- lastErr = fmt.Errorf("http request failed: %w", err)
42- s.log.Debug("Timezone detection failed",
43- "attempt", attempt,
44- "error", lastErr)
45- continue
46- }
47-
48- // Check HTTP status code
49- if resp.StatusCode != http.StatusOK {
50- if closeErr := resp.Body.Close(); closeErr != nil {
51- lastErr = fmt.Errorf("failed to close response: %w", closeErr)
52- s.log.Debug("Timezone detection failed",
53- "attempt", attempt,
54- "error", lastErr)
55- continue
56- }
57- lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
58- s.log.Debug("Timezone detection failed",
59- "attempt", attempt,
60- "error", lastErr)
61- continue
62- }
63-
64- // Read response body
65- body, err := io.ReadAll(resp.Body)
66- if err != nil {
67- if closeErr := resp.Body.Close(); closeErr != nil {
68- lastErr = fmt.Errorf("failed to close response: %w", closeErr)
69- s.log.Debug("Timezone detection failed",
70- "attempt", attempt,
71- "error", lastErr)
72- continue
73- }
74- lastErr = fmt.Errorf("failed to read response: %w", err)
75- s.log.Debug("Timezone detection failed",
76- "attempt", attempt,
77- "error", lastErr)
78- continue
79- }
80-
81- if err := resp.Body.Close(); err != nil {
82- lastErr = fmt.Errorf("failed to close response: %w", err)
83- s.log.Debug("Timezone detection failed",
84- "attempt", attempt,
85- "error", lastErr)
86- continue
87- }
88-
89- timezone := strings.TrimSpace(string(body))
90-
91- // Validate timezone format (should be like "America/New_York")
92- if !strings.Contains(timezone, "/") {
93- lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone)
94- s.log.Debug("Timezone detection failed",
95- "attempt", attempt,
96- "error", lastErr)
97- continue
98- }
99-
100- // Success - log and return
101- s.log.Info("Timezone detected successfully", "timezone", timezone)
102- return timezone
103- }
104-
105- // All retries exhausted
106- s.log.Warn("Timezone detection failed after all retries",
107- "attempts", timezoneRetryAttempt,
108- "last_error", lastErr)
109- return ""
110-}
111-
112-// ValidateTimezone checks if a timezone string is valid
113-// This is a basic check - full validation would require checking against zoneinfo
114-func ValidateTimezone(tz string) bool {
115- switch {
116- case tz == "":
117- return false
118- case !strings.Contains(tz, "/"):
119- return false
120- default:
121- return true
122- }
123-}
124-
125-// DetectTimezoneSimple fetches timezone from ipapi.co API without logging
126-// Returns detected timezone string or empty string on failure
15+// DetectTimezoneSimple fetches timezone from ipapi.co API without logging.
16+// Returns detected timezone string or empty string on failure.
12717 func DetectTimezoneSimple() string {
12818 client := &http.Client{
12919 Timeout: timezoneAPITimeout,
@@ -1,7 +1,6 @@
1 package system1 package system
2 2
3 import (3 import (
4- "fmt"
5 "io"4 "io"
6 "net/http"5 "net/http"
7 "strings"6 "strings"
@@ -9,121 +8,12 @@ import (
9 )8 )
10 9
11 const (10 const (
12- timezoneAPIURL = "https://ipapi.co/timezone/"11+ timezoneAPIURL = "https://ipapi.co/timezone/"
13- timezoneAPITimeout = 5 * time.Second12+ timezoneAPITimeout = 5 * time.Second
14- timezoneRetryAttempt = 3
15- timezoneRetryDelay = 1 * time.Second
16 )13 )
17 14
18-// DetectTimezone fetches timezone from ipapi.co API with retry logic15+// DetectTimezoneSimple fetches timezone from ipapi.co API without logging.
19-// Attempts up to 3 times with exponential backoff before falling back16+// Returns detected timezone string or empty string on failure.
20-// Returns detected timezone string or empty string on failure
21-// Logs details to the system logger
22-func (s *System) DetectTimezone() string {
23- var lastErr error
24-
25- for attempt := 1; attempt <= timezoneRetryAttempt; attempt++ {
26- if attempt > 1 {
27- // Log retry attempt
28- s.log.Debug("Timezone detection retry",
29- "attempt", attempt,
30- "max_attempts", timezoneRetryAttempt,
31- "delay", timezoneRetryDelay.String())
32- time.Sleep(timezoneRetryDelay)
33- }
34-
35- client := &http.Client{
36- Timeout: timezoneAPITimeout,
37- }
38-
39- resp, err := client.Get(timezoneAPIURL)
40- if err != nil {
41- lastErr = fmt.Errorf("http request failed: %w", err)
42- s.log.Debug("Timezone detection failed",
43- "attempt", attempt,
44- "error", lastErr)
45- continue
46- }
47-
48- // Check HTTP status code
49- if resp.StatusCode != http.StatusOK {
50- if closeErr := resp.Body.Close(); closeErr != nil {
51- lastErr = fmt.Errorf("failed to close response: %w", closeErr)
52- s.log.Debug("Timezone detection failed",
53- "attempt", attempt,
54- "error", lastErr)
55- continue
56- }
57- lastErr = fmt.Errorf("API returned status %d", resp.StatusCode)
58- s.log.Debug("Timezone detection failed",
59- "attempt", attempt,
60- "error", lastErr)
61- continue
62- }
63-
64- // Read response body
65- body, err := io.ReadAll(resp.Body)
66- if err != nil {
67- if closeErr := resp.Body.Close(); closeErr != nil {
68- lastErr = fmt.Errorf("failed to close response: %w", closeErr)
69- s.log.Debug("Timezone detection failed",
70- "attempt", attempt,
71- "error", lastErr)
72- continue
73- }
74- lastErr = fmt.Errorf("failed to read response: %w", err)
75- s.log.Debug("Timezone detection failed",
76- "attempt", attempt,
77- "error", lastErr)
78- continue
79- }
80-
81- if err := resp.Body.Close(); err != nil {
82- lastErr = fmt.Errorf("failed to close response: %w", err)
83- s.log.Debug("Timezone detection failed",
84- "attempt", attempt,
85- "error", lastErr)
86- continue
87- }
88-
89- timezone := strings.TrimSpace(string(body))
90-
91- // Validate timezone format (should be like "America/New_York")
92- if !strings.Contains(timezone, "/") {
93- lastErr = fmt.Errorf("invalid timezone format: %q (expected format like America/New_York)", timezone)
94- s.log.Debug("Timezone detection failed",
95- "attempt", attempt,
96- "error", lastErr)
97- continue
98- }
99-
100- // Success - log and return
101- s.log.Info("Timezone detected successfully", "timezone", timezone)
102- return timezone
103- }
104-
105- // All retries exhausted
106- s.log.Warn("Timezone detection failed after all retries",
107- "attempts", timezoneRetryAttempt,
108- "last_error", lastErr)
109- return ""
110-}
111-
112-// ValidateTimezone checks if a timezone string is valid
113-// This is a basic check - full validation would require checking against zoneinfo
114-func ValidateTimezone(tz string) bool {
115- switch {
116- case tz == "":
117- return false
118- case !strings.Contains(tz, "/"):
119- return false
120- default:
121- return true
122- }
123-}
124-
125-// DetectTimezoneSimple fetches timezone from ipapi.co API without logging
126-// Returns detected timezone string or empty string on failure
127 func DetectTimezoneSimple() string {17 func DetectTimezoneSimple() string {
128 client := &http.Client{18 client := &http.Client{
129 Timeout: timezoneAPITimeout,19 Timeout: timezoneAPITimeout,
D internal/system/timezone_test.go +0 -37
@@ -1,37 +0,0 @@
1-package system
2-
3-import (
4- "testing"
5-)
6-
7-// TestDetectTimezoneSuccess tests successful timezone detection
8-func TestDetectTimezoneSuccess(t *testing.T) {
9- // Skip this test as it requires network and a System instance with logger
10- // A proper unit test would require refactoring DetectTimezone to accept a base URL
11- t.Skip("Skipping timezone detection test - requires System instance and network access")
12-}
13-
14-// TestValidateTimezone tests timezone validation
15-func TestValidateTimezone(t *testing.T) {
16- tests := []struct {
17- name string
18- timezone string
19- want bool
20- }{
21- {"Valid Europe timezone", "Europe/Paris", true},
22- {"Valid US timezone", "America/New_York", true},
23- {"Valid Asia timezone", "Asia/Tokyo", true},
24- {"Invalid - no slash", "UTC", false},
25- {"Invalid - empty string", "", false},
26- {"Valid - just slash", "/", true},
27- }
28-
29- for _, tt := range tests {
30- t.Run(tt.name, func(t *testing.T) {
31- got := ValidateTimezone(tt.timezone)
32- if got != tt.want {
33- t.Errorf("ValidateTimezone(%q) = %v, want %v", tt.timezone, got, tt.want)
34- }
35- })
36- }
37-}
@@ -1,37 +0,0 @@
1-package system
2-
3-import (
4- "testing"
5-)
6-
7-// TestDetectTimezoneSuccess tests successful timezone detection
8-func TestDetectTimezoneSuccess(t *testing.T) {
9- // Skip this test as it requires network and a System instance with logger
10- // A proper unit test would require refactoring DetectTimezone to accept a base URL
11- t.Skip("Skipping timezone detection test - requires System instance and network access")
12-}
13-
14-// TestValidateTimezone tests timezone validation
15-func TestValidateTimezone(t *testing.T) {
16- tests := []struct {
17- name string
18- timezone string
19- want bool
20- }{
21- {"Valid Europe timezone", "Europe/Paris", true},
22- {"Valid US timezone", "America/New_York", true},
23- {"Valid Asia timezone", "Asia/Tokyo", true},
24- {"Invalid - no slash", "UTC", false},
25- {"Invalid - empty string", "", false},
26- {"Valid - just slash", "/", true},
27- }
28-
29- for _, tt := range tests {
30- t.Run(tt.name, func(t *testing.T) {
31- got := ValidateTimezone(tt.timezone)
32- if got != tt.want {
33- t.Errorf("ValidateTimezone(%q) = %v, want %v", tt.timezone, got, tt.want)
34- }
35- })
36- }
37-}
D internal/validation/validators.go +0 -119
@@ -1,119 +0,0 @@
1-package validation
2-
3-import (
4- "errors"
5- "fmt"
6-)
7-
8-// ValidateNotEmpty validates that a string is not empty
9-func ValidateNotEmpty(field string) func(string) error {
10- return func(s string) error {
11- if s == "" {
12- return fmt.Errorf("%s cannot be empty", field)
13- }
14- return nil
15- }
16-}
17-
18-// ValidateHostname validates a hostname
19-func ValidateHostname(s string) error {
20- if s == "" {
21- return errors.New("hostname cannot be empty")
22- }
23- if len(s) > 63 {
24- return errors.New("hostname must be 63 characters or less")
25- }
26- // Basic validation
27- for _, c := range s {
28- if !isHostnameChar(c) {
29- return errors.New("hostname can only contain letters, numbers, and hyphens")
30- }
31- }
32- return nil
33-}
34-
35-// ValidateUsername validates a username
36-func ValidateUsername(s string) error {
37- if s == "" {
38- return errors.New("username cannot be empty")
39- }
40- if len(s) > 32 {
41- return errors.New("username must be 32 characters or less")
42- }
43- // Must start with lowercase letter
44- if s[0] < 'a' || s[0] > 'z' {
45- return errors.New("username must start with a lowercase letter")
46- }
47- // Can only contain lowercase letters, numbers, underscore, hyphen
48- for _, c := range s {
49- if !isUsernameChar(c) {
50- return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")
51- }
52- }
53- return nil
54-}
55-
56-// ValidatePassword validates a password (minimum length)
57-func ValidatePassword(s string) error {
58- if len(s) < 3 {
59- return errors.New("password must be at least 3 characters")
60- }
61- return nil
62-}
63-
64-// ValidateTimezone validates a timezone (basic check)
65-func ValidateTimezone(s string) error {
66- if s == "" {
67- return errors.New("timezone cannot be empty")
68- }
69- // Basic validation - should contain /
70- // More thorough validation would check against zoneinfo database
71- return nil
72-}
73-
74-// ValidateLocale validates a locale (basic check)
75-func ValidateLocale(s string) error {
76- if s == "" {
77- return errors.New("locale cannot be empty")
78- }
79- // Basic validation - should match pattern like en_US.UTF-8
80- return nil
81-}
82-
83-func isHostnameChar(c rune) bool {
84- switch {
85- case c >= 'a' && c <= 'z':
86- return true
87- case c >= 'A' && c <= 'Z':
88- return true
89- case c >= '0' && c <= '9':
90- return true
91- case c == '-':
92- return true
93- default:
94- return false
95- }
96-}
97-
98-func isUsernameChar(c rune) bool {
99- switch {
100- case c >= 'a' && c <= 'z':
101- return true
102- case c >= '0' && c <= '9':
103- return true
104- case c == '_' || c == '-':
105- return true
106- default:
107- return false
108- }
109-}
110-
111-// ValidatePasswordConfirmation validates that password and confirmation match
112-func ValidatePasswordConfirmation(password *string) func(string) error {
113- return func(confirm string) error {
114- if confirm != *password {
115- return errors.New("passwords do not match")
116- }
117- return nil
118- }
119-}
@@ -1,119 +0,0 @@
1-package validation
2-
3-import (
4- "errors"
5- "fmt"
6-)
7-
8-// ValidateNotEmpty validates that a string is not empty
9-func ValidateNotEmpty(field string) func(string) error {
10- return func(s string) error {
11- if s == "" {
12- return fmt.Errorf("%s cannot be empty", field)
13- }
14- return nil
15- }
16-}
17-
18-// ValidateHostname validates a hostname
19-func ValidateHostname(s string) error {
20- if s == "" {
21- return errors.New("hostname cannot be empty")
22- }
23- if len(s) > 63 {
24- return errors.New("hostname must be 63 characters or less")
25- }
26- // Basic validation
27- for _, c := range s {
28- if !isHostnameChar(c) {
29- return errors.New("hostname can only contain letters, numbers, and hyphens")
30- }
31- }
32- return nil
33-}
34-
35-// ValidateUsername validates a username
36-func ValidateUsername(s string) error {
37- if s == "" {
38- return errors.New("username cannot be empty")
39- }
40- if len(s) > 32 {
41- return errors.New("username must be 32 characters or less")
42- }
43- // Must start with lowercase letter
44- if s[0] < 'a' || s[0] > 'z' {
45- return errors.New("username must start with a lowercase letter")
46- }
47- // Can only contain lowercase letters, numbers, underscore, hyphen
48- for _, c := range s {
49- if !isUsernameChar(c) {
50- return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")
51- }
52- }
53- return nil
54-}
55-
56-// ValidatePassword validates a password (minimum length)
57-func ValidatePassword(s string) error {
58- if len(s) < 3 {
59- return errors.New("password must be at least 3 characters")
60- }
61- return nil
62-}
63-
64-// ValidateTimezone validates a timezone (basic check)
65-func ValidateTimezone(s string) error {
66- if s == "" {
67- return errors.New("timezone cannot be empty")
68- }
69- // Basic validation - should contain /
70- // More thorough validation would check against zoneinfo database
71- return nil
72-}
73-
74-// ValidateLocale validates a locale (basic check)
75-func ValidateLocale(s string) error {
76- if s == "" {
77- return errors.New("locale cannot be empty")
78- }
79- // Basic validation - should match pattern like en_US.UTF-8
80- return nil
81-}
82-
83-func isHostnameChar(c rune) bool {
84- switch {
85- case c >= 'a' && c <= 'z':
86- return true
87- case c >= 'A' && c <= 'Z':
88- return true
89- case c >= '0' && c <= '9':
90- return true
91- case c == '-':
92- return true
93- default:
94- return false
95- }
96-}
97-
98-func isUsernameChar(c rune) bool {
99- switch {
100- case c >= 'a' && c <= 'z':
101- return true
102- case c >= '0' && c <= '9':
103- return true
104- case c == '_' || c == '-':
105- return true
106- default:
107- return false
108- }
109-}
110-
111-// ValidatePasswordConfirmation validates that password and confirmation match
112-func ValidatePasswordConfirmation(password *string) func(string) error {
113- return func(confirm string) error {
114- if confirm != *password {
115- return errors.New("passwords do not match")
116- }
117- return nil
118- }
119-}
D internal/verify/shell.go +0 -87
@@ -1,87 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ShellConfig holds verification results for shell configuration
13-type ShellConfig struct {
14- BashrcExists bool
15- ShellFilesOK bool
16- BashrcSyntaxOK bool
17- Warnings []string
18- Errors []string
19-}
20-
21-// ValidateShellConfigs performs comprehensive shell configuration verification
22-func ValidateShellConfigs(
23- fs interfaces.FileSystem,
24- chrExec interfaces.ChrootExecutor,
25- logPath string,
26- userHome string,
27-) ShellConfig {
28- result := ShellConfig{
29- Warnings: []string{},
30- Errors: []string{},
31- }
32-
33- // Check .bashrc exists
34- bashrcPath := filepath.Join(userHome, ".bashrc")
35- _, err := fs.Stat(bashrcPath)
36- if fs.IsNotExist(err) {
37- result.BashrcExists = false
38- result.Warnings = append(result.Warnings, ".bashrc not found")
39- } else if err != nil {
40- result.Warnings = append(result.Warnings, fmt.Sprintf(".bashrc stat error: %v", err))
41- } else {
42- result.BashrcExists = true
43- }
44-
45- // Check shell config directory exists
46- archupDefaultBash := filepath.Join(userHome, ".local", "share", "archup", "default", "bash")
47- _, err = fs.Stat(archupDefaultBash)
48- if fs.IsNotExist(err) {
49- result.Warnings = append(result.Warnings, "shell config directory missing")
50- } else if err == nil {
51- result.ShellFilesOK = true
52- } else {
53- result.Warnings = append(result.Warnings, fmt.Sprintf("shell config directory stat error: %v", err))
54- }
55-
56- return result
57-}
58-
59-// ValidateBashSyntax tests bash configuration syntax by loading bash in chroot
60-func ValidateBashSyntax(
61- chrExec interfaces.ChrootExecutor,
62- logPath string,
63-) error {
64- // Test bash syntax: bash -n ~/.bashrc in chroot
65- cmd := "bash -n /root/.bashrc"
66- if err := chrExec.ChrootExec(logPath, config.PathMnt, cmd); err != nil {
67- return fmt.Errorf("bash syntax check failed: %w", err)
68- }
69- return nil
70-}
71-
72-// GetShellConfigWarnings returns formatted warning messages
73-func GetShellConfigWarnings(result ShellConfig) string {
74- if len(result.Warnings) == 0 && len(result.Errors) == 0 {
75- return ""
76- }
77-
78- var msgs []string
79- for _, w := range result.Warnings {
80- msgs = append(msgs, fmt.Sprintf("[WARN] %s", w))
81- }
82- for _, e := range result.Errors {
83- msgs = append(msgs, fmt.Sprintf("[ERROR] %s", e))
84- }
85-
86- return strings.Join(msgs, "\n")
87-}
@@ -1,87 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ShellConfig holds verification results for shell configuration
13-type ShellConfig struct {
14- BashrcExists bool
15- ShellFilesOK bool
16- BashrcSyntaxOK bool
17- Warnings []string
18- Errors []string
19-}
20-
21-// ValidateShellConfigs performs comprehensive shell configuration verification
22-func ValidateShellConfigs(
23- fs interfaces.FileSystem,
24- chrExec interfaces.ChrootExecutor,
25- logPath string,
26- userHome string,
27-) ShellConfig {
28- result := ShellConfig{
29- Warnings: []string{},
30- Errors: []string{},
31- }
32-
33- // Check .bashrc exists
34- bashrcPath := filepath.Join(userHome, ".bashrc")
35- _, err := fs.Stat(bashrcPath)
36- if fs.IsNotExist(err) {
37- result.BashrcExists = false
38- result.Warnings = append(result.Warnings, ".bashrc not found")
39- } else if err != nil {
40- result.Warnings = append(result.Warnings, fmt.Sprintf(".bashrc stat error: %v", err))
41- } else {
42- result.BashrcExists = true
43- }
44-
45- // Check shell config directory exists
46- archupDefaultBash := filepath.Join(userHome, ".local", "share", "archup", "default", "bash")
47- _, err = fs.Stat(archupDefaultBash)
48- if fs.IsNotExist(err) {
49- result.Warnings = append(result.Warnings, "shell config directory missing")
50- } else if err == nil {
51- result.ShellFilesOK = true
52- } else {
53- result.Warnings = append(result.Warnings, fmt.Sprintf("shell config directory stat error: %v", err))
54- }
55-
56- return result
57-}
58-
59-// ValidateBashSyntax tests bash configuration syntax by loading bash in chroot
60-func ValidateBashSyntax(
61- chrExec interfaces.ChrootExecutor,
62- logPath string,
63-) error {
64- // Test bash syntax: bash -n ~/.bashrc in chroot
65- cmd := "bash -n /root/.bashrc"
66- if err := chrExec.ChrootExec(logPath, config.PathMnt, cmd); err != nil {
67- return fmt.Errorf("bash syntax check failed: %w", err)
68- }
69- return nil
70-}
71-
72-// GetShellConfigWarnings returns formatted warning messages
73-func GetShellConfigWarnings(result ShellConfig) string {
74- if len(result.Warnings) == 0 && len(result.Errors) == 0 {
75- return ""
76- }
77-
78- var msgs []string
79- for _, w := range result.Warnings {
80- msgs = append(msgs, fmt.Sprintf("[WARN] %s", w))
81- }
82- for _, e := range result.Errors {
83- msgs = append(msgs, fmt.Sprintf("[ERROR] %s", e))
84- }
85-
86- return strings.Join(msgs, "\n")
87-}

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