Trim maintenance clutter and add desktop screenshots
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 21s
Check and mirror to GitHub / github-mirror (push) Successful in 8s

This commit is contained in:
Fi3w0 2026-10-03 23:57:58 +02:00
parent 732df73b90
commit fd3d79f61f
19 changed files with 89 additions and 1312 deletions

191
README.md
View file

@ -9,178 +9,109 @@
![Gentoo](https://img.shields.io/badge/Gentoo-54487A?logo=gentoo&logoColor=white)
![KDE Plasma](https://img.shields.io/badge/KDE_Plasma-1D99F3?logo=kde&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-purple)
![Status](https://img.shields.io/badge/status-work_in_progress-yellow)
[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
![Fiw's Gentoo desktop](assets/Screenshots/Main-screen.png)
My running desktop. Wallpapers and panels stay local; the saved Fastfetch
preset uses the built-in Gentoo logo.
### Installer
![Terminal installer options](assets/Screenshots/Install-script.png)
## 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View file

@ -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")
}
}

View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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).

View file

@ -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"

View file

@ -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."
}

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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())

View file

@ -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)