Compare commits

...

9 commits
v0.1.2 ... main

Author SHA1 Message Date
Fi3w0
0a2590256e Sync custom kernel to CachyOS 7.2.10-1 and fix overlay and ESP refresh
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 25s
Check and mirror to GitHub / github-mirror (push) Successful in 9s
- Kernel snapshot, patch and guard now match the running 7.2.10-fiw-nyx-v1.2.
- Overlay install replaces the tree instead of merging, so removed ebuilds
  cannot linger next to a newer Manifest; the old tree is restored on failure.
- Limine refresh removes ESP kernel folders the managed menu no longer lists.
- ESP discovery checks fstab FAT mounts, triggers x-systemd.automount and
  ignores the autofs placeholder.
2026-10-15 23:34:55 +02:00
Fi3w0
4118fbeb26 Kernel name: drop CachyOS release number (7.2.9-fiw-nyx-v1.2)
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 19s
Check and mirror to GitHub / github-mirror (push) Successful in 7s
fiw-cachy-patch no longer rewrites CONFIG_LOCALVERSION; the name is only the
Linux version plus -fiw-nyx-v<N>.
2026-10-08 04:01:56 +02:00
Fi3w0
8c9322b4ed Update custom kernel to CachyOS 7.2.9-2 (fiw-nyx-v1.2) and add Ryzen tuning
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 21s
Check and mirror to GitHub / github-mirror (push) Successful in 7s
- Kernel snapshot: CachyOS 7.2.9-2 patch, name -2-fiw-nyx-v1.2; later 7.2.x
  point releases reuse the patch, next series masked from snapshot.json
- Fiw's Ryzen preset: sysctl tuning (max_map_count, dirty bytes, BBR + fq)
- tidewm list gains mpvpaper; grim moves to dev
2026-10-08 03:43:01 +02:00
Fi3w0
ba98cf48a7 Start Kitty in Fish and fix boot and module restoration after QEMU checks
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 20s
Check and mirror to GitHub / github-mirror (push) Successful in 7s
2026-10-04 19:34:31 +02:00
Fi3w0
52d4e90883 Fix fresh Gentoo package resolution and record Stock VM validation
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 19s
Check and mirror to GitHub / github-mirror (push) Successful in 7s
2026-10-04 01:36:45 +02:00
Fi3w0
459341413a Disable Git metadata stamping in CI build
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 18s
Check and mirror to GitHub / github-mirror (push) Successful in 7s
2026-10-04 00:00:14 +02:00
Fi3w0
fd3d79f61f 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
2026-10-03 23:57:58 +02:00
fi3w0
732df73b90 Match Forgejo workflow to docker runner label
All checks were successful
Check and mirror to GitHub / checks (push) Successful in 36s
Check and mirror to GitHub / github-mirror (push) Successful in 6s
2026-10-03 23:37:40 +02:00
fi3w0
ca8c5de013 Add Forgejo CI and GitHub mirroring after checks
Some checks are pending
Check and mirror to GitHub / checks (push) Waiting to run
Check and mirror to GitHub / github-mirror (push) Blocked by required conditions
2026-10-03 23:28:26 +02:00
47 changed files with 5119 additions and 5126 deletions

50
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,50 @@
name: Check and mirror to GitHub
on:
push:
pull_request:
workflow_dispatch:
jobs:
checks:
runs-on: ${{ vars.CI_RUNNER_LABEL || 'docker' }}
container: node:22-bookworm
timeout-minutes: 20
steps:
- name: Prepare CI tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git ca-certificates
- name: Checkout
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Go
uses: https://github.com/actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.26.7'
cache: false
- name: Check code and bundled assets
run: python3 tools/ci/check.py
github-mirror:
needs: checks
if: github.event_name != 'pull_request' && github.repository == 'fiwdev/Fiw-Gentoo-Dots'
runs-on: ${{ vars.CI_RUNNER_LABEL || 'docker' }}
container: node:22-bookworm
timeout-minutes: 10
steps:
- name: Prepare mirror tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git openssh-client ca-certificates
- name: Checkout
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Publish branches and tags to GitHub
env:
GH_MIRROR_SSH_KEY: ${{ secrets.GH_MIRROR_SSH_KEY }}
MIRROR_SOURCE_REF: ${{ github.ref }}
MIRROR_SOURCE_SHA: ${{ github.sha }}
run: python3 tools/ci/mirror.py

31
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: Checks
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
container: node:22-bookworm
timeout-minutes: 20
steps:
- name: Prepare CI tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git ca-certificates
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.26.7'
cache: false
- name: Check code and bundled assets
run: python3 tools/ci/check.py

189
README.md
View file

@ -9,174 +9,111 @@
![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.
Stock and Fiw's Ryzen have been installed and checked in Gentoo QEMU/KVM.
The full TUI restore, KDE presets, optional apps, GRUB/Limine and ext4/Btrfs
booting passed. See [tested scope and hardware limits](docs/setup.md#current-scope).
**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.
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

@ -61,9 +61,10 @@
"label": "Kitty lavender profiles",
"group": "fiw-apps",
"packages": [
"x11-terms/kitty"
"x11-terms/kitty",
"app-shells/fish"
],
"note": "Select fonts separately for the bundled JetBrainsMono Nerd Font."
"note": "Both profiles start Fish. Select fonts separately for the bundled JetBrainsMono Nerd Font."
},
"neovim": {
"label": "Neovim transparent purple style",

View file

@ -5,6 +5,7 @@ italic_font auto
bold_italic_font auto
font_size 11.0
shell fish
linux_display_server auto
background_blur 32
dynamic_background_opacity yes

View file

@ -43,6 +43,10 @@ packages again, including requirements of apps whose installation was skipped.
Fish, Kitty, Neovim and Fastfetch have no Plasma or KWin requirements. Fonts
have their own checkbox; Kitty can use its fallback font if those are omitted.
Both Kitty profiles start Fish explicitly, so the Kitty preset requires Fish
even when the optional Fish preferences are unchecked.
For Fish in TTY and SSH logins too, run `chsh -s "$(command -v fish)"` as your
regular user after installing Fish, then log in again.
Neovim's config includes a pinned Catppuccin runtime in its native package
directory and loads it before the captured purple highlights. It needs no
plugin manager or runtime download. A built-in fallback remains available if

View file

@ -13,8 +13,9 @@ sudo ./install --selection local/selection.json --deploy-bootloader
sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi
```
Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
ESP type and FAT filesystem. The plan shows the selected partition, root
Discovery checks `/efi`, `/boot/efi`, `/boot` and FAT mount points from
`/etc/fstab` for the GPT ESP type and FAT filesystem. An `x-systemd.automount`
ESP is mounted by accessing it first. The plan shows the selected partition, root
filesystem, kernel arguments, installed kernel/initramfs candidates and
proposed loader config or commands. UUIDs are derived on the target and kept
in its local state; they are not shipped in the repo.
@ -41,7 +42,8 @@ followed by generic kernels, including an installed binary fallback.
A marked block contains the generated Gentoo entries. Entries outside that
block are preserved. Locally edited managed entries receive a `.new` proposal
instead of being overwritten. ESP free space is checked before copying kernel
artifacts. The protocol and paths follow the upstream
artifacts. Once the managed menu is current, kernel folders it no longer lists
are removed from the ESP; folders with other files are left alone. The protocol and paths follow the upstream
[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md).
## GRUB
@ -55,6 +57,11 @@ is generated before replacing it. If the previous menu was edited locally,
the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings
are used by `grub-mkconfig`.
Explicit deployment asks before replacing a conflicting GRUB menu or Limine
managed block, with a backup. Keeping it writes a proposal and leaves firmware
registration and order unchanged. Automatic kernel refreshes preserve edits
without prompting; review their proposals before using the new entries.
## Kernel updates and backups
Deployment installs a standalone refresh helper at
@ -72,13 +79,11 @@ live under `/var/lib/Fiw-Gentoo-Dots`. No existing disk partition is formatted.
Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs
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.
before changes. Automatic GRUB and Limine deployment and booting were checked
in Gentoo QEMU/KVM with ext4 and a Btrfs `@` root. The custom Ryzen kernel and
binary fallback booted, and addition/removal hooks and preservation of edited
menus were checked. Physical firmware and unsupported storage layouts still
need their own setup.
`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.

63
docs/ci.md Normal file
View file

@ -0,0 +1,63 @@
# CI and GitHub synchronization
Push changes to the primary
[Forgejo repository](https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots).
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 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).
Pull request runs perform checks without mirroring. GitHub's separate workflow
runs the same checks on received pushes and pull requests.
The runner uses a Debian container with Python 3.11 and pinned Go 1.26.7.
Remote checkout/setup actions use fixed commit IDs. CI checks the installer
code without installing Gentoo packages, applying live desktop configs or
deploying bootloaders. It verifies the existing release bundle; changing
frontend source requires rebuilding and committing its matching bundle.
## Forgejo setup
Repository Actions must be enabled and an available runner must match
`docker`. If your runner has another label, set the Forgejo repository
Actions variable `CI_RUNNER_LABEL` to that label. The runner needs Docker to
execute the workflow's container jobs. See the
[Forgejo 10 Actions guide](https://forgejo.org/docs/v10.0/user/actions/) for
runner and secret settings used by this instance.
Add the repository Actions secret **`GH_MIRROR_SSH_KEY`** with the complete
private SSH deploy key registered on the GitHub mirror. The initial key is
stored locally at `local/ci/github-mirror`, outside version control. The
corresponding GitHub deploy key is named **Forgejo mirror**, with write access.
GitHub documents [deploy key management](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys).
The workflow exposes the key only to the mirroring step and uses a temporary
mode-600 key file, removed when the process exits. GitHub's Ed25519 host key
is pinned in `tools/ci/github_known_hosts` from its authenticated metadata API.
The **Run workflow** action retries synchronization after adding a secret or
runner. Pushing another commit also triggers it.
## Sync behaviour
The mirror fetches the current source repository and checks that the triggering
ref still points to the commit reviewed by that CI run. Superseded runs skip
publication. Branches and annotated tags keep their original history and IDs.
Pushes are atomic and use ordinary fast-forward rules. Divergent GitHub edits
cause an error for review. GitHub-only refs and refs deleted at the source are
retained. Keep normal development on Forgejo to maintain a consistent mirror.
For an explicit manual sync with the initial local deploy key:
```sh
python3 tools/ci/mirror.py --key-file local/ci/github-mirror
```
The shared check command is:
```sh
python3 tools/ci/check.py
```

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

@ -8,6 +8,7 @@ The report includes:
- Completed, partial, failed, cancelled and not attempted steps.
- Confirmed present Portage packages and skipped or missing packages.
- NVIDIA module checks/builds for a selected custom kernel and binary fallback.
- Installed, existing, skipped or failed user Flatpaks.
- Enabled, missing or skipped service units.
- Applied configs, preserved conflicts and pending `.new` proposals.

View file

@ -1,36 +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 |
| `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

@ -65,6 +65,21 @@ recommended. Skipping a dependency also skips requested packages needing it.
A package report records requested, skipped and missing packages. There is a
final package confirmation before live Portage configuration changes.
Binary packages retain the binhost's USE settings; required dependency flags
are still checked. The preview includes dependency updates and replacements,
such as adding Steam's 32-bit libraries to a fresh multilib installation.
Custom kernel installs with NVIDIA also check its modules against the selected
custom kernel and binary fallback. Missing or outdated modules are compiled
for each, and failures appear in the package report.
The custom kernel uses the CachyOS 7.2.10-1 patch and is named `7.2.10-fiw-nyx-v1.2`. Newer
7.2.x point releases reuse that patch and config; the next series stays masked
until the snapshot is updated. Fiw's Ryzen preset also installs
`/etc/sysctl.d/70-fiw-dots-performance.conf` (CachyOS-style memory/network
values, `vm.max_map_count` for Proton games, TCP BBR with fq) and loads
`tcp_bbr`. No extra packages are needed for either.
Read-only resolution and an export are available separately:
```sh
@ -140,11 +155,19 @@ or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews,
firmware choices and kernel update integration. Other layouts can keep their
existing loader.
The optional [TideWM recipe](tidewm.md) follows the audited upstream master
branch and uses the normal compile-or-skip prompt. End-to-end package and
boot execution on a fresh Gentoo
installation remains unverified; development checks used temporary homes,
staged Portage configuration and mocked system commands.
Gentoo QEMU/KVM checks covered a fresh Stock installation, Fiw's Ryzen custom
kernel and binary fallback, all package sections, the complete TUI restore,
all 16 config presets, capture filtering, preserved edits, proposals and backups.
Plasma Login Manager, KDE/GTK styling, shortcuts, optional autostart, service
enabling and both user Flatpaks were checked. GRUB and Limine booted ext4 and
a Btrfs `@` root; kernel addition/removal hooks and menu conflicts were checked.
The Btrfs guest used a converted copy of the installed ext4 guest.
App checks cover launches and local configuration, including MangoHud,
GameMode, FiwNode audio and Apdatifier. [TideWM](tidewm.md) compiled and ran
with virtual graphics. NVIDIA modules were built and checked for both selected
kernels. Physical NVIDIA rendering, Wi-Fi/Bluetooth devices, real audio hardware,
account sign-ins and gameplay still need checks on the target device.
Do not publish the private original fiw-gentoo archive. It has different
history and host-specific files.

View file

@ -19,8 +19,11 @@ is enabled by default, including the session-specific portal files.
This is a source build. The main installer shows it as an additional
compile-or-skip choice; it is not part of the deliberately small source list.
A live recipe follows future upstream changes, while the current audit SHA
documents the version reviewed for this snapshot. Compilation of the
refreshed tree on a fresh device remains unverified.
documents the version reviewed for this snapshot. That commit compiled and
ran in the Gentoo QEMU/KVM guest, both nested inside Plasma and directly from
a TTY with virtual graphics. Kitty started Fish with the restored terminal
preferences in both sessions. Physical GPU and screencasting behaviour remain
device-specific checks.
After installation, choose **TideWM** at login. It creates its own default
configuration on first launch. No existing TideWM settings are captured or

View file

@ -10,6 +10,7 @@ import shlex
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
@ -27,13 +28,31 @@ def mount_info(path, exact=False):
text=True, capture_output=True)
if result.returncode:
raise RuntimeError('Not a mounted filesystem: ' + str(path))
data = json.loads(result.stdout)['filesystems'][0]
return data
# An x-systemd.automount point lists its autofs placeholder first.
mounted = [fs for fs in json.loads(result.stdout)['filesystems'] if fs['fstype'] != 'autofs']
if not mounted:
raise RuntimeError('Not a mounted filesystem: ' + str(path))
return mounted[-1]
def fstab_vfat():
result = subprocess.run(['findmnt', '--fstab', '--json', '--types', 'vfat', '--output', 'TARGET'],
text=True, capture_output=True)
if result.returncode or not result.stdout.strip():
return []
return [Path(fs['target']) for fs in json.loads(result.stdout)['filesystems']]
def discover_esp(path=None):
candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')]
if not path:
candidates += [target for target in fstab_vfat() if target not in candidates]
for candidate in candidates:
# Accessing an automount point mounts it; findmnt alone does not.
try:
os.listdir(candidate)
except OSError:
pass
try:
info = mount_info(candidate, exact=True)
except RuntimeError:
@ -229,7 +248,35 @@ def write(path, content, backups, mode=0o644):
tmp.replace(path)
def refresh(saved, backups, exclude=None):
def grub_config(output, images, exclude=None):
env = dict(os.environ, GRUB_TOP_LEVEL=images[0]['kernel'])
command = ['grub-mkconfig', '-o', str(output)]
if not exclude:
subprocess.run(command, env=env, check=True)
return
# Removal hooks can run before the kernel file disappears. Filter discovery
# through GRUB's library override, without moving installed boot artifacts.
library = Path('/usr/share/grub')
source = (library / 'grub-mkconfig_lib').read_text()
source, count = re.subn(r'\bgrub_file_is_not_garbage\s*\(\s*\)',
'fiw_dots_file_is_not_garbage ()', source, count=1)
if count != 1:
raise RuntimeError('Cannot filter this GRUB library; preserving the boot menu.')
paths = ['/boot/kernel-' + exclude] + [prefix + exclude for prefix in
('/boot/vmlinuz-', '/vmlinuz-', '/boot/vmlinux-', '/vmlinux-')]
source += '\ngrub_file_is_not_garbage () {\n case "$1" in\n ' + '|'.join(
shlex.quote(path) for path in paths) + ') return 1 ;;\n esac\n fiw_dots_file_is_not_garbage "$@"\n}\n'
with tempfile.TemporaryDirectory(prefix='fiw-dots-grub-') as directory:
root = Path(directory)
for path in library.iterdir():
if path.name != 'grub-mkconfig_lib':
(root / path.name).symlink_to(path)
(root / 'grub-mkconfig_lib').write_text(source)
subprocess.run(command, env=dict(env, pkgdatadir=directory), check=True)
def refresh(saved, backups, exclude=None, ask=None):
saved.pop('pending_config', None)
# Recheck the mount every time; never write into an unmounted ESP directory.
target = discover_esp(Path(saved['esp']))
images = kernel_images(exclude=exclude)
@ -246,7 +293,10 @@ def refresh(saved, backups, exclude=None):
config = GRUB_DIRECTORY / 'grub/grub.cfg'
old_hash = file_hash(config)
output = config
if old_hash and old_hash != saved.get('config_hash'):
edited = old_hash and old_hash != saved.get('config_hash')
replace = edited and ask and ask('Existing GRUB menu differs from the saved version. Replace it with the generated menu?',
['replace', 'keep'], 'keep') == 'replace'
if edited and not replace:
output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
else:
backup(config, backups)
@ -264,12 +314,13 @@ def refresh(saved, backups, exclude=None):
shutil.copy2(source, dest)
scratch = output.with_name('.' + output.name + '.tmp')
try:
subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True)
grub_config(scratch, images, exclude)
scratch.replace(output)
finally:
if scratch.exists():
scratch.unlink()
if output != config:
saved['pending_config'] = str(output)
print('Preserved edited GRUB config; review ' + str(output))
else:
saved['config_hash'] = file_hash(config)
@ -295,16 +346,37 @@ def refresh(saved, backups, exclude=None):
prior = managed_block(old)
block = render_limine(images, saved['cmdline'])
output = merge_limine(old, block)
if prior and block_hash(prior) != saved.get('block_hash'):
edited = prior and block_hash(prior) != saved.get('block_hash')
replace = edited and ask and ask('Existing Limine entries differ from the saved version. Replace the managed entries?',
['replace', 'keep'], 'keep') == 'replace'
if edited and not replace:
proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
write(proposal, output, backups)
saved['pending_config'] = str(proposal)
print('Preserved edited Limine entries; review ' + str(proposal))
elif output != old:
else:
if output != old:
write(config, output, backups)
saved['block_hash'] = block_hash(block)
# Only once the menu no longer lists them: drop removed kernels' copies.
prune_kernels(folder / 'kernels', {image['version'] for image in images})
return saved
def prune_kernels(directory, keep):
"""Remove ESP kernel folders that refresh created and the menu no longer uses."""
if not directory.is_dir():
return
for path in directory.iterdir():
if path.name in keep or path.is_symlink() or not path.is_dir():
continue
names = {child.name for child in path.iterdir()}
# Leave anything that is not purely this tool's vmlinuz/initramfs copy.
if names <= {'vmlinuz', 'initramfs.img', '.vmlinuz.tmp', '.initramfs.img.tmp'}:
shutil.rmtree(path)
print('Removed unused ESP kernel copy: ' + path.name)
def confirm(prompt, choices, default):
if not sys.stdin.isatty():
raise RuntimeError('Interactive confirmation required: ' + prompt)
@ -381,7 +453,12 @@ def deploy(mode, esp=None, ask=confirm):
else:
subprocess.run(proposed['commands'][0], check=True)
loader_path = '/EFI/Fiw-Gentoo/grubx64.efi'
saved = refresh(saved, backups)
saved = refresh(saved, backups, ask=ask)
pending = saved.pop('pending_config', None)
if pending and firmware != 'keep':
commands = []
firmware = 'keep'
print('Kept the existing boot menu; firmware entries and order remain unchanged. Review ' + pending)
# Native installkernel provides versioned /boot kernel/initrd files; our
# final hook refreshes only the chosen loader's own configuration.
write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups)
@ -403,7 +480,8 @@ def deploy(mode, esp=None, ask=confirm):
report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware,
'backups': str(backups)}, indent=2) + '\n')
print('Boot deployment finished. Report: ' + str(report))
return {'status': 'completed', 'mode': mode, 'firmware_choice': firmware,
return {'status': 'partial' if pending else 'completed', 'mode': mode, 'firmware_choice': firmware,
'review': [pending] if pending else [],
'report': str(report), 'backups': str(backups)}
@ -422,6 +500,7 @@ def main():
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backups = MANAGED / 'backups' / ('boot-refresh-' + stamp)
updated = refresh(saved, backups, args.exclude_version)
updated.pop('pending_config', None)
write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600)

View file

@ -341,7 +341,7 @@ def root_files(selection, repo_location=None):
result['etc/portage/sets/fiw-dots-' + group] = '\n'.join(packages) + '\n'
result['etc/portage/sets/fiw-dots'] = '\n'.join('@fiw-dots-' + g for g in package_map(selection)) + '\n'
result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n'
result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg --usepkg --binpkg-respect-use=n --with-bdeps=n ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n'
for name in required_repositories(selection):
if not existing_repository(name):
@ -353,7 +353,12 @@ def root_files(selection, repo_location=None):
result['etc/kernel/config.d/' + src.name] = src.read_text()
result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n'
result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text()
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n'
# Point releases of the tested series reuse the patch (see guard); a new series needs a new snapshot.
major, minor = json.loads((REPO / 'optional/kernel/snapshot.json').read_text())['patch_kernel'].split('.')[:2]
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel series; update snapshot and patch for the next one.\n>=sys-kernel/gentoo-kernel-' + major + '.' + str(int(minor) + 1) + '\n'
if selection['profile'] == 'fiw-ryzen':
result['etc/sysctl.d/70-fiw-dots-performance.conf'] = (REPO / 'optional/tuning/70-fiw-performance.conf').read_text()
result['etc/modules-load.d/fiw-dots-bbr.conf'] = (REPO / 'optional/tuning/bbr.conf').read_text()
if selection['bootloader'] != 'keep':
result['etc/portage/package.use/fiw-dots-boot'] = ('sys-kernel/installkernel systemd dracut -grub -uki -ukify -ugrd -efistub -refind -systemd-boot\n')
if selection['bootloader'] == 'grub':
@ -425,16 +430,20 @@ def source_builds(output):
values = set(re.findall(r'^\[ebuild[^\]]*\]\s+(?:\([^)]*\)\s+)?([^\s:]+)', output, re.M))
# Dedicated -bin ebuilds install upstream binaries; these do not require
# compiling the application just because Portage labels them "ebuild".
# This released Plasma widget installs QML/scripts without a compile phase.
no_compile = {'kde-misc/apdatifier-gentoo'}
# These install upstream binaries or QML rather than compile applications.
no_compile = {'app-editors/vscode', 'media-sound/spotify',
'games-util/steam-launcher', 'kde-misc/apdatifier-gentoo'}
return {value for value in values if not cp_from_cpv(value).endswith('-bin')
and not value.startswith(('acct-user/', 'acct-group/', 'virtual/'))
and cp_from_cpv(value) not in no_compile}
def resolve(stage, selection, packages, execute=False):
exclusions = ' '.join(source_policy(selection))
command = ['emerge', '--config-root=' + str(stage), '--color=n', '--getbinpkg', '--usepkg',
'--usepkg-exclude=' + exclusions, '--autounmask=n', '--noreplace']
'--binpkg-respect-use=n', '--with-bdeps=n',
'--usepkg-exclude=' + exclusions, '--autounmask=n',
'--update', '--deep', '--newuse', '--backtrack=100']
if not execute:
command += ['--pretend']
env = dict(os.environ, EMERGE_DEFAULT_OPTS='')
@ -443,6 +452,61 @@ def resolve(stage, selection, packages, execute=False):
stderr=None if execute else subprocess.STDOUT)
def nvidia_modules():
"""Check both selected distribution kernels, not just /usr/src/linux."""
snapshot = json.loads((REPO / 'optional/kernel/snapshot.json').read_text())
driver = subprocess.check_output(['portageq', 'best_version', '/', 'x11-drivers/nvidia-drivers'], text=True).strip()
expected = re.sub(r'-r\d+$', '', driver.split('nvidia-drivers-', 1)[1])
result = {'checked': [], 'rebuilt': [], 'missing': []}
for atom in [snapshot['atom'], 'sys-kernel/gentoo-kernel-bin']:
cpv = subprocess.check_output(['portageq', 'best_version', '/', atom], text=True).strip()
contents = (Path('/var/db/pkg') / cpv / 'CONTENTS').read_text() if cpv else ''
versions = sorted(set(re.findall(r'/(?:usr/)?lib/modules/([^/ ]+)/', contents)))
if not versions:
result['missing'].append('NVIDIA kernel target: ' + atom)
for version in versions:
result['checked'].append(version)
def ready():
return all(subprocess.run(['modinfo', '-k', version, '-F', 'version', module],
text=True, capture_output=True).stdout.strip() == expected
for module in ['nvidia', 'nvidia_drm', 'nvidia_modeset', 'nvidia_uvm'])
if ready():
continue
build = Path('/lib/modules') / version / 'build'
print('Building NVIDIA modules for ' + version, flush=True)
command = ['emerge', '--oneshot', '--nodeps', '--usepkg=n', '--getbinpkg=n',
'--autounmask=n', '=' + driver]
if build.is_dir() and subprocess.run(command, env=dict(os.environ, KERNEL_DIR=str(build))).returncode == 0 and ready():
result['rebuilt'].append(version)
else:
result['missing'].append('NVIDIA modules for ' + version)
return result
def replace_tree(source, target):
"""Replace target with a copy of source; merging would keep removed ebuilds
next to a Manifest that no longer lists their distfiles."""
target.parent.mkdir(parents=True, exist_ok=True)
scratch = target.with_name('.' + target.name + '-new-' + str(os.getpid()))
retired = target.with_name('.' + target.name + '-old-' + str(os.getpid()))
try:
shutil.copytree(source, scratch, symlinks=True)
if target.exists() or target.is_symlink():
target.rename(retired)
try:
scratch.rename(target)
except OSError:
if retired.exists() and not target.exists():
retired.rename(target)
raise
finally:
for leftover in (scratch, retired):
if leftover.is_symlink():
leftover.unlink()
elif leftover.exists():
shutil.rmtree(leftover)
def install_packages(selection):
if os.geteuid() != 0:
raise RuntimeError('Package installation requires root. Run sudo ./install --install-packages --selection <file>.')
@ -455,6 +519,8 @@ def install_packages(selection):
raise RuntimeError('No packages selected')
with tempfile.TemporaryDirectory(prefix='fiw-dots-portage-') as temp:
stage = Path(temp)
# Portage evaluates ebuild metadata as its unprivileged user.
stage.chmod(0o755)
shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True,
ignore=shutil.ignore_patterns('gnupg'))
profile = stage / 'etc/portage/make.profile'
@ -498,6 +564,11 @@ def install_packages(selection):
if not accepted:
print('All requested packages were skipped; no live configuration applied.')
return {'status': 'skipped', 'requested': requested, 'skipped': skipped, 'missing': requested}
build_modules = (selection['kernel'] == 'custom' and 'system' in selection['groups']
and 'x11-drivers/nvidia-drivers' in accepted
and any(atom.startswith('=sys-kernel/gentoo-kernel-') for atom in accepted))
if build_modules:
print('NVIDIA modules will be checked and compiled when needed for the selected custom kernel and binary fallback.')
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
return {'status': 'cancelled', 'requested': requested, 'skipped': skipped}
publish_repositories(fetched)
@ -531,7 +602,7 @@ def install_packages(selection):
overlay = Path('/var/db/repos/fiw-dots')
if overlay.exists():
shutil.copytree(overlay, backup_root / 'overlay', symlinks=True)
shutil.copytree(REPO / 'overlay', overlay, dirs_exist_ok=True, symlinks=True)
replace_tree(REPO / 'overlay', overlay)
make_conf = Path('/etc/portage/make.conf')
include = 'source /etc/portage/fiw-dots.conf'
if include not in make_conf.read_text():
@ -550,20 +621,24 @@ def install_packages(selection):
if final.returncode or ({cp_from_cpv(x) for x in source_builds(final.stdout)} - approved):
raise RuntimeError('The final package plan changed. Review it before retrying; backups are at ' + str(backup_root))
result = resolve(Path('/'), selection, ['@fiw-dots-selected'], execute=True)
modules = nvidia_modules() if result.returncode == 0 and build_modules else {}
missing = [atom for atom in requested if subprocess.run(
['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode != 0]
report = {'requested': requested, 'present': [atom for atom in accepted if atom not in missing],
'skipped': skipped, 'emerge_exit_code': result.returncode,
'missing': missing,
'modules': modules, 'failed': modules.get('missing', []),
'pending': []}
state = Path('/var/lib/Fiw-Gentoo-Dots')
state.mkdir(parents=True, exist_ok=True)
(state / ('packages-' + stamp + '.json')).write_text(json.dumps(report, indent=2) + '\n')
print('Package report: ' + str(state / ('packages-' + stamp + '.json')))
print('Missing packages: ' + (', '.join(missing) or 'none'))
if result.returncode:
error = RuntimeError('Some packages did not install; consult the package report and emerge output.')
if build_modules and result.returncode == 0:
print('Missing NVIDIA modules: ' + (', '.join(modules.get('missing', [])) or 'none'))
if result.returncode or modules.get('missing'):
error = RuntimeError('Some packages or kernel modules did not install; consult the package report and emerge output.')
error.report = report
raise error
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
@ -577,6 +652,7 @@ def check_packages(selection):
requested = list(dict.fromkeys(p for values in package_map(selection).values() for p in values))
with tempfile.TemporaryDirectory(prefix='fiw-dots-check-') as temp:
stage = Path(temp)
stage.chmod(0o755)
shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True,
ignore=shutil.ignore_patterns('gnupg'))
profile = stage / 'etc/portage/make.profile'

View file

@ -15,9 +15,10 @@ trap 'rm -rf "$work"' EXIT
cd "$work"
# Newest CachyOS release for this kernel version (cachyos-7.2.8-1, -2, ...)
tag=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50" |
releases=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50")
tag=$(printf '%s\n' "$releases" |
grep -oE "\"tag_name\": *\"cachyos-${ver//./\\.}-[0-9]+\"" |
grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1)
grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1 || true)
[[ -n $tag ]] || { echo "CachyOS has no release for $ver yet" >&2; exit 1; }
echo "Using $tag"
@ -39,8 +40,6 @@ mkdir -p "$patchdir"
rm -f "$patchdir"/0100-cachyos-*.patch
install -m644 "${tag}.patch" "$patchdir/0100-${tag}.patch"
sed -i "s/^FIW_CACHY_PV=.*/FIW_CACHY_PV=${ver}/" "$guard"
# kernel name: <ver>-<cachyos release>-<rest>, only the release number changes
sed -i -E "s/^CONFIG_LOCALVERSION=\"-[0-9]+-/CONFIG_LOCALVERSION=\"-${tag##*-}-/" "$name_cfg"
grep ^CONFIG_LOCALVERSION "$name_cfg"
echo "Installed $patchdir/0100-${tag}.patch; guard now allows ${ver}."
echo "Next: emerge -1 =sys-kernel/gentoo-kernel-${ver}"

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,5 +1,7 @@
# uname -r → <linux version>-<cachyos release>-fiw-nyx-v<own version>
# e.g. 7.2.7-1-fiw-nyx-v1.1. fiw-cachy-patch updates the cachyos release number;
# bump v<N> by hand when you change your own tweaks.
# History: v1 = "-1-fiwzen-v1" (first build), v1.1 = renamed to fiw-nyx.
CONFIG_LOCALVERSION="-1-fiw-nyx-v1.1"
# uname -r → <linux version>-fiw-nyx-v<own version>, e.g. 7.2.10-fiw-nyx-v1.2.
# Only the kernel version plus Fiw's name: no CachyOS release number or other extras.
# Bump v<N> by hand when you change your own tweaks.
# History: v1 = "-1-fiwzen-v1" (first build), v1.1 = renamed to fiw-nyx,
# v1.2 = 2026-10-08 rebuild (7.2.9, CachyOS 7.2.9-2 patch), "-<cachyos release>" dropped
# from the name from the next kernel build on (first: 7.2.10, CachyOS 7.2.10-1 patch).
CONFIG_LOCALVERSION="-fiw-nyx-v1.2"

View file

@ -3,14 +3,17 @@
export KBUILD_BUILD_USER=fiw
export KBUILD_BUILD_HOST=gentoo
# The CachyOS patch is a diff against exactly one kernel version. Refuse to build
# any other version until scripts/fiw-cachy-patch has regenerated it.
# Versions the current patch is known to apply to (space separated).
# 2026-09-26: CachyOS has no 7.2.8 yet; the 7.2.7-1 patch applies to 7.2.8 (3 hunks
# with fuzz, checked by hand: only context shifted by 7.2.8's APX lines).
FIW_CACHY_PV="7.2.7 7.2.8"
# Version the CachyOS patch in /etc/portage/patches was generated for.
# fiw-cachy-patch rewrites this line (the postsync hook runs it when CachyOS
# has a release for a new gentoo-kernel). A newer point release in the same
# series (7.2.x) reuses the existing patch and config.d unchanged; if the patch
# no longer applies, the build stops and the installed kernels stay as they are.
FIW_CACHY_PV=7.2.10
pre_src_prepare() {
if [[ " ${FIW_CACHY_PV} " != *" ${PV} "* ]]; then
die "CachyOS patch is for ${FIW_CACHY_PV}, not ${PV}. Run: fiw-cachy-patch ${PV}"
[[ ${PV} == "${FIW_CACHY_PV}" ]] && return
if [[ ${PV%.*} != "${FIW_CACHY_PV%.*}" ]]; then
die "CachyOS patch is for ${FIW_CACHY_PV}; ${PV} is a new series. Run: fiw-cachy-patch ${PV}"
fi
ewarn "No CachyOS patch for ${PV} yet: reusing the ${FIW_CACHY_PV} patch and config."
ewarn "Fallbacks if it misbehaves: gentoo-kernel-bin entry, or CachyOS."
}

View file

@ -1,10 +1,9 @@
{
"atom": "=sys-kernel/gentoo-kernel-7.2.8",
"patch_kernel": "7.2.7",
"cachyos_tag": "cachyos-7.2.7-1",
"atom": "=sys-kernel/gentoo-kernel-7.2.10",
"patch_kernel": "7.2.10",
"cachyos_tag": "cachyos-7.2.10-1",
"tested_kernel_versions": [
"7.2.7",
"7.2.8"
"7.2.10"
],
"patch_sha256": "c1aca3cbd113866e5660cb827403ee73a8ca6f0eb5dcb497fad90561e059ef75"
"patch_sha256": "ed5f06622f9045ac3938e7c89270622f647252dd6e9e6c699b7ff8368a829b1a"
}

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# SpotX-Bash + spicetify (Marketplace) on Gentoo's media-sound/spotify.
# Run as your regular user after the first setup and after every Spotify update.
# Write access comes from an ACL (/etc/portage/env/media-sound/spotify).
# Write access must be configured explicitly before opting in.
set -euo pipefail
client=/opt/spotify/spotify-client
@ -9,7 +9,7 @@ spotx_url=https://raw.githubusercontent.com/SpotX-Official/SpotX-Bash/main/spotx
spicetify=$HOME/.spicetify/spicetify
[[ -w $client/Apps/xpui.spa && -w $client/spotify ]] || {
echo "No write access to $client. Run once as root: the optional Spotify ACL setup described in docs/spotify.md" >&2
echo "No write access to $client. Review docs/spotify.md and configure client write access explicitly before opting in." >&2
exit 1
}

View file

@ -0,0 +1,22 @@
# Installed as /etc/sysctl.d/70-fiw-dots-performance.conf (Fiw's Ryzen preset)
# Desktop/gaming tuning, mostly from CachyOS-Settings (70-cachyos-settings.conf).
# swappiness 100 / page-cluster 0 (zram) and nmi_watchdog 0 already come from the CachyOS patch.
# Games under Proton (CS2, Star Citizen, Hogwarts Legacy...) need many memory maps.
# Gentoo default 65530; Arch/CachyOS/Fedora use 1048576.
vm.max_map_count = 1048576
# Keep directory/inode cache longer (default 100).
vm.vfs_cache_pressure = 50
# Flush dirty pages in small chunks instead of % of 32 GB RAM: fewer stalls on big writes.
vm.dirty_bytes = 268435456
vm.dirty_background_bytes = 67108864
vm.dirty_writeback_centisecs = 1500
# Bigger network receive queue.
net.core.netdev_max_backlog = 4096
# TCP BBR congestion control with fq pacing (module loaded by modules-load.d/bbr.conf).
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

2
optional/tuning/bbr.conf Normal file
View file

@ -0,0 +1,2 @@
# Installed as /etc/modules-load.d/fiw-dots-bbr.conf (Fiw's Ryzen preset)
tcp_bbr

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

@ -14,3 +14,4 @@ dev-qt/qtdeclarative
media-video/pipewire
dev-db/sqlite
llvm-core/clang
gui-apps/grim

View file

@ -2,4 +2,4 @@
gui-wm/tidewm::fiw-dots
gui-apps/xwayland-satellite::guru
gui-apps/wlr-randr::guru
gui-apps/grim
gui-apps/mpvpaper

View file

@ -1,8 +1,8 @@
llvm-core/llvm heavy.conf notmpfs.conf
llvm-core/clang heavy.conf notmpfs.conf
dev-qt/qtwebengine heavy.conf notmpfs.conf
net-libs/nodejs heavy.conf
dev-lang/spidermonkey heavy.conf
media-libs/embree heavy.conf notmpfs.conf
media-gfx/blender heavy.conf notmpfs.conf
media-gfx/openvdb heavy.conf notmpfs.conf
llvm-core/llvm fiw-dots-heavy.conf fiw-dots-notmpfs.conf
llvm-core/clang fiw-dots-heavy.conf fiw-dots-notmpfs.conf
dev-qt/qtwebengine fiw-dots-heavy.conf fiw-dots-notmpfs.conf
net-libs/nodejs fiw-dots-heavy.conf
dev-lang/spidermonkey fiw-dots-heavy.conf
media-libs/embree fiw-dots-heavy.conf fiw-dots-notmpfs.conf
media-gfx/blender fiw-dots-heavy.conf fiw-dots-notmpfs.conf
media-gfx/openvdb fiw-dots-heavy.conf fiw-dots-notmpfs.conf

View file

@ -1,2 +1,2 @@
sys-kernel/gentoo-kernel notmpfs.conf
sys-devel/gcc notmpfs.conf
sys-kernel/gentoo-kernel fiw-dots-notmpfs.conf
sys-devel/gcc fiw-dots-notmpfs.conf

View file

@ -1 +1 @@
media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* avx avx2 f16c fma3 popcnt sse sse2 sse3 sse4_1 sse4_2 ssse3
media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* sse sse2

View file

@ -1,3 +1,4 @@
media-video/obs-studio browser websocket nvenc v4l
media-video/ffmpeg opus nvenc lame theora xvid vpx
app-arch/7zip rar
dev-libs/libdbusmenu gtk3

View file

@ -1 +1,2 @@
*/* CPU_FLAGS_X86: aes avx avx2 avx512_bf16 avx512_bitalg avx512_vbmi2 avx512_vnni avx512_vp2intersect avx512_vpopcntdq avx512bw avx512cd avx512dq avx512f avx512ifma avx512vbmi avx512vl avx_vnni bmi1 bmi2 f16c fma3 mmx mmxext pclmul popcnt rdrand sha sse sse2 sse3 sse4_1 sse4_2 sse4a ssse3 vpclmulqdq
media-libs/opencv CPU_FLAGS_X86: -* avx avx2 f16c fma3 popcnt sse sse2 sse3 sse4_1 sse4_2 ssse3

View file

@ -65,6 +65,7 @@ virtual/zlib abi_x86_32
x11-drivers/nvidia-drivers abi_x86_32
x11-libs/cairo abi_x86_32
x11-libs/libdrm abi_x86_32
x11-libs/libpciaccess abi_x86_32
x11-libs/libX11 abi_x86_32
x11-libs/libXau abi_x86_32
x11-libs/libxcb abi_x86_32

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

@ -183,10 +183,16 @@ def capture_fish():
def capture_kitty():
def portable_profile(text, name):
text = re.sub(r'(?m)^linux_display_server\s+wayland\s*$',
'linux_display_server auto', text)
text = re.sub(r'(?m)^shell\s+.*\n?', '', text)
if name == 'kitty-common.conf':
text = text.rstrip() + '\n\nshell fish\n'
return text
for name in ['kitty.conf', 'kitty-main.conf', 'kitty-bright.conf', 'kitty-common.conf']:
copy('kitty', '.config/kitty/' + name,
lambda t: re.sub(r'(?m)^linux_display_server\s+wayland\s*$',
'linux_display_server auto', t))
lambda t: portable_profile(t, name))
def capture_neovim():

69
tools/ci/check.py Normal file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Check portable code and the shipped assets without applying the installer."""
import gzip
import hashlib
import importlib.util
import io
import json
import os
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def sha(data):
return hashlib.sha256(data).hexdigest()
def check_assets():
spec = importlib.util.spec_from_file_location('launcher', REPO / 'lib/launcher.py')
launcher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(launcher)
root = REPO / 'assets/bin'
metadata = json.loads((root / 'tui.json').read_text())
if metadata['version'] != (REPO / 'VERSION').read_text().strip():
raise RuntimeError('TUI version does not match VERSION; rebuild the release bundle.')
if metadata['source_sha256'] != launcher.source_hash(REPO):
raise RuntimeError('TUI source changed; rebuild the release bundle.')
for binary in metadata['binaries'].values():
path = root / binary['file']
if path.parent.resolve() != root.resolve():
raise RuntimeError('Invalid TUI archive path.')
compressed = path.read_bytes()
if sha(compressed) != binary['gzip_sha256']:
raise RuntimeError('TUI archive checksum mismatch.')
size = binary['size']
if not isinstance(size, int) or not 0 < size <= 64 * 1024 * 1024:
raise RuntimeError('Invalid TUI size.')
with gzip.GzipFile(fileobj=io.BytesIO(compressed)) as stream:
executable = stream.read(size + 1)
if len(executable) != size or sha(executable) != binary['sha256']:
raise RuntimeError('TUI executable checksum mismatch.')
line = binary['gzip_sha256'] + ' ' + binary['file']
if line not in (root / 'SHA256SUMS').read_text().splitlines():
raise RuntimeError('SHA256SUMS does not match the TUI archive.')
for name, entry in json.loads((REPO / 'assets/editor-themes.json').read_text()).items():
directory = REPO / entry['root']
if not directory.resolve().is_relative_to(REPO):
raise RuntimeError('Invalid editor asset directory.')
actual = {path.relative_to(directory).as_posix(): sha(path.read_bytes())
for path in sorted(directory.rglob('*')) if path.is_file()}
if actual != entry['sha256']:
raise RuntimeError('Editor asset fingerprints changed: ' + name)
print('Prebuilt TUI and editor asset fingerprints match.', flush=True)
def main():
subprocess.run(['python3', 'tools/check-private.py'], cwd=REPO, check=True)
check_assets()
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', '-buildvcs=false', '-o', os.devnull, './cmd/dots'], cwd=REPO, check=True)
print('Portable CI checks passed; no installer actions were applied.', flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1 @@
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

64
tools/ci/mirror.py Normal file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Publish Forgejo branches/tags to GitHub without deleting or forcing refs."""
import argparse
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
REPO = Path(__file__).resolve().parents[2]
SOURCE = 'https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git'
GITHUB_HOST = 'github.com'
GITHUB_REPOSITORY = 'Fi3w0/Fiw-Gentoo-Dots'
TARGET = f'ssh://git@{GITHUB_HOST}/{GITHUB_REPOSITORY}.git'
def mirror(source=SOURCE, target=TARGET, key=None, source_ref=None, source_sha=None):
environment = dict(os.environ, GIT_TERMINAL_PROMPT='0')
environment.pop('GH_MIRROR_SSH_KEY', None)
with tempfile.TemporaryDirectory(prefix='fiw-github-mirror-') as temporary:
root = Path(temporary)
remote = urlsplit(target)
if remote.scheme == 'ssh' and remote.hostname == GITHUB_HOST:
if not key or not key.strip():
raise RuntimeError('Add the GH_MIRROR_SSH_KEY repository secret in Forgejo before mirroring.')
identity = root / 'identity'
identity.write_text(key.strip() + '\n')
identity.chmod(0o600)
environment['GIT_SSH_COMMAND'] = shlex.join([
'ssh', '-F', '/dev/null', '-i', str(identity),
'-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(REPO / 'tools/ci/github_known_hosts')])
snapshot = root / 'source.git'
subprocess.run(['git', 'clone', '--bare', '--no-local', source, str(snapshot)], env=environment, check=True)
if source_ref:
if not source_ref.startswith(('refs/heads/', 'refs/tags/')):
raise RuntimeError('Mirror source must be a branch or tag ref.')
subprocess.run(['git', 'check-ref-format', source_ref], env=environment, check=True)
actual = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', source_ref + '^{commit}'], env=environment, text=True, capture_output=True)
reviewed = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', (source_sha or '') + '^{commit}'], env=environment, text=True, capture_output=True)
if actual.returncode or reviewed.returncode or actual.stdout != reviewed.stdout:
print('Source ref changed or was removed since this CI run; a newer run can publish it.')
return
subprocess.run(['git', '-C', str(snapshot), 'push', '--atomic', target,
'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*'], env=environment, check=True)
print('Forgejo branches and tags published to GitHub.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', default=SOURCE)
parser.add_argument('--target', default=TARGET)
parser.add_argument('--key-file', type=Path, help='Local deploy key for an explicit manual sync')
args = parser.parse_args()
key = args.key_file.read_text() if args.key_file else os.environ.get('GH_MIRROR_SSH_KEY')
mirror(args.source, args.target, key, os.environ.get('MIRROR_SOURCE_REF'), os.environ.get('MIRROR_SOURCE_SHA'))
if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
raise SystemExit(str(error))