Trim maintenance clutter and add desktop screenshots
This commit is contained in:
parent
732df73b90
commit
fd3d79f61f
19 changed files with 89 additions and 1312 deletions
191
README.md
191
README.md
|
|
@ -9,178 +9,109 @@
|
|||

|
||||

|
||||

|
||||

|
||||
|
||||
[KDE styling](docs/kde-style.md) · [Shortcuts](docs/shortcuts.md) · [Packages](#package-sections) · [Setup](docs/setup.md)
|
||||
[Setup](docs/setup.md) · [Shortcuts](docs/shortcuts.md) · [Styling](docs/kde-style.md) · [Packages](#packages)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## About
|
||||
|
||||
## Overview
|
||||
My personal Gentoo restoration kit, mainly for my own devices:
|
||||
|
||||
A personal Gentoo rice and restoration kit: my Plasma styling, current
|
||||
shortcuts, package lists and the app preferences I use every day. Built
|
||||
mainly for my own devices, with deliberate updates and a preview before
|
||||
anything is applied.
|
||||
- KDE colours, fonts, cursor, window styling and my exact keybinds.
|
||||
- Separate package lists and individually optional app configs.
|
||||
- Fish, Kitty and Neovim preferences that work across compositors.
|
||||
- Binaries preferred, with a small deliberate source-build list.
|
||||
- A Bubble Tea chooser with a final preview, conflict prompts and backups.
|
||||
|
||||
- **KDE first:** colours, fonts, cursor, window styling and current keybinds.
|
||||
- **Pick what you want:** package sections and individually optional app configs.
|
||||
- **Portable terminals:** Fish, Kitty and Neovim work across compositors.
|
||||
- **Binaries preferred:** a small source list, with a choice before extra builds.
|
||||
- **Keep your edits:** conflict prompts, backups and `.new` proposals on updates.
|
||||
Package and boot actions still need a first run on a fresh Gentoo installation.
|
||||
|
||||
**Work in progress:** configuration restoration and the TUI are ready for review.
|
||||
Package and boot execution need a supervised first run on a fresh Gentoo system.
|
||||
The existing installation has not been changed by creating this repo.
|
||||
## Screenshots
|
||||
|
||||
### Desktop
|
||||
|
||||

|
||||
|
||||
My running desktop. Wallpapers and panels stay local; the saved Fastfetch
|
||||
preset uses the built-in Gentoo logo.
|
||||
|
||||
### Installer
|
||||
|
||||

|
||||
|
||||
## Start
|
||||
|
||||
```sh
|
||||
git clone --branch v0.1.2 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
|
||||
git clone https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
|
||||
cd Fiw-Gentoo-Dots
|
||||
./install
|
||||
```
|
||||
|
||||
The Bubble Tea interface offers a preset, package sections, individual app
|
||||
configs, kernel/bootloader choices, individual Flatpaks, optional services and
|
||||
a final preview. Enter saves without applying. `r` runs the full restore;
|
||||
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
|
||||
`v` enables selected services and `b` deploys the selected bootloader.
|
||||
`e` updates configs while preserving local edits; `p` opens pending changes
|
||||
for a diff and individual acceptance. `u` opens config backups for preview
|
||||
and restoration. `n` saves a [named device selection](docs/devices.md), available
|
||||
alongside Stock and Fiw's Ryzen on the preset screen. Apply actions finish
|
||||
with a [combined report](docs/reports.md) of outcomes and remaining setup.
|
||||
Linux amd64 includes a verified prebuilt TUI; Git and Python 3.11+ are enough.
|
||||
Go 1.24+ is the source fallback for changed frontend code or other platforms.
|
||||
Saved selections are reused on subsequent launches. See the
|
||||
[snapshot and build instructions](docs/releases.md).
|
||||
Start from an installed Gentoo system. Linux amd64 includes a prebuilt TUI;
|
||||
Git and Python 3.11+ are enough. Go 1.24+ is needed only for source builds.
|
||||
Choose your packages/configs, review the plan, then select an action.
|
||||
Enter saves your choices without applying them.
|
||||
|
||||
Forgejo is the primary repository; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots)
|
||||
receives branches and tags through the [CI mirror workflow](docs/ci.md) after
|
||||
the checks pass.
|
||||
|
||||
For a preview without building the TUI:
|
||||
|
||||
```sh
|
||||
./install --plan --profile stock
|
||||
./install --plan --profile fiw-ryzen
|
||||
```
|
||||
|
||||
See [setup](docs/setup.md) for installation steps and draft limitations.
|
||||
See [setup and TUI keys](docs/setup.md), [tagged snapshots](docs/releases.md),
|
||||
[saved devices](docs/devices.md), [config updates](docs/updates.md) and
|
||||
[backup restoration](docs/backups.md).
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Build settings | Kernel |
|
||||
|---|---|---|
|
||||
| Stock | Portable compiler settings; binary packages preferred | Generic Gentoo binary kernel |
|
||||
| Fiw's Ryzen | Current Ryzen 9900X/Zen 5 tuning and NVIDIA configuration | Tested custom kernel plus generic binary fallback |
|
||||
| Stock | Portable settings, binaries preferred | Generic Gentoo binary kernel |
|
||||
| Fiw's Ryzen | Ryzen 9900X / Zen 5 tuning and NVIDIA | Custom kernel + binary fallback |
|
||||
|
||||
Both deliberately compile Fastfetch, jq and zip. Custom kernels and their
|
||||
matching external modules compile when selected. Other packages prefer
|
||||
Portage binaries or upstream `-bin` packages. Additional source builds
|
||||
require a compile-or-skip choice, with compilation recommended; skipped
|
||||
dependencies also cause dependent requested applications to be skipped.
|
||||
Both compile Fastfetch, jq and zip. Selected custom kernels and modules also
|
||||
compile. Other source builds require a compile-or-skip choice; missing/skipped
|
||||
packages appear in the [final report](docs/reports.md).
|
||||
|
||||
The custom kernel snapshot currently targets 7.2.8 with the tested
|
||||
CachyOS 7.2.7-1 patch. Refresh it explicitly when updating the snapshot.
|
||||
|
||||
## Package sections
|
||||
## Packages
|
||||
|
||||
| List | Contents |
|
||||
|---|---|
|
||||
| [kde](packages/kde.list) | Plasma, Plasma Login Manager, desktop components and supporting fonts |
|
||||
| [fiw-apps](packages/fiw-apps.list) | Everyday apps including Dolphin, Ark, Gwenview and Spectacle |
|
||||
| [cli](packages/cli.list) | Shell, terminal utilities and FFmpeg |
|
||||
| [kde](packages/kde.list) | Plasma, Plasma Login Manager and desktop components |
|
||||
| [fiw-apps](packages/fiw-apps.list) | Everyday apps, including Dolphin, Ark, Gwenview and Spectacle |
|
||||
| [cli](packages/cli.list) | Shell and terminal utilities |
|
||||
| [dev](packages/dev.list) | Languages and development tools |
|
||||
| [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman; Sober is in flatpaks/gaming.list |
|
||||
| [system](packages/system.list) | System utilities; the selected kernels are added by the installer |
|
||||
| [fiw-tools](packages/fiw-tools.list) | FiwNode binary release, Apdatifier Gentoo, OpenDeck and Music Presence |
|
||||
| [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
|
||||
| [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman |
|
||||
| [system](packages/system.list) | System utilities; kernels follow your selection |
|
||||
| [fiw-tools](packages/fiw-tools.list) | FiwNode, Apdatifier Gentoo, OpenDeck and Music Presence |
|
||||
| [tidewm](packages/tidewm.list) | Optional compositor and supporting tools |
|
||||
|
||||
Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
|
||||
require Btrfs tools. LocalSend and Sober are individually optional Flatpaks
|
||||
in the TUI, grouped with apps and gaming respectively.
|
||||
Limine and GRUB are optional alternatives; retaining the current loader is
|
||||
the default. Kernel choice is independent of bootloader choice. Deployment
|
||||
previews the target ESP and firmware choices; see [boot setup](docs/boot.md).
|
||||
The [package audit](docs/package-audit.md) accounts for all current explicit
|
||||
selections. [TideWM](docs/tidewm.md) remains an optional source build.
|
||||
Filelight, NVIDIA and Btrfs tools are separate extras. Sober and LocalSend are
|
||||
individually optional Flatpaks. Limine and GRUB are optional; keeping the
|
||||
existing bootloader is the default. See [boot setup](docs/boot.md).
|
||||
|
||||
## Configs
|
||||
|
||||
Every config is independently selectable, even when its package section is
|
||||
unchecked—for example, to configure apps already installed. KDE appearance covers
|
||||
colours, fonts, cursor and window decorations, with matching GTK preferences.
|
||||
Dolphin styling has its own checkbox. Panels, widgets and
|
||||
wallpapers are excluded. All portable current shortcut assignments,
|
||||
including disabled defaults, are captured with their launchers and helpers;
|
||||
unused machine-specific activity IDs are omitted.
|
||||
Every [app config](docs/app-configs.md) is independently optional. KDE styling
|
||||
includes matching GTK preferences; autostart has its own checkbox.
|
||||
Neovim's Catppuccin theme, VS Code's ayu MiDas theme, fonts and cursor assets
|
||||
are bundled with their licences. Accounts and sessions stay local.
|
||||
|
||||
Fish, Kitty and Neovim are usable without KDE or KWin. Fastfetch keeps the
|
||||
tree layout and purple colours with the built-in Gentoo logo. MangoHud and
|
||||
Vesktop preferences are included; browser profiles, chat sessions and account
|
||||
data are excluded. App autostart is a separate optional selection.
|
||||
Neovim's Catppuccin and VS Code's ayu MiDas themes are bundled with their
|
||||
respective optional configs, so their styling is available on fresh devices.
|
||||
Ark, Gwenview and Prism preferences are individually optional too; see
|
||||
[app configs and requirements](docs/app-configs.md). Each config's required
|
||||
packages appear in the final preview without changing package selections.
|
||||
|
||||
Spotify is stock by default. The existing customization scripts remain an
|
||||
[explicit optional step](docs/spotify.md). [Firefox-Privacy](docs/firefox.md)
|
||||
is also optional.
|
||||
|
||||
Existing differences prompt apply-or-keep and get backups when replaced.
|
||||
Updates leave locally edited configs intact and write the proposal to `.new`.
|
||||
The TUI can [review and accept each proposal](docs/updates.md), backing up the
|
||||
current file and refusing proposals whose target changed since creation.
|
||||
KConfig patches preserve unrelated keys and groups; JSON patches preserve
|
||||
unrelated preferences and login data already on the target device.
|
||||
The TUI can [restore user config backups](docs/backups.md), with a preview,
|
||||
confirmation and a new backup of the replaced current files.
|
||||
|
||||
## Project layout
|
||||
|
||||
```text
|
||||
cmd/dots/ Bubble Tea selection interface
|
||||
configs/ Optional KDE and app configs
|
||||
packages/ Categorized Portage package lists
|
||||
presets/ Stock and Fiw's Ryzen defaults
|
||||
portage/ Build settings and binary preferences
|
||||
overlay/ Selected additional packages
|
||||
optional/ Kernel, boot and app helpers
|
||||
flatpaks/ Separate optional Flatpak lists
|
||||
docs/ Setup, decisions and asset credits
|
||||
```
|
||||
Local edits are preserved on updates, with proposed changes saved as `.new`
|
||||
for review. Replaced files get backups. Spotify stays stock;
|
||||
[customization](docs/spotify.md) and [Firefox-Privacy](docs/firefox.md) are
|
||||
explicit optional steps.
|
||||
|
||||
## Maintenance
|
||||
|
||||
```sh
|
||||
python3 tools/capture.py # recapture only the explicit allowlist
|
||||
python3 tools/capture.py --config kde-shortcuts # refresh only shortcuts
|
||||
python3 tools/capture.py --config kitty neovim # refresh only these apps
|
||||
python3 tools/capture.py --config kde-shortcuts
|
||||
python3 tools/capture.py --config kitty neovim
|
||||
python3 tools/check-private.py
|
||||
python3 -m unittest discover -s tests
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Missing selected overlays are staged before package resolution. Optional
|
||||
service presets enable audio, networking, Bluetooth and power profiles for
|
||||
the next boot/login. See [setup](docs/setup.md) and the [helper list](docs/scripts.md).
|
||||
|
||||
Review captures before committing. `local/` and built binaries are ignored.
|
||||
See [selective recapture](docs/capture.md) for supported selections and pinned assets.
|
||||
This repo starts with fresh history. Third-party theme/cursor assets retain
|
||||
their licence notices; overlay ebuilds retain their original notices.
|
||||
Review `git diff` before committing. See [capture options](docs/capture.md)
|
||||
and [helper commands](docs/scripts.md). Device choices and local files are
|
||||
gitignored. Forgejo is primary; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots)
|
||||
receives branches and tags automatically after [CI checks](docs/ci.md).
|
||||
|
||||
## License
|
||||
|
||||
Original code and configuration contributions are licensed under [MIT](LICENSE).
|
||||
Bundled third-party assets, fonts, patches and ebuilds keep their own licences
|
||||
and attribution; see [asset credits](docs/assets.md).
|
||||
|
||||
---
|
||||
Original code and configs: [MIT](LICENSE). Bundled third-party assets keep
|
||||
their own licences; see [credits](docs/assets.md).
|
||||
|
||||
<div align="center">A personal Gentoo setup by Fiw.</div>
|
||||
|
|
|
|||
BIN
assets/Screenshots/Install-script.png
Normal file
BIN
assets/Screenshots/Install-script.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 335 KiB |
BIN
assets/Screenshots/Main-screen.png
Normal file
BIN
assets/Screenshots/Main-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
|
|
@ -1,207 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRyzenSelectionSurvivesSpaceAndEnter(t *testing.T) {
|
||||
c := catalog{Stock: selection{Name: "Stock", Profile: "stock"}, Ryzen: selection{Name: "Fiw's Ryzen", Profile: "fiw-ryzen"}}
|
||||
m := model{catalog: c, selection: c.Stock}
|
||||
m.prepare()
|
||||
m.cursor = 1
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
m = next.(model)
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.selection.Profile != "fiw-ryzen" || m.stage != 1 {
|
||||
t.Fatalf("selected wrong preset: %+v", m.selection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootloaderChoicesAreExclusive(t *testing.T) {
|
||||
m := model{stage: 3, selection: selection{Kernel: "binary", Bootloader: "keep"}, catalog: catalog{Extras: map[string]string{}}}
|
||||
m.prepare()
|
||||
m.cursor = 4
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
m = next.(model)
|
||||
m.remember()
|
||||
if m.selection.Bootloader != "grub" {
|
||||
t.Fatal("GRUB was not selected")
|
||||
}
|
||||
selected := 0
|
||||
for _, r := range m.rows {
|
||||
if r.checked && (r.id == "keep" || r.id == "limine" || r.id == "grub") {
|
||||
selected++
|
||||
}
|
||||
}
|
||||
if selected != 1 {
|
||||
t.Fatal("multiple bootloaders selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigsSelectableWithoutPackageGroups(t *testing.T) {
|
||||
m := model{stage: 2, catalog: catalog{Configs: map[string]config{
|
||||
"fish": {Label: "Fish", Group: "cli"},
|
||||
"kitty": {Label: "Kitty", Group: "fiw-apps"},
|
||||
}}, selection: selection{Configs: []string{"fish"}}}
|
||||
m.prepare()
|
||||
if len(m.rows) != 2 || !m.rows[0].checked {
|
||||
t.Fatal("configs were hidden or deselected by package choices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) {
|
||||
m := model{stage: 4, catalog: catalog{
|
||||
Flatpaks: map[string]setupOption{"org.vinegarhq.Sober": {Label: "Sober", Group: "gaming"}},
|
||||
Services: map[string]setupOption{"audio": {Label: "Audio", Scope: "user"}},
|
||||
}, selection: selection{}}
|
||||
m.prepare()
|
||||
for i := range m.rows {
|
||||
m.rows[i].checked = true
|
||||
}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
result := next.(model)
|
||||
if result.stage != 5 || command == nil || !contains(result.selection.Flatpaks, "org.vinegarhq.Sober") || !contains(result.selection.Services, "audio") {
|
||||
t.Fatalf("setup selections lost before preview: %+v", result.selection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupNeedsSelectionPreviewAndExplicitRestoreAction(t *testing.T) {
|
||||
m := model{stage: 5}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
|
||||
m = next.(model)
|
||||
if m.stage != 6 || command == nil || m.done {
|
||||
t.Fatal("backup key did not open the chooser")
|
||||
}
|
||||
next, _ = m.Update(backupsMsg{choices: []backupChoice{{ID: "20260101T000000Z", Count: 1}}})
|
||||
m = next.(model)
|
||||
next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.stage != 7 || command == nil || m.done {
|
||||
t.Fatal("choosing a backup must only open its preview")
|
||||
}
|
||||
next, _ = m.Update(previewMsg{content: "preview"})
|
||||
m = next.(model)
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
|
||||
m = next.(model)
|
||||
if m.done {
|
||||
t.Fatal("config application key triggered a restore from backup preview")
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if !m.done || m.action != "u" || m.backupID != "20260101T000000Z" {
|
||||
t.Fatal("explicit backup action was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyBackupChooserDoesNotStartRestore(t *testing.T) {
|
||||
m := model{stage: 6}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.done || command != nil || m.stage != 6 {
|
||||
t.Fatal("empty backup chooser started a restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCanScrollThroughLongRequirementLines(t *testing.T) {
|
||||
m := model{stage: 5, height: 20, width: 40, preview: strings.Repeat("package [missing] ", 20)}
|
||||
for range 3 {
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
m = next.(model)
|
||||
}
|
||||
if m.scroll != 3 {
|
||||
t.Fatal("wrapped requirements cannot be scrolled into view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalNeedsDiffAndExplicitAcceptance(t *testing.T) {
|
||||
m := model{stage: 5}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}})
|
||||
m = next.(model)
|
||||
if m.stage != 8 || command == nil || m.done {
|
||||
t.Fatal("proposal key did not open the chooser")
|
||||
}
|
||||
next, _ = m.Update(proposalsMsg{choices: []proposalChoice{{ID: ".config/app.new", Target: ".config/app", Status: "ready"}}})
|
||||
m = next.(model)
|
||||
next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.stage != 9 || command == nil || m.done {
|
||||
t.Fatal("choosing a proposal must only open its diff")
|
||||
}
|
||||
next, _ = m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: ready\n\n-preview\n+proposal"})
|
||||
m = next.(model)
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
|
||||
m = next.(model)
|
||||
if m.done {
|
||||
t.Fatal("application key accepted a proposal")
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if !m.done || m.action != "p" || m.proposalID != ".config/app.new" {
|
||||
t.Fatal("explicit proposal acceptance was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalThatBecameStaleCannotBeAcceptedFromPreview(t *testing.T) {
|
||||
m := model{stage: 9, proposalStatus: "ready", loading: true}
|
||||
next, _ := m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: stale\n"})
|
||||
m = next.(model)
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.done || command != nil || !strings.Contains(m.View(), "Target changed") {
|
||||
t.Fatal("stale proposal was offered for acceptance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateIsASeparateAction(t *testing.T) {
|
||||
m := model{stage: 5}
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||
m = next.(model)
|
||||
if !m.done || m.action != "e" {
|
||||
t.Fatal("config update action was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedDeviceKeepsItsSelections(t *testing.T) {
|
||||
device := selection{Name: "portable", Profile: "stock", Groups: []string{"cli"}, Configs: []string{"neovim"}, Kernel: "binary", Bootloader: "keep"}
|
||||
m := model{catalog: catalog{Stock: selection{Name: "Stock", Profile: "stock"}, Ryzen: selection{Name: "Ryzen", Profile: "fiw-ryzen"}, Devices: map[string]selection{"portable": device}}, selection: device}
|
||||
m.prepare()
|
||||
if m.cursor != 2 || !m.rows[2].checked || m.rows[0].checked {
|
||||
t.Fatal("saved device is not selected in the preset chooser")
|
||||
}
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if m.stage != 1 || len(m.selection.Groups) != 1 || !contains(m.selection.Configs, "neovim") || m.selection.Name != "portable" {
|
||||
t.Fatal("device choices were replaced by the stock preset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceNameAcceptsQAndRequiresExplicitSave(t *testing.T) {
|
||||
m := model{stage: 5}
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||
m = next.(model)
|
||||
if m.stage != 10 || m.done {
|
||||
t.Fatal("n must open the name input without saving")
|
||||
}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
m = next.(model)
|
||||
if command != nil || m.deviceName != "q" {
|
||||
t.Fatal("q should be accepted as part of a device name")
|
||||
}
|
||||
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = next.(model)
|
||||
if !m.done || m.action != "n" || m.selection.Name != "q" {
|
||||
t.Fatal("explicit named-device save was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceNameEscapeDoesNotSave(t *testing.T) {
|
||||
m := model{stage: 10, deviceName: "discard"}
|
||||
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = next.(model)
|
||||
if m.stage != 5 || m.done || command != nil {
|
||||
t.Fatal("cancelled device input saved a selection")
|
||||
}
|
||||
}
|
||||
|
|
@ -74,11 +74,5 @@ roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can
|
|||
keep their existing loader. Secure Boot being active stops unsigned deployment
|
||||
before changes. Full boot execution on a fresh installation remains unverified.
|
||||
|
||||
## Retained helpers
|
||||
|
||||
`optional/fiw-limine-splice` is the earlier helper for Fiw's marker/snippet
|
||||
layout. It is retained for manual use and is not installed by this deployment
|
||||
or by the GRUB variant. The new refresh helper manages its own separate config.
|
||||
|
||||
`optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select
|
||||
a bootloader. See the [helper list](scripts.md) before using legacy helpers.
|
||||
a bootloader. See the [helper list](scripts.md) before using it.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Its `.forgejo/workflows/ci.yml` runs these checks:
|
|||
- Publishable files pass the privacy scanner.
|
||||
- The prebuilt TUI matches `VERSION`, its frontend source and binary checksums.
|
||||
- Pinned editor assets match their recorded fingerprints.
|
||||
- Python and Go checks pass using temporary homes and fixtures.
|
||||
- Python sources parse and the Go frontend compiles.
|
||||
|
||||
Successful push or manual runs then synchronize Git branches and tags to
|
||||
[Fi3w0/Fiw-Gentoo-Dots on GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots).
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# Explicit package audit
|
||||
|
||||
The 2026-10-03 audit compares Portage `world` and the selected `@fiw-*` sets
|
||||
with the main lists, optional lists and kernel choices. It reads explicit
|
||||
selections only; it does not expand package dependencies or copy an installed
|
||||
package inventory.
|
||||
|
||||
Result: **111 explicit selections accounted for**. 110 have package-list or
|
||||
kernel coverage. `app-admin/fiw-scripts` supplies the two legacy helpers
|
||||
already retained under `optional/`; that exception is documented in
|
||||
`packages/audit-exceptions.json`. There are no unexplained missing packages
|
||||
or unresolved sets.
|
||||
|
||||
The audit added `x11-misc/ydotool` to `cli`. It does not enable its daemon or
|
||||
change input permissions. Filelight, Btrfs tools, NVIDIA drivers and TideWM
|
||||
remain in their separate optional selections. Dolphin, Ark, Gwenview and
|
||||
Spectacle remain in `fiw-apps`.
|
||||
|
||||
Repeat the audit on a device:
|
||||
|
||||
```sh
|
||||
python3 tools/audit-packages.py
|
||||
python3 tools/audit-packages.py --json > local/package-audit.json
|
||||
```
|
||||
|
||||
Version pins and old repository qualifiers are normalized for coverage;
|
||||
explicit Java slots remain distinct. Cyclic or missing selected sets are
|
||||
reported rather than treated as covered. The tool does not change package
|
||||
lists, Portage configuration or the device's world selections.
|
||||
|
|
@ -1,38 +1,24 @@
|
|||
# Helpers and what they do
|
||||
# Commands and helpers
|
||||
|
||||
| Helper | Purpose | How it is used |
|
||||
|---|---|---|
|
||||
| `install` | Opens the verified prebuilt chooser with a Go source fallback, or forwards CLI options to the Python backend | Main entry point |
|
||||
| `lib/launcher.py` | Verifies frontend/binary fingerprints and extracts/caches the bundled TUI | Called by the entry point |
|
||||
| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
|
||||
| `lib/device_presets.py` | Saves and lists named local device selections with overwrite prompts | TUI named preset action or backend commands |
|
||||
| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
|
||||
| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment |
|
||||
| `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands |
|
||||
| `lib/proposals.py` | Lists pending config changes, previews diffs and accepts individual proposals after checking for newer edits | TUI pending updates action or backend commands |
|
||||
| `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use |
|
||||
| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument |
|
||||
| `tools/capture.py` | Recaptures selected configs or the explicit preference allowlist without copying full personal profiles | `--config` selects individual configs; review the diff |
|
||||
| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts |
|
||||
| `tools/check-private.py` | Scans publishable files for personal paths, identifiers and credentials; keeps fingerprinted upstream attribution | Run before committing |
|
||||
| `tools/audit-packages.py` | Compares explicit world/selected sets with main/optional package lists and documented helper exceptions | Read-only package audit |
|
||||
| `tools/build-release.py` | Builds the generic static amd64 TUI bundle, fingerprints and dependency licence notices | Maintainer command before tagging snapshots |
|
||||
| `tools/ci/check.py` | Checks privacy, bundle/theme fingerprints and portable Python/Go behaviour | Forgejo and GitHub CI; can also run locally |
|
||||
| `tools/ci/mirror.py` | Copies Forgejo branches/tags to GitHub with a repository deploy key | Forgejo CI after checks; explicit manual sync supported |
|
||||
| `optional/fiw-cachy-patch` | Downloads matching vanilla/CachyOS kernel sources, generates a patch and adjusts the installed guard/name config | Explicit manual root operation; not part of ordinary restoration |
|
||||
| `optional/fiw-limine-splice` | Replaces the old Gentoo snippet block in an existing Limine menu and orders its entries | Explicit manual use with an ESP argument; specific to the earlier snippet design |
|
||||
| `optional/spotify/spotify-patch` | Runs the retained SpotX/Spicetify customization workflow for the Gentoo Spotify client | Explicit optional user operation; stock Spotify is the default |
|
||||
| `optional/spotify/spicetify-snapshotfix` | Copies missing Marketplace fragments into the captured Spotify UI snapshot format | Called by the optional customization workflow |
|
||||
| Command/helper | Purpose |
|
||||
|---|---|
|
||||
| `./install` | Opens the TUI; CLI options use the same backend. See [setup](setup.md). |
|
||||
| `tools/capture.py` | Refreshes selected config preferences from your home. See [capture](capture.md). |
|
||||
| `tools/shortcuts.py` | Regenerates the readable KDE shortcut reference after recapture. |
|
||||
| `tools/check-private.py` | Checks publishable files for personal paths, identifiers and credentials. |
|
||||
| `tools/build-release.py` | Rebuilds the bundled TUI, fingerprints and dependency licences. |
|
||||
| `tools/ci/check.py` | Checks privacy, asset fingerprints, Python syntax and frontend compilation. |
|
||||
| `tools/ci/mirror.py` | Synchronizes branches/tags to GitHub after checks. See [CI](ci.md). |
|
||||
| `fiw-shot` | Screenshot region/monitor, copy to clipboard, then offer Save/Edit. Installed with KDE shortcuts. |
|
||||
| `optional/fiw-cachy-patch` | Manually generates/installs a matching CachyOS kernel patch and updates the installed guard/name. |
|
||||
| `optional/spotify/spotify-patch` | Runs the optional Spotify customization workflow. |
|
||||
| `optional/spotify/spicetify-snapshotfix` | Supplies missing Marketplace fragments for that customization. |
|
||||
|
||||
The kernel refresh helper installed on a target runs with `--refresh`. Its
|
||||
kernel hooks are created only when boot deployment is explicitly confirmed;
|
||||
they are not enabled by the default `keep` choice.
|
||||
The custom-kernel helper does not update the repo's `snapshot.json`, patch
|
||||
checksum or version mask. Review those together when refreshing the snapshot.
|
||||
|
||||
The older Cachy patch helper predates the snapshot guard shipped by this
|
||||
repo. It does not update the repo's `snapshot.json`, checksum or version mask;
|
||||
review/update those together when preparing a new supported kernel snapshot.
|
||||
It is retained as a reference, not run automatically.
|
||||
Confirmed boot deployment installs its own refresh helper and kernel hooks.
|
||||
The default `keep` choice leaves them disabled. See [boot setup](boot.md).
|
||||
|
||||
For the optional Spotify scripts, see [the setup and risk note](spotify.md).
|
||||
Unofficial Spotify modifications are optional and used at your own risk.
|
||||
This project does not endorse bypassing paid features.
|
||||
Spotify modifications are optional and at your own risk. This project does
|
||||
not endorse bypassing paid features. See [Spotify setup](spotify.md).
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# fiw-limine-splice <esp-mountpoint>
|
||||
# Replaces the fiw-gentoo block in <esp>/limine.conf with the snippet Gentoo left
|
||||
# at <esp>/<gentoo-machine-id>/limine-entries.conf after a Gentoo kernel update.
|
||||
# Menu order (Fiw): Gentoo, CachyOS, other entries, Windows last.
|
||||
set -euo pipefail
|
||||
esp=${1:?usage: fiw-limine-splice <esp>}
|
||||
conf=$esp/limine.conf
|
||||
begin='# >>> fiw-gentoo (managed by fiw-limine, do not edit)'
|
||||
end='# <<< fiw-gentoo'
|
||||
|
||||
shopt -s nullglob
|
||||
snippets=("$esp"/*/fiw-gentoo.marker)
|
||||
((${#snippets[@]} == 1)) || { echo "fiw-limine-splice: expected 1 Gentoo snippet, found ${#snippets[@]}" >&2; exit 1; }
|
||||
snippet=$(dirname "${snippets[0]}")/limine-entries.conf
|
||||
[[ -f $conf && -f $snippet ]] || exit 0
|
||||
|
||||
tmp=$conf.fiw-tmp
|
||||
# Drop the old Gentoo block, split the rest into header + top-level entries
|
||||
# (a top-level entry starts at a line "/X" and runs to the next one), then
|
||||
# print: header, Gentoo block, CachyOS, other entries, Windows entries.
|
||||
awk -v b="$begin" -v e="$end" -v snip="$snippet" '
|
||||
BEGIN {n = 0}
|
||||
$0==b {skip=1} skip {if ($0==e) skip=0; next}
|
||||
/^comment: machine-id=/ {mid=$0; next}
|
||||
/^\/[^\/]/ {n++; if (mid != "") ent[n] = mid "\n"; mid=""}
|
||||
{ ent[n] = ent[n] $0 "\n" }
|
||||
END {
|
||||
if (mid != "") ent[n] = ent[n] mid "\n"
|
||||
for (i = 1; i <= n; i++) {
|
||||
h = ent[i]; sub(/\n.*/, "", h)
|
||||
if (h ~ /^\/\+?CachyOS/) cls[i] = 1
|
||||
else if (ent[i] ~ /Windows|bootmgfw\.efi/) cls[i] = 3
|
||||
else cls[i] = 2
|
||||
}
|
||||
out = ent[0]
|
||||
sub(/\n+$/, "\n", out)
|
||||
out = out "\n" b "\n"
|
||||
while ((getline l < snip) > 0) out = out l "\n"
|
||||
out = out e "\n\n"
|
||||
for (c = 1; c <= 3; c++) for (i = 1; i <= n; i++) if (cls[i] == c) out = out ent[i]
|
||||
sub(/\n+$/, "\n", out)
|
||||
printf "%s", out
|
||||
}' "$conf" >"$tmp"
|
||||
|
||||
# Refuse to write if any non-Gentoo line got lost.
|
||||
if ! diff <(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$conf" | sort) \
|
||||
<(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$tmp" | sort) >/dev/null; then
|
||||
rm -f "$tmp"; echo "fiw-limine-splice: reorder would change entries, leaving limine.conf alone" >&2; exit 1
|
||||
fi
|
||||
cmp -s "$tmp" "$conf" && { rm -f "$tmp"; exit 0; }
|
||||
cp -p "$conf" "$conf.before-fiw"
|
||||
mv "$tmp" "$conf"
|
||||
sync -f "$conf"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"app-admin/fiw-scripts": "Its two legacy kernel/boot helpers are retained as optional/fiw-cachy-patch and optional/fiw-limine-splice for manual use. The current boot action has its own refresh helper."
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import importlib.util
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('ci_mirror', REPO / 'tools/ci/mirror.py')
|
||||
ci_mirror = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ci_mirror)
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', *map(str, args)], text=True, stderr=subprocess.DEVNULL).strip()
|
||||
|
||||
|
||||
def identity(path):
|
||||
for key, value in [('user.name', 'CI fixture'), ('user.email', 'ci-fixture'),
|
||||
('commit.gpgsign', 'false'), ('tag.gpgsign', 'false')]:
|
||||
git('-C', path, 'config', key, value)
|
||||
|
||||
|
||||
class MirrorTests(unittest.TestCase):
|
||||
def test_mirror_preserves_tags_and_refs_and_refuses_divergence(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); work, source, target = root / 'work', root / 'source.git', root / 'target.git'
|
||||
git('init', '-b', 'main', work); identity(work)
|
||||
(work / 'README').write_text('first\n')
|
||||
git('-C', work, 'add', 'README'); git('-C', work, 'commit', '-m', 'first')
|
||||
git('-C', work, 'branch', 'other'); git('-C', work, 'tag', '-a', 'v1', '-m', 'version1')
|
||||
git('clone', '--bare', work, source); git('init', '--bare', target)
|
||||
head = git('-C', work, 'rev-parse', 'HEAD')
|
||||
ci_mirror.mirror(str(source), str(target), source_ref='refs/tags/v1', source_sha=head)
|
||||
self.assertEqual(git('-C', source, 'show-ref'), git('-C', target, 'show-ref'))
|
||||
git('-C', target, 'branch', 'github-only', head)
|
||||
(work / 'README').write_text('source update\n')
|
||||
git('-C', work, 'commit', '-am', 'source update'); git('-C', work, 'push', source, 'main')
|
||||
diverged = root / 'diverged'
|
||||
git('clone', '--branch', 'main', target, diverged); identity(diverged)
|
||||
(diverged / 'other').write_text('GitHub edit\n')
|
||||
git('-C', diverged, 'add', 'other'); git('-C', diverged, 'commit', '-m', 'GitHub edit')
|
||||
git('-C', diverged, 'push', 'origin', 'main')
|
||||
before = git('-C', target, 'show-ref')
|
||||
ci_mirror.mirror(str(source), str(target), source_ref='refs/heads/main', source_sha=head)
|
||||
self.assertEqual(before, git('-C', target, 'show-ref'))
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
ci_mirror.mirror(str(source), str(target))
|
||||
self.assertEqual(before, git('-C', target, 'show-ref'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
|
||||
rice = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rice)
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_kconfig_preserves_unrelated_state_and_combines_window_rules(self):
|
||||
old = '# comment\n[General]\nrules=my-rule\ncount=1\nkeep=value\n[Session]\nprivate=preserved\n'
|
||||
desired = '[General]\nrules=fiw-dolphin-opacity\ncount=1\n[Style]\ncolour=purple\n'
|
||||
result = rice.merge_kconfig(old, desired)
|
||||
self.assertIn('# comment', result)
|
||||
self.assertIn('private=preserved', result)
|
||||
self.assertIn('keep=value', result)
|
||||
self.assertIn('rules=my-rule,fiw-dolphin-opacity', result)
|
||||
self.assertIn('count=2', result)
|
||||
self.assertEqual(rice.merge_kconfig(result, desired), result)
|
||||
|
||||
def test_json_merge_preserves_account_and_unrelated_preferences(self):
|
||||
old = '{"session": "existing", "nested": {"unrelated": 1, "selected": 2}}'
|
||||
desired = '{"nested": {"selected": 3}}'
|
||||
self.assertEqual(json.loads(rice.merge_json(old, desired)),
|
||||
{'session': 'existing', 'nested': {'unrelated': 1, 'selected': 3}})
|
||||
|
||||
def test_conflicts_backup_and_updates_preserve_local_edits(self):
|
||||
selection = rice.load_selection()
|
||||
selection['configs'] = ['fastfetch']
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
home = Path(temp)
|
||||
target = home / '.config/fastfetch/config.jsonc'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text('original local config\n')
|
||||
kept = rice.apply_configs(selection, home, conflict='keep')
|
||||
self.assertEqual(target.read_text(), 'original local config\n')
|
||||
self.assertEqual(len(kept['kept']), 1)
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
backups = list((home / '.local/state/Fiw-Gentoo-Dots/backups').rglob('config.jsonc'))
|
||||
self.assertEqual(backups[0].read_text(), 'original local config\n')
|
||||
target.write_text('user edited config\n')
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
self.assertEqual(target.read_text(), 'user edited config\n')
|
||||
pending = Path(str(target) + '.new')
|
||||
self.assertEqual(json.loads(pending.read_text())['logo']['source'], 'gentoo')
|
||||
pending.write_text('user edited review\n')
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
self.assertEqual(pending.read_text(), 'user edited review\n')
|
||||
|
||||
def test_shortcut_launchers_expand_target_home(self):
|
||||
selection = rice.load_selection()
|
||||
selection['configs'] = ['kde-shortcuts']
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
home = Path(temp)
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
desktop = home / '.local/share/applications/net.local.fiw-shot-region.desktop'
|
||||
self.assertIn('Exec="' + str(home) + '/.local/bin/fiw-shot" region', desktop.read_text())
|
||||
self.assertNotIn('{{HOME}}', desktop.read_text())
|
||||
self.assertTrue((home / '.local/bin/fiw-shot').stat().st_mode & 0o111)
|
||||
|
||||
def test_actual_kde_session_is_rejected_before_any_writes(self):
|
||||
selection = rice.load_selection()
|
||||
selection['configs'] = ['kde-style']
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice, 'active_plasma', return_value=True):
|
||||
home = Path(temp)
|
||||
with self.assertRaises(RuntimeError):
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
self.assertEqual(list(home.iterdir()), [])
|
||||
|
||||
def test_source_plan_distinguishes_binary_payloads_and_dependencies(self):
|
||||
output = '[binary N ] app-misc/jq-1.8.2::gentoo\n[ebuild N ] dev-libs/foo-2.0::gentoo\n[ebuild N ] games-util/ge-proton-bin-11.7::fiw-dots\n[ebuild N ] kde-misc/apdatifier-gentoo-1.0.0::fiw-dots\n'
|
||||
self.assertEqual(rice.source_builds(output), {'dev-libs/foo-2.0'})
|
||||
self.assertEqual(rice.cp_from_cpv('dev-libs/foo-2.0'), 'dev-libs/foo')
|
||||
|
||||
def test_configs_restore_without_installing_package_sections(self):
|
||||
selection = rice.load_selection()
|
||||
selection['groups'] = []
|
||||
selection['configs'] = ['fish', 'kitty', 'neovim', 'fastfetch']
|
||||
selection['extras'] = []
|
||||
self.assertEqual(rice.package_map(selection), {})
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
home = Path(temp)
|
||||
report = rice.apply_configs(selection, home, conflict='apply')
|
||||
self.assertTrue(report['applied'])
|
||||
for relative in ['.config/fish/conf.d/fiw.fish', '.config/kitty/kitty.conf',
|
||||
'.config/nvim/init.lua', '.config/fastfetch/config.jsonc']:
|
||||
self.assertTrue((home / relative).is_file(), relative)
|
||||
self.assertNotIn('inactive', rice.preview(selection))
|
||||
|
||||
def test_category_separation_and_stock_kernel(self):
|
||||
selection = rice.load_selection()
|
||||
packages = rice.package_map(selection)
|
||||
self.assertIn('sys-kernel/gentoo-kernel-bin', packages['system'])
|
||||
self.assertNotIn('sys-kernel/gentoo-kernel', packages['system'])
|
||||
self.assertFalse(any(p.startswith('=sys-kernel/gentoo-kernel-') for p in packages['system']))
|
||||
self.assertNotIn('kde-apps/dolphin', packages['kde'])
|
||||
self.assertIn('kde-apps/dolphin', packages['fiw-apps'])
|
||||
self.assertNotIn('kde-apps/filelight', packages['fiw-apps'])
|
||||
self.assertEqual(selection['bootloader'], 'keep')
|
||||
self.assertNotIn('autostart', selection['configs'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('rice', REPO / 'lib/rice.py')
|
||||
rice = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rice)
|
||||
import capture
|
||||
|
||||
|
||||
class DevicesAndCaptureTests(unittest.TestCase):
|
||||
def test_named_devices_preserve_independent_choices_and_ask_on_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
repo = Path(temp)
|
||||
desktop = rice.load_selection(profile='fiw-ryzen')
|
||||
portable = rice.load_selection()
|
||||
portable.update(groups=['cli'], configs=['fish', 'neovim'], extras=[])
|
||||
rice.device_presets.save(repo, 'desktop', desktop, lambda *args: 'keep')
|
||||
rice.device_presets.save(repo, 'portable', portable, lambda *args: 'keep')
|
||||
original = rice.device_presets.path(repo, 'desktop').read_bytes()
|
||||
rice.device_presets.save(repo, 'desktop', portable, lambda *args: 'keep')
|
||||
self.assertEqual(rice.device_presets.path(repo, 'desktop').read_bytes(), original)
|
||||
devices = rice.device_presets.list_presets(repo, rice.load_selection)
|
||||
self.assertEqual(devices['desktop']['profile'], 'fiw-ryzen')
|
||||
self.assertEqual(devices['portable']['configs'], ['fish', 'neovim'])
|
||||
self.assertEqual(devices['portable']['name'], 'portable')
|
||||
self.assertEqual(rice.device_presets.path(repo, 'portable').stat().st_mode & 0o777, 0o600)
|
||||
rice.device_presets.save(repo, 'desktop', portable, lambda *args: 'save')
|
||||
self.assertEqual(rice.device_presets.list_presets(repo, rice.load_selection)['desktop']['profile'], 'stock')
|
||||
|
||||
def test_unsafe_names_and_invalid_local_selections_do_not_enter_catalog(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
repo = Path(temp)
|
||||
root = repo / 'local/devices'; root.mkdir(parents=True)
|
||||
for content in ('[]', '{"profile":"stock"}', '{broken'):
|
||||
(root / 'broken.json').write_text(content)
|
||||
self.assertEqual(rice.device_presets.list_presets(repo, rice.load_selection), {})
|
||||
for name in ('../escape', '/escape', '-name', 'Desktop', 'a'*49):
|
||||
with self.assertRaises(ValueError):
|
||||
rice.device_presets.save(repo, name, rice.load_selection(), lambda *args: 'save')
|
||||
(root / 'linked.json').symlink_to(repo / 'outside.json')
|
||||
with self.assertRaises(RuntimeError):
|
||||
rice.device_presets.save(repo, 'linked', rice.load_selection(), lambda *args: 'save')
|
||||
self.assertFalse((repo / 'outside.json').exists())
|
||||
|
||||
def test_selective_portable_capture_needs_no_other_app_or_kde_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home, repo = root / 'home', root / 'repo'
|
||||
source = home / '.config/kitty/kitty.conf'; source.parent.mkdir(parents=True)
|
||||
source.write_text('foreground #abcdef\nlinux_display_server wayland\n')
|
||||
unrelated = repo / 'configs/fish/files/.config/fish/conf.d/fiw.fish'
|
||||
unrelated.parent.mkdir(parents=True); unrelated.write_text('keep existing capture\n')
|
||||
with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
|
||||
capture.capture(['kitty'])
|
||||
target = repo / 'configs/kitty/files/.config/kitty/kitty.conf'
|
||||
self.assertIn('linux_display_server auto', target.read_text())
|
||||
self.assertEqual(unrelated.read_text(), 'keep existing capture\n')
|
||||
self.assertEqual({path.relative_to(repo).parts[1] for path in repo.rglob('*') if path.is_file()}, {'kitty','fish'})
|
||||
|
||||
def test_shortcuts_capture_keeps_style_and_drops_device_ids(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home, repo = root / 'home', root / 'repo'
|
||||
source = home / '.config/kwinrc'; source.parent.mkdir(parents=True)
|
||||
source.write_text('[Desktops]\nNumber=1\nRows=1\nId_1=device-only\n[org.kde.kdecoration2]\ntheme=local\n')
|
||||
style = repo / 'configs/kde-style/kconfig/.config/kwinrc'
|
||||
style.parent.mkdir(parents=True); style.write_text('captured style stays intact\n')
|
||||
with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
|
||||
capture.capture(['kde-shortcuts'])
|
||||
result = (repo / 'configs/kde-shortcuts/kconfig/.config/kwinrc').read_text()
|
||||
self.assertIn('Number=1',result)
|
||||
self.assertNotIn('device-only',result)
|
||||
self.assertNotIn('theme=local',result)
|
||||
self.assertEqual(style.read_text(), 'captured style stays intact\n')
|
||||
|
||||
def test_vscode_capture_reads_jsonc_and_omits_machine_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home, repo = root / 'home', root / 'repo'
|
||||
source = home / '.config/Code/User/settings.json'; source.parent.mkdir(parents=True)
|
||||
source.write_text('{ // comment\n"workbench.colorTheme":"ayu MiDas", /* comment */\n"claudeCode.selectedModel":"literal//comma,}",\n"java.configuration.runtimes":[{"path":"device-only"}], "unrelatedSecret":"keep-local",}')
|
||||
with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
|
||||
capture.capture(['vscode'])
|
||||
result = json.loads((repo / 'configs/vscode/json/.config/Code/User/settings.json').read_text())
|
||||
self.assertEqual(result, {'workbench.colorTheme':'ayu MiDas','claudeCode.selectedModel':'literal//comma,}'})
|
||||
|
||||
def test_restore_then_recapture_keeps_helpers_portable_and_does_not_nest_guards(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home, repo = root / 'home', root / 'repo'
|
||||
home.mkdir()
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=[], configs=['fish', 'kde-shortcuts'])
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
|
||||
capture.capture(['fish', 'kde-shortcuts'])
|
||||
fish = (repo / 'configs/fish/files/.config/fish/conf.d/fiw.fish').read_text()
|
||||
self.assertEqual(fish.count('if type -q fiw-update'), 1)
|
||||
desktop = (repo / 'configs/kde-shortcuts/files/.local/share/applications/net.local.fiw-shot-region.desktop').read_text()
|
||||
self.assertIn('Exec="{{HOME}}/.local/bin/fiw-shot" region', desktop)
|
||||
self.assertNotIn(str(home), desktop)
|
||||
|
||||
def test_vscode_restore_merges_jsonc_and_keeps_local_runtime_preferences(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
home = Path(temp)
|
||||
target = home / '.config/Code/User/settings.json'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text('{ // local preference comment\n"privatePreference":"local-only",\n"java.configuration.runtimes":[{"path":"device-only"}],}')
|
||||
selection = rice.load_selection(); selection.update(groups=[], configs=['vscode'])
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
result = json.loads(target.read_text())
|
||||
self.assertEqual(result['privatePreference'], 'local-only')
|
||||
self.assertEqual(result['java.configuration.runtimes'], [{'path':'device-only'}])
|
||||
self.assertEqual(result['workbench.colorTheme'], 'ayu MiDas')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
|
||||
rice = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rice)
|
||||
|
||||
|
||||
class ProposalTests(unittest.TestCase):
|
||||
def create(self, home):
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=[], configs=['fastfetch'])
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
target = home / '.config/fastfetch/config.jsonc'
|
||||
target.write_text('local preferences\n')
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
candidate = Path(str(target) + '.new')
|
||||
return selection, target, candidate, candidate.relative_to(home).as_posix()
|
||||
|
||||
def test_accept_backs_up_local_file_and_keeps_other_proposals(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
selection, target, candidate, proposal_id = self.create(home)
|
||||
proposed = candidate.read_bytes()
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
pending = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(pending), 2)
|
||||
self.assertIn('-local preferences', rice.proposals.preview(home, proposal_id, rice.backup_paths()))
|
||||
report = rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
self.assertEqual(target.read_bytes(), proposed)
|
||||
backup = rice.backups.user_state(home) / 'backups' / report['backup'] / '.config/fastfetch/config.jsonc'
|
||||
self.assertEqual(backup.read_text(), 'local preferences\n')
|
||||
self.assertFalse(candidate.exists())
|
||||
pending = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0]['status'], 'unchanged')
|
||||
index = json.loads((rice.backups.user_state(home) / 'managed.json').read_text())
|
||||
self.assertEqual(index['.config/fastfetch/config.jsonc'], rice.digest(target))
|
||||
|
||||
def test_keep_and_stale_targets_preserve_both_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
original_proposal = candidate.read_bytes()
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'keep', lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertEqual(candidate.read_bytes(), original_proposal)
|
||||
target.write_text('newer local preferences\n')
|
||||
with self.assertRaisesRegex(RuntimeError, 'changed since'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'newer local preferences\n')
|
||||
self.assertEqual(candidate.read_bytes(), original_proposal)
|
||||
|
||||
def test_target_change_during_confirmation_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
def confirm(*args):
|
||||
target.write_text('app rewrote its config\n')
|
||||
return 'apply'
|
||||
with self.assertRaisesRegex(RuntimeError, 'changed during'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'app rewrote its config\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_manually_edited_proposal_remains_local_on_future_updates(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
selection, target, candidate, proposal_id = self.create(home)
|
||||
candidate.write_text('chosen custom config\n')
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
self.assertEqual(target.read_text(), 'chosen custom config\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_proposal_change_during_confirmation_requires_another_review(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
def confirm(*args):
|
||||
candidate.write_text('edited proposal during review\n')
|
||||
return 'apply'
|
||||
with self.assertRaisesRegex(RuntimeError, 'proposal changed during'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertEqual(candidate.read_text(), 'edited proposal during review\n')
|
||||
|
||||
def test_paths_outside_home_and_live_kde_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
outside = root / 'outside'; outside.mkdir()
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
config = home / '.config'
|
||||
config.rename(home / 'saved-config')
|
||||
config.symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaisesRegex(RuntimeError, 'Unsafe'):
|
||||
rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
config.unlink(); (home / 'saved-config').rename(config)
|
||||
allowed = rice.backup_paths()
|
||||
allowed['.config/fastfetch/config.jsonc']['kde'] = True
|
||||
with self.assertRaisesRegex(RuntimeError, 'Log out'):
|
||||
rice.proposals.accept(home, proposal_id, allowed, lambda *args: 'apply', lambda *args: True)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_shared_kconfig_generates_one_complete_proposal(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
relative = Path('.config/kwinrc')
|
||||
target = home / relative; target.parent.mkdir()
|
||||
target.write_text('[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n[Other]\nkeep=yes\n')
|
||||
style, shortcuts = root / 'style', root / 'shortcuts'
|
||||
style.write_text('[Appearance]\ncolour=purple\n')
|
||||
shortcuts.write_text('[Desktops]\nNumber=1\n')
|
||||
entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
|
||||
with patch.object(rice, 'config_entries', return_value=entries):
|
||||
rice.apply_configs(rice.load_selection(), home, update=True, conflict='apply')
|
||||
choices = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(choices), 1)
|
||||
content = (home / choices[0]['id']).read_text()
|
||||
for value in ('colour=purple', 'Number=1', 'keep=yes'):
|
||||
self.assertIn(value, content)
|
||||
self.assertIn('colour=orange', target.read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import gzip
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
launcher = module('launcher', REPO / 'lib/launcher.py')
|
||||
audit = module('audit', REPO / 'tools/audit-packages.py')
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def bundle(self, repo):
|
||||
for name in ('go.mod', 'go.sum', 'VERSION'):
|
||||
(repo / name).write_text('fixture')
|
||||
root = repo / 'assets/bin'; root.mkdir(parents=True)
|
||||
data = b'fixture-binary'
|
||||
archive = gzip.compress(data, mtime=0)
|
||||
(root / 'fiw-dots.gz').write_bytes(archive)
|
||||
metadata = {'source_sha256': launcher.source_hash(repo), 'binaries': {
|
||||
'linux-amd64': {'file': 'fiw-dots.gz', 'size': len(data),
|
||||
'sha256': launcher.digest(data), 'gzip_sha256': launcher.digest(archive)}}}
|
||||
(root / 'tui.json').write_text(json.dumps(metadata))
|
||||
return data
|
||||
|
||||
def test_bundle_runs_without_go_and_replaces_a_damaged_cache(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
repo = Path(temp); data = self.bundle(repo)
|
||||
cache = repo / 'build/fiw-dots'; cache.parent.mkdir(); cache.write_bytes(b'damaged')
|
||||
with patch.object(launcher.platform, 'system', return_value='Linux'), patch.object(launcher.platform, 'machine', return_value='x86_64'), patch.object(launcher.shutil, 'which', return_value=None), patch.object(launcher.os, 'chdir'), patch.dict(launcher.os.environ, {'FIW_DOTS_SOURCE': '0'}), patch.object(launcher.subprocess, 'run') as run:
|
||||
self.assertEqual(launcher.launch(repo), cache)
|
||||
self.assertEqual(cache.read_bytes(), data)
|
||||
self.assertTrue(cache.stat().st_mode & 0o111)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_changed_frontend_and_modified_archives_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
repo = Path(temp); self.bundle(repo)
|
||||
cache = repo / 'build/fiw-dots'
|
||||
with patch.object(launcher.platform, 'system', return_value='Linux'), patch.object(launcher.platform, 'machine', return_value='x86_64'):
|
||||
archive = repo / 'assets/bin/fiw-dots.gz'
|
||||
original = archive.read_bytes(); archive.write_bytes(original + b'modified')
|
||||
with self.assertRaises(RuntimeError):
|
||||
launcher.bundled(repo, cache)
|
||||
archive.write_bytes(original)
|
||||
(repo / 'cmd/dots').mkdir(parents=True)
|
||||
(repo / 'cmd/dots/main.go').write_text('changed frontend')
|
||||
with self.assertRaises(RuntimeError):
|
||||
launcher.bundled(repo, cache)
|
||||
self.assertFalse(cache.exists())
|
||||
|
||||
def test_audit_expands_only_selected_sets_and_preserves_java_slots(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); sets = root / 'sets'; sets.mkdir()
|
||||
world, selected = root / 'world', root / 'world_sets'
|
||||
world.write_text('=app-editors/vscode-1.0::old\n')
|
||||
selected.write_text('@desktop\n')
|
||||
(sets / 'desktop').write_text('app-editors/vscode\n@java\n')
|
||||
(sets / 'java').write_text('dev-java/openjdk-bin:21\n')
|
||||
(sets / 'not-selected').write_text('dev-libs/dependency-only\n')
|
||||
result, unresolved = audit.explicit(world, selected, sets)
|
||||
self.assertEqual(set(result), {'app-editors/vscode', 'dev-java/openjdk-bin:21'})
|
||||
self.assertEqual(unresolved, [])
|
||||
(sets / 'java').write_text('@desktop\n')
|
||||
with self.assertRaises(ValueError):
|
||||
audit.explicit(world, selected, sets)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
|
||||
rice = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rice)
|
||||
|
||||
|
||||
class RestorationTests(unittest.TestCase):
|
||||
def test_app_capture_uses_explicit_preferences_and_omits_accounts_and_paths(self):
|
||||
import capture
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
home, repo = root / 'home', root / 'repo'
|
||||
source = home / '.local/share/PrismLauncher/prismlauncher.cfg'
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text('[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\nSelectedInstance=local-instance\nJavaPath=/device/java\nModrinthToken=local-only\n[Accounts]\nuser=local-user\n')
|
||||
with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
|
||||
capture.capture_apps()
|
||||
result = (repo / 'configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg').read_text()
|
||||
self.assertEqual(result, '[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\n')
|
||||
|
||||
def test_config_requirements_do_not_select_packages_and_report_actual_missing_apps(self):
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=[], configs=['kitty'], extras=[], bootloader='keep')
|
||||
with patch.object(rice.shutil, 'which', return_value='portageq'), patch.object(rice.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1)):
|
||||
requirements = rice.config_requirements(selection)
|
||||
self.assertEqual(requirements['kitty'], [{'package': 'x11-terms/kitty', 'status': 'missing'}])
|
||||
self.assertEqual(rice.package_map(selection), {})
|
||||
selection['groups'] = ['fiw-apps']
|
||||
self.assertEqual(rice.config_requirements(selection)['kitty'][0]['status'], 'selected')
|
||||
self.assertEqual(rice.config_requirements(selection, planned=False)['kitty'][0]['status'], 'missing')
|
||||
|
||||
def test_backup_restore_preserves_current_file_and_future_updates_preserve_restored_edits(self):
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=[], configs=['fastfetch'])
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.backups.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
target = home / '.config/fastfetch/config.jsonc'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text('original personal config\n')
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
current = target.read_text()
|
||||
choices = rice.backups.list_backups(home, rice.backup_paths())
|
||||
self.assertEqual(len(choices), 1)
|
||||
report = rice.backups.restore(home, choices[0]['id'], rice.backup_paths(), lambda *args: 'restore', lambda *args: False, conflict='apply')
|
||||
self.assertEqual(target.read_text(), 'original personal config\n')
|
||||
saved = rice.backups.user_state(home) / 'backups' / report['current_backup'] / '.config/fastfetch/config.jsonc'
|
||||
self.assertEqual(saved.read_text(), current)
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
self.assertEqual(target.read_text(), 'original personal config\n')
|
||||
self.assertTrue(Path(str(target) + '.new').is_file())
|
||||
|
||||
def test_backup_preview_rejects_unknown_paths_and_destinations_outside_home(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
home, outside = root / 'home', root / 'outside'
|
||||
home.mkdir(); outside.mkdir()
|
||||
backup_id = '20260101T000000.000000Z'
|
||||
backup = rice.backups.user_state(home) / 'backups' / backup_id
|
||||
source = backup / '.config/fastfetch/config.jsonc'
|
||||
source.parent.mkdir(parents=True); source.write_text('previous')
|
||||
(home / '.config').symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaises(RuntimeError):
|
||||
rice.backups.plan(home, backup_id, rice.backup_paths())
|
||||
(home / '.config').unlink()
|
||||
(backup / 'unrecognized').write_text('unrecognized')
|
||||
with self.assertRaises(RuntimeError):
|
||||
rice.backups.plan(home, backup_id, rice.backup_paths())
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_shared_config_backup_keeps_original_before_all_selected_patches(self):
|
||||
selection = rice.load_selection()
|
||||
selection['configs'] = ['kde-style', 'kde-shortcuts']
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
relative = Path('.config/kwinrc')
|
||||
target = home / relative; target.parent.mkdir()
|
||||
original = '[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n'
|
||||
target.write_text(original)
|
||||
style, shortcuts = root / 'style', root / 'shortcuts'
|
||||
style.write_text('[Appearance]\ncolour=purple\n')
|
||||
shortcuts.write_text('[Desktops]\nNumber=1\n')
|
||||
entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
|
||||
with patch.object(rice, 'config_entries', return_value=entries):
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
saved = list((rice.backups.user_state(home) / 'backups').rglob('kwinrc'))
|
||||
self.assertEqual(len(saved), 1)
|
||||
self.assertEqual(saved[0].read_text(), original)
|
||||
self.assertIn('colour=purple', target.read_text())
|
||||
self.assertIn('Number=1', target.read_text())
|
||||
|
||||
def test_combined_report_correlates_scopes_and_excludes_unrelated_runs(self):
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=['gaming'], configs=['fastfetch'], flatpaks=['org.vinegarhq.Sober'], services=['audio'])
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
with patch.object(rice.reporting, 'SYSTEM_STATE', root / 'system'):
|
||||
with patch.object(rice.reporting.os, 'geteuid', return_value=0):
|
||||
rice.reporting.record(selection, 'current', 'packages', home, {'present': ['app-misc/fastfetch'], 'skipped': ['games-util/example'], 'missing': ['games-util/example']})
|
||||
with patch.object(rice.reporting.os, 'geteuid', return_value=1000):
|
||||
rice.reporting.record(selection, 'current', 'flatpaks', home, {'failed': ['org.vinegarhq.Sober']}, RuntimeError('download failed'))
|
||||
rice.reporting.record(selection, 'different', 'configs', home, {'applied': ['unrelated-file']})
|
||||
result = rice.reporting.summarize(selection, home, {}, ['manual reminder'], 'current')
|
||||
self.assertEqual(result['steps']['packages']['status'], 'partial')
|
||||
self.assertEqual(result['steps']['flatpaks']['status'], 'failed')
|
||||
self.assertEqual(result['steps']['configs']['status'], 'not attempted')
|
||||
self.assertEqual(result['steps']['services-user']['status'], 'not attempted')
|
||||
self.assertNotIn('unrelated-file', json.dumps(result))
|
||||
self.assertEqual(result['manual_steps'], ['manual reminder'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
LIB = Path(__file__).resolve().parents[1] / 'lib'
|
||||
|
||||
|
||||
def module(name):
|
||||
spec = importlib.util.spec_from_file_location(name, LIB / (name + '.py'))
|
||||
value = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(value)
|
||||
return value
|
||||
|
||||
|
||||
setup = module('setup')
|
||||
boot = module('boot')
|
||||
|
||||
|
||||
class SetupTests(unittest.TestCase):
|
||||
def test_only_selected_overlays_are_staged_and_existing_ones_are_preserved(self):
|
||||
self.assertEqual(setup.required_repositories({'groups': ['kde']}), [])
|
||||
self.assertEqual(setup.required_repositories({'groups': ['tidewm']}), ['guru'])
|
||||
self.assertEqual(setup.required_repositories({'groups': ['gaming']}), ['guru', 'steam-overlay'])
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
def clone(command, **kwargs):
|
||||
destination = Path(command[-1])
|
||||
(destination / 'profiles').mkdir(parents=True)
|
||||
(destination / 'profiles/repo_name').write_text(destination.name + '\n')
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
with patch.object(setup, 'existing_repository', side_effect=lambda name: Path('/existing') if name == 'guru' else None), patch.object(setup.shutil, 'which', return_value='/usr/bin/git'), patch.object(setup.subprocess, 'run', side_effect=clone) as run:
|
||||
fetched = setup.stage_repositories({'groups': ['gaming']}, root)
|
||||
self.assertEqual(set(fetched), {'steam-overlay'})
|
||||
self.assertEqual(run.call_count, 1)
|
||||
config = root / 'etc/portage/repos.conf/fiw-dots-steam-overlay.conf'
|
||||
self.assertIn(str(root / 'var/db/repos/steam-overlay'), config.read_text())
|
||||
self.assertFalse((root / 'etc/portage/repos.conf/fiw-dots-guru.conf').exists())
|
||||
|
||||
def test_flatpak_failure_is_reported_and_installs_are_user_scoped(self):
|
||||
reports = []
|
||||
installed = set()
|
||||
def run(command, **kwargs):
|
||||
if command[:2] == ['flatpak', 'info']:
|
||||
return subprocess.CompletedProcess(command, 0 if command[-1] in installed else 1)
|
||||
if 'install' in command and command[-1] == 'org.localsend.localsend_app':
|
||||
installed.add(command[-1])
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
if 'install' in command:
|
||||
return subprocess.CompletedProcess(command, 1)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.shutil, 'which', return_value='/usr/bin/flatpak'), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report: reports.append(report)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
setup.install_flatpaks({'flatpaks': list(setup.FLATPAKS)}, lambda *args: 'install')
|
||||
self.assertEqual(reports[0]['installed'], ['org.localsend.localsend_app'])
|
||||
self.assertEqual(reports[0]['failed'], ['org.vinegarhq.Sober'])
|
||||
for call in commands.call_args_list:
|
||||
self.assertIn('--user', call.args[0])
|
||||
|
||||
def test_missing_services_are_reported_and_enabled_without_starting(self):
|
||||
reports = []
|
||||
def run(command, **kwargs):
|
||||
if 'show' in command:
|
||||
state = 'not-found\n' if command[-1] == 'wireplumber.service' else 'loaded\n'
|
||||
return subprocess.CompletedProcess(command, 0, state)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report, system: reports.append(report)):
|
||||
setup.enable_services({'services': ['audio', 'network']}, lambda *args: 'enable')
|
||||
self.assertEqual(reports[0]['missing'], ['wireplumber.service'])
|
||||
self.assertEqual(reports[0]['enabled'], ['pipewire.socket', 'pipewire-pulse.socket'])
|
||||
for call in commands.call_args_list:
|
||||
self.assertNotIn('--now', call.args[0])
|
||||
self.assertIn('--user', call.args[0])
|
||||
|
||||
|
||||
class BootTests(unittest.TestCase):
|
||||
def test_managed_limine_block_preserves_other_entries_and_rejects_bad_markers(self):
|
||||
original = 'timeout: 8\n\n/Other OS\n protocol: efi\n path: boot():/other.efi\n'
|
||||
block = boot.render_limine([{'version': 'test-gentoo'}], 'root=UUID=target-root ro')
|
||||
merged = boot.merge_limine(original, block)
|
||||
self.assertTrue(merged.startswith(original))
|
||||
self.assertEqual(boot.merge_limine(merged, block), merged)
|
||||
with self.assertRaises(RuntimeError):
|
||||
boot.merge_limine(original + boot.BEGIN, block)
|
||||
|
||||
def test_edited_limine_entries_get_proposal_while_external_entries_remain(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
image = root / 'kernel';image.write_bytes(b'kernel')
|
||||
initrd = root / 'initrd';initrd.write_bytes(b'initrd')
|
||||
images = [{'version': 'test-gentoo', 'kernel': str(image), 'initrd': str(initrd)}]
|
||||
saved = {'mode': 'limine', 'esp': str(root / 'esp'), 'cmdline': 'root=UUID=target-root ro'}
|
||||
(root / 'esp').mkdir()
|
||||
with patch.object(boot, 'discover_esp', return_value={'path': saved['esp']}), patch.object(boot, 'kernel_images', return_value=images):
|
||||
saved = boot.refresh(saved, root / 'backups')
|
||||
config = root / 'esp/EFI/Fiw-Gentoo/limine.conf'
|
||||
config.write_text(config.read_text() + '\n/Other OS\n protocol: efi\n')
|
||||
saved = boot.refresh(saved, root / 'backups')
|
||||
self.assertIn('/Other OS', config.read_text())
|
||||
config.write_text(config.read_text().replace('target-root', 'user-edited-root'))
|
||||
previous = config.read_text()
|
||||
boot.refresh(saved, root / 'backups')
|
||||
self.assertEqual(config.read_text(), previous)
|
||||
proposals = list(config.parent.glob('limine.conf.new.*'))
|
||||
self.assertEqual(len(proposals), 1)
|
||||
self.assertIn('/Other OS', proposals[0].read_text())
|
||||
|
||||
def test_failed_grub_generation_does_not_replace_existing_menu(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
folder = root / 'boot';(folder / 'grub').mkdir(parents=True)
|
||||
config = folder / 'grub/grub.cfg';config.write_text('working config\n')
|
||||
images = []
|
||||
def run(command, **kwargs):
|
||||
Path(command[-1]).write_text('partial failed config')
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
# /boot sources prevent normalization writes outside the staging dir.
|
||||
images = [{'version': 'test', 'kernel': '/boot/kernel-test', 'initrd': '/boot/initramfs-test.img'}]
|
||||
saved = {'mode': 'grub', 'esp': str(root), 'config_hash': boot.file_hash(config)}
|
||||
with patch.object(boot, 'GRUB_DIRECTORY', folder), patch.object(boot, 'discover_esp', return_value={'path': str(root)}), patch.object(boot, 'kernel_images', return_value=images), patch.object(boot.subprocess, 'run', side_effect=run):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
boot.refresh(saved, root / 'backups')
|
||||
self.assertEqual(config.read_text(), 'working config\n')
|
||||
self.assertFalse(list(config.parent.glob('.*.tmp')))
|
||||
|
||||
def test_firmware_add_preserves_bootorder_and_repeat_default_does_not_duplicate(self):
|
||||
target = {'disk': '/dev/testdisk', 'partition': 2, 'partuuid': 'target-partition'}
|
||||
loader = '/EFI/Fiw-Gentoo/BOOTX64.EFI'
|
||||
command = boot.firmware_commands(target, 'limine', loader, 'add', '')[0]
|
||||
self.assertIn('--create-only', command)
|
||||
self.assertNotIn('--create', command)
|
||||
listing = 'BootOrder: 0001,0002\nBoot0002* Fiw-Gentoo-limine HD(2,GPT,target-partition)/File(\\EFI\\Fiw-Gentoo\\BOOTX64.EFI)\n'
|
||||
commands = boot.firmware_commands(target, 'limine', loader, 'default', listing)
|
||||
self.assertEqual(commands, [['efibootmgr', '--bootorder', '0002,0001']])
|
||||
self.assertEqual(boot.firmware_commands(target, 'limine', loader, 'add', listing), [])
|
||||
self.assertEqual(boot.plan('keep'), {'mode': 'keep', 'changes': []})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compare explicit world/set selections with main and optional package lists."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def lines(path):
|
||||
if not path.is_file():
|
||||
return []
|
||||
return [line.split('#', 1)[0].strip() for line in path.read_text().splitlines()
|
||||
if line.split('#', 1)[0].strip()]
|
||||
|
||||
|
||||
def package(atom):
|
||||
value = atom.split('::', 1)[0].split('[', 1)[0].lstrip('<>=~')
|
||||
base, _, slot = value.partition(':')
|
||||
base = re.sub(r'-[0-9].*$', '', base)
|
||||
if not re.fullmatch(r'[A-Za-z0-9+_.-]+/[A-Za-z0-9+_.-]+', base):
|
||||
raise ValueError('Invalid explicit package atom.')
|
||||
return base + (':' + slot if slot else '')
|
||||
|
||||
|
||||
def explicit(world, world_sets, sets):
|
||||
selections, unresolved = {}, set()
|
||||
def read(items, origin, stack=()):
|
||||
for atom in items:
|
||||
if atom.startswith('@'):
|
||||
name = atom[1:]
|
||||
if not re.fullmatch(r'[A-Za-z0-9+_.-]+', name):
|
||||
raise ValueError('Invalid selected set name.')
|
||||
if name in stack:
|
||||
raise ValueError('Cyclic selected set: ' + name)
|
||||
source = sets / name
|
||||
if not source.is_file():
|
||||
unresolved.add(name)
|
||||
else:
|
||||
read(lines(source), '@' + name, stack + (name,))
|
||||
else:
|
||||
selections.setdefault(package(atom), set()).add(origin)
|
||||
read(lines(world), 'world')
|
||||
read(lines(world_sets), 'world_sets')
|
||||
return {atom: sorted(origins) for atom, origins in sorted(selections.items())}, sorted(unresolved)
|
||||
|
||||
|
||||
def audit(world, world_sets, sets, repo=REPO):
|
||||
selected, unresolved = explicit(world, world_sets, sets)
|
||||
coverage = {}
|
||||
for path in sorted((repo / 'packages').rglob('*.list')):
|
||||
if path.name == 'source.list':
|
||||
continue
|
||||
category = path.relative_to(repo / 'packages').with_suffix('').as_posix()
|
||||
for atom in lines(path):
|
||||
coverage.setdefault(package(atom), set()).add(category)
|
||||
for atom, category in [('sys-kernel/gentoo-kernel', 'kernel/custom'),
|
||||
('sys-kernel/gentoo-kernel-bin', 'kernel/binary')]:
|
||||
coverage.setdefault(atom, set()).add(category)
|
||||
exceptions = json.loads((repo / 'packages/audit-exceptions.json').read_text())
|
||||
matched = {atom: sorted(coverage[atom]) for atom in selected if atom in coverage}
|
||||
manual = {atom: exceptions[atom] for atom in selected if atom in exceptions and atom not in coverage}
|
||||
missing = sorted(set(selected) - set(matched) - set(manual))
|
||||
return {'explicit_count': len(selected), 'covered_count': len(matched),
|
||||
'coverage': matched, 'sources': selected, 'manual_helpers': manual,
|
||||
'missing': missing, 'unresolved_sets': unresolved}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--world', type=Path, default=Path('/var/lib/portage/world'))
|
||||
parser.add_argument('--world-sets', type=Path, default=Path('/var/lib/portage/world_sets'))
|
||||
parser.add_argument('--sets', type=Path, default=Path('/etc/portage/sets'))
|
||||
parser.add_argument('--json', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if not args.world.is_file() and not args.world_sets.is_file():
|
||||
parser.error('No Portage explicit selection files found.')
|
||||
result = audit(args.world, args.world_sets, args.sets)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(str(result['covered_count']) + '/' + str(result['explicit_count']) + ' explicit selections covered by package lists/kernel choices.')
|
||||
for atom, reason in result['manual_helpers'].items():
|
||||
print('Manual helper: ' + atom + ' — ' + reason)
|
||||
print('Missing main packages: ' + (', '.join(result['missing']) or 'none'))
|
||||
print('Unresolved selected sets: ' + (', '.join(result['unresolved_sets']) or 'none'))
|
||||
return 1 if result['missing'] or result['unresolved_sets'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -5,6 +5,7 @@ import hashlib
|
|||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -56,8 +57,11 @@ def check_assets():
|
|||
def main():
|
||||
subprocess.run(['python3', 'tools/check-private.py'], cwd=REPO, check=True)
|
||||
check_assets()
|
||||
subprocess.run(['python3', '-m', 'unittest', 'discover', '-s', 'tests'], cwd=REPO, check=True)
|
||||
subprocess.run(['go', 'test', './...'], cwd=REPO, check=True)
|
||||
for directory in ('lib', 'tools'):
|
||||
for path in (REPO / directory).rglob('*.py'):
|
||||
compile(path.read_bytes(), path.relative_to(REPO).as_posix(), 'exec')
|
||||
print('Python sources parse.', flush=True)
|
||||
subprocess.run(['go', 'build', '-o', os.devnull, './cmd/dots'], cwd=REPO, check=True)
|
||||
print('Portable CI checks passed; no installer actions were applied.', flush=True)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue