Compare commits
No commits in common. "main" and "v0.1.2" have entirely different histories.
47 changed files with 5127 additions and 5120 deletions
|
|
@ -1,50 +0,0 @@
|
|||
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
31
.github/workflows/ci.yml
vendored
|
|
@ -1,31 +0,0 @@
|
|||
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
189
README.md
|
|
@ -9,111 +9,174 @@
|
|||

|
||||

|
||||

|
||||

|
||||
|
||||
[Setup](docs/setup.md) · [Shortcuts](docs/shortcuts.md) · [Styling](docs/kde-style.md) · [Packages](#packages)
|
||||
[KDE styling](docs/kde-style.md) · [Shortcuts](docs/shortcuts.md) · [Packages](#package-sections) · [Setup](docs/setup.md)
|
||||
|
||||
</div>
|
||||
|
||||
## About
|
||||
---
|
||||
|
||||
My personal Gentoo restoration kit, mainly for my own devices:
|
||||
## Overview
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
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).
|
||||
- **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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Desktop
|
||||
|
||||

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

|
||||
**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.
|
||||
|
||||
## Start
|
||||
|
||||
```sh
|
||||
git clone https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
|
||||
git clone --branch v0.1.2 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
|
||||
cd Fiw-Gentoo-Dots
|
||||
./install
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Build settings | Kernel |
|
||||
|---|---|---|
|
||||
| Stock | Portable settings, binaries preferred | Generic Gentoo binary kernel |
|
||||
| Fiw's Ryzen | Ryzen 9900X / Zen 5 tuning and NVIDIA | Custom kernel + binary fallback |
|
||||
| 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 |
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Packages
|
||||
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
|
||||
|
||||
| List | Contents |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [dev](packages/dev.list) | Languages and development tools |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Configs
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
```sh
|
||||
python3 tools/capture.py --config kde-shortcuts
|
||||
python3 tools/capture.py --config kitty neovim
|
||||
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/check-private.py
|
||||
python3 -m unittest discover -s tests
|
||||
go test ./...
|
||||
```
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
Original code and configs: [MIT](LICENSE). Bundled third-party assets keep
|
||||
their own licences; see [credits](docs/assets.md).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">A personal Gentoo setup by Fiw.</div>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB |
207
cmd/dots/main_test.go
Normal file
207
cmd/dots/main_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -61,10 +61,9 @@
|
|||
"label": "Kitty lavender profiles",
|
||||
"group": "fiw-apps",
|
||||
"packages": [
|
||||
"x11-terms/kitty",
|
||||
"app-shells/fish"
|
||||
"x11-terms/kitty"
|
||||
],
|
||||
"note": "Both profiles start Fish. Select fonts separately for the bundled JetBrainsMono Nerd Font."
|
||||
"note": "Select fonts separately for the bundled JetBrainsMono Nerd Font."
|
||||
},
|
||||
"neovim": {
|
||||
"label": "Neovim transparent purple style",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ italic_font auto
|
|||
bold_italic_font auto
|
||||
font_size 11.0
|
||||
|
||||
shell fish
|
||||
linux_display_server auto
|
||||
background_blur 32
|
||||
dynamic_background_opacity yes
|
||||
|
|
|
|||
|
|
@ -43,10 +43,6 @@ 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
|
||||
|
|
|
|||
27
docs/boot.md
27
docs/boot.md
|
|
@ -13,9 +13,8 @@ sudo ./install --selection local/selection.json --deploy-bootloader
|
|||
sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi
|
||||
```
|
||||
|
||||
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
|
||||
Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
|
||||
ESP type and FAT filesystem. 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.
|
||||
|
|
@ -42,8 +41,7 @@ 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. 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
|
||||
artifacts. The protocol and paths follow the upstream
|
||||
[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md).
|
||||
|
||||
## GRUB
|
||||
|
|
@ -57,11 +55,6 @@ 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
|
||||
|
|
@ -79,11 +72,13 @@ 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. 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.
|
||||
before changes. Full boot execution on a fresh installation remains unverified.
|
||||
|
||||
## Retained helpers
|
||||
|
||||
`optional/fiw-limine-splice` is the earlier helper for Fiw's marker/snippet
|
||||
layout. It is retained for manual use and is not installed by this deployment
|
||||
or by the GRUB variant. The new refresh helper manages its own separate config.
|
||||
|
||||
`optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select
|
||||
a bootloader. See the [helper list](scripts.md) before using it.
|
||||
a bootloader. See the [helper list](scripts.md) before using legacy helpers.
|
||||
|
|
|
|||
63
docs/ci.md
63
docs/ci.md
|
|
@ -1,63 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
29
docs/package-audit.md
Normal file
29
docs/package-audit.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# 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.
|
||||
|
|
@ -8,7 +8,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,36 @@
|
|||
# Commands and helpers
|
||||
# Helpers and what they do
|
||||
|
||||
| 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. |
|
||||
| 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 |
|
||||
|
||||
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 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.
|
||||
|
||||
Confirmed boot deployment installs its own refresh helper and kernel hooks.
|
||||
The default `keep` choice leaves them disabled. See [boot setup](boot.md).
|
||||
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.
|
||||
|
||||
Spotify modifications are optional and at your own risk. This project does
|
||||
not endorse bypassing paid features. See [Spotify setup](spotify.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.
|
||||
|
|
|
|||
|
|
@ -65,21 +65,6 @@ 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
|
||||
|
|
@ -155,19 +140,11 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Do not publish the private original fiw-gentoo archive. It has different
|
||||
history and host-specific files.
|
||||
|
|
|
|||
|
|
@ -19,11 +19,8 @@ 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. 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.
|
||||
documents the version reviewed for this snapshot. Compilation of the
|
||||
refreshed tree on a fresh device remains unverified.
|
||||
|
||||
After installation, choose **TideWM** at login. It creates its own default
|
||||
configuration on first launch. No existing TideWM settings are captured or
|
||||
|
|
|
|||
97
lib/boot.py
97
lib/boot.py
|
|
@ -10,7 +10,6 @@ import shlex
|
|||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -28,31 +27,13 @@ def mount_info(path, exact=False):
|
|||
text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Not a mounted filesystem: ' + str(path))
|
||||
# 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']]
|
||||
data = json.loads(result.stdout)['filesystems'][0]
|
||||
return data
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -248,35 +229,7 @@ def write(path, content, backups, mode=0o644):
|
|||
tmp.replace(path)
|
||||
|
||||
|
||||
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)
|
||||
def refresh(saved, backups, exclude=None):
|
||||
# Recheck the mount every time; never write into an unmounted ESP directory.
|
||||
target = discover_esp(Path(saved['esp']))
|
||||
images = kernel_images(exclude=exclude)
|
||||
|
|
@ -293,10 +246,7 @@ def refresh(saved, backups, exclude=None, ask=None):
|
|||
config = GRUB_DIRECTORY / 'grub/grub.cfg'
|
||||
old_hash = file_hash(config)
|
||||
output = config
|
||||
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:
|
||||
if old_hash and old_hash != saved.get('config_hash'):
|
||||
output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
|
||||
else:
|
||||
backup(config, backups)
|
||||
|
|
@ -314,13 +264,12 @@ def refresh(saved, backups, exclude=None, ask=None):
|
|||
shutil.copy2(source, dest)
|
||||
scratch = output.with_name('.' + output.name + '.tmp')
|
||||
try:
|
||||
grub_config(scratch, images, exclude)
|
||||
subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True)
|
||||
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)
|
||||
|
|
@ -346,37 +295,16 @@ def refresh(saved, backups, exclude=None, ask=None):
|
|||
prior = managed_block(old)
|
||||
block = render_limine(images, saved['cmdline'])
|
||||
output = merge_limine(old, block)
|
||||
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:
|
||||
if prior and block_hash(prior) != saved.get('block_hash'):
|
||||
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))
|
||||
else:
|
||||
if output != old:
|
||||
elif 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)
|
||||
|
|
@ -453,12 +381,7 @@ 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, 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)
|
||||
saved = refresh(saved, backups)
|
||||
# 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)
|
||||
|
|
@ -480,8 +403,7 @@ 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': 'partial' if pending else 'completed', 'mode': mode, 'firmware_choice': firmware,
|
||||
'review': [pending] if pending else [],
|
||||
return {'status': 'completed', 'mode': mode, 'firmware_choice': firmware,
|
||||
'report': str(report), 'backups': str(backups)}
|
||||
|
||||
|
||||
|
|
@ -500,7 +422,6 @@ 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)
|
||||
|
||||
|
||||
|
|
|
|||
92
lib/rice.py
92
lib/rice.py
|
|
@ -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 --usepkg --binpkg-respect-use=n --with-bdeps=n ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\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/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,12 +353,7 @@ 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()
|
||||
# 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()
|
||||
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'
|
||||
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':
|
||||
|
|
@ -430,20 +425,16 @@ 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".
|
||||
# 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'}
|
||||
# This released Plasma widget installs QML/scripts without a compile phase.
|
||||
no_compile = {'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',
|
||||
'--binpkg-respect-use=n', '--with-bdeps=n',
|
||||
'--usepkg-exclude=' + exclusions, '--autounmask=n',
|
||||
'--update', '--deep', '--newuse', '--backtrack=100']
|
||||
'--usepkg-exclude=' + exclusions, '--autounmask=n', '--noreplace']
|
||||
if not execute:
|
||||
command += ['--pretend']
|
||||
env = dict(os.environ, EMERGE_DEFAULT_OPTS='')
|
||||
|
|
@ -452,61 +443,6 @@ 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>.')
|
||||
|
|
@ -519,8 +455,6 @@ 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'
|
||||
|
|
@ -564,11 +498,6 @@ 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)
|
||||
|
|
@ -602,7 +531,7 @@ def install_packages(selection):
|
|||
overlay = Path('/var/db/repos/fiw-dots')
|
||||
if overlay.exists():
|
||||
shutil.copytree(overlay, backup_root / 'overlay', symlinks=True)
|
||||
replace_tree(REPO / 'overlay', overlay)
|
||||
shutil.copytree(REPO / 'overlay', overlay, dirs_exist_ok=True, symlinks=True)
|
||||
make_conf = Path('/etc/portage/make.conf')
|
||||
include = 'source /etc/portage/fiw-dots.conf'
|
||||
if include not in make_conf.read_text():
|
||||
|
|
@ -621,24 +550,20 @@ 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 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.')
|
||||
if result.returncode:
|
||||
error = RuntimeError('Some packages 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:
|
||||
|
|
@ -652,7 +577,6 @@ 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'
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@ trap 'rm -rf "$work"' EXIT
|
|||
cd "$work"
|
||||
|
||||
# Newest CachyOS release for this kernel version (cachyos-7.2.8-1, -2, ...)
|
||||
releases=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50")
|
||||
tag=$(printf '%s\n' "$releases" |
|
||||
tag=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50" |
|
||||
grep -oE "\"tag_name\": *\"cachyos-${ver//./\\.}-[0-9]+\"" |
|
||||
grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1 || true)
|
||||
grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1)
|
||||
[[ -n $tag ]] || { echo "CachyOS has no release for $ver yet" >&2; exit 1; }
|
||||
echo "Using $tag"
|
||||
|
||||
|
|
@ -40,6 +39,8 @@ 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}"
|
||||
|
|
|
|||
54
optional/fiw-limine-splice
Executable file
54
optional/fiw-limine-splice
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# fiw-limine-splice <esp-mountpoint>
|
||||
# Replaces the fiw-gentoo block in <esp>/limine.conf with the snippet Gentoo left
|
||||
# at <esp>/<gentoo-machine-id>/limine-entries.conf after a Gentoo kernel update.
|
||||
# Menu order (Fiw): Gentoo, CachyOS, other entries, Windows last.
|
||||
set -euo pipefail
|
||||
esp=${1:?usage: fiw-limine-splice <esp>}
|
||||
conf=$esp/limine.conf
|
||||
begin='# >>> fiw-gentoo (managed by fiw-limine, do not edit)'
|
||||
end='# <<< fiw-gentoo'
|
||||
|
||||
shopt -s nullglob
|
||||
snippets=("$esp"/*/fiw-gentoo.marker)
|
||||
((${#snippets[@]} == 1)) || { echo "fiw-limine-splice: expected 1 Gentoo snippet, found ${#snippets[@]}" >&2; exit 1; }
|
||||
snippet=$(dirname "${snippets[0]}")/limine-entries.conf
|
||||
[[ -f $conf && -f $snippet ]] || exit 0
|
||||
|
||||
tmp=$conf.fiw-tmp
|
||||
# Drop the old Gentoo block, split the rest into header + top-level entries
|
||||
# (a top-level entry starts at a line "/X" and runs to the next one), then
|
||||
# print: header, Gentoo block, CachyOS, other entries, Windows entries.
|
||||
awk -v b="$begin" -v e="$end" -v snip="$snippet" '
|
||||
BEGIN {n = 0}
|
||||
$0==b {skip=1} skip {if ($0==e) skip=0; next}
|
||||
/^comment: machine-id=/ {mid=$0; next}
|
||||
/^\/[^\/]/ {n++; if (mid != "") ent[n] = mid "\n"; mid=""}
|
||||
{ ent[n] = ent[n] $0 "\n" }
|
||||
END {
|
||||
if (mid != "") ent[n] = ent[n] mid "\n"
|
||||
for (i = 1; i <= n; i++) {
|
||||
h = ent[i]; sub(/\n.*/, "", h)
|
||||
if (h ~ /^\/\+?CachyOS/) cls[i] = 1
|
||||
else if (ent[i] ~ /Windows|bootmgfw\.efi/) cls[i] = 3
|
||||
else cls[i] = 2
|
||||
}
|
||||
out = ent[0]
|
||||
sub(/\n+$/, "\n", out)
|
||||
out = out "\n" b "\n"
|
||||
while ((getline l < snip) > 0) out = out l "\n"
|
||||
out = out e "\n\n"
|
||||
for (c = 1; c <= 3; c++) for (i = 1; i <= n; i++) if (cls[i] == c) out = out ent[i]
|
||||
sub(/\n+$/, "\n", out)
|
||||
printf "%s", out
|
||||
}' "$conf" >"$tmp"
|
||||
|
||||
# Refuse to write if any non-Gentoo line got lost.
|
||||
if ! diff <(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$conf" | sort) \
|
||||
<(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$tmp" | sort) >/dev/null; then
|
||||
rm -f "$tmp"; echo "fiw-limine-splice: reorder would change entries, leaving limine.conf alone" >&2; exit 1
|
||||
fi
|
||||
cmp -s "$tmp" "$conf" && { rm -f "$tmp"; exit 0; }
|
||||
cp -p "$conf" "$conf.before-fiw"
|
||||
mv "$tmp" "$conf"
|
||||
sync -f "$conf"
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# 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"
|
||||
# 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"
|
||||
|
|
|
|||
|
|
@ -3,17 +3,14 @@
|
|||
export KBUILD_BUILD_USER=fiw
|
||||
export KBUILD_BUILD_HOST=gentoo
|
||||
|
||||
# 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
|
||||
# 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"
|
||||
pre_src_prepare() {
|
||||
[[ ${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}"
|
||||
if [[ " ${FIW_CACHY_PV} " != *" ${PV} "* ]]; then
|
||||
die "CachyOS patch is for ${FIW_CACHY_PV}, not ${PV}. 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."
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"atom": "=sys-kernel/gentoo-kernel-7.2.10",
|
||||
"patch_kernel": "7.2.10",
|
||||
"cachyos_tag": "cachyos-7.2.10-1",
|
||||
"atom": "=sys-kernel/gentoo-kernel-7.2.8",
|
||||
"patch_kernel": "7.2.7",
|
||||
"cachyos_tag": "cachyos-7.2.7-1",
|
||||
"tested_kernel_versions": [
|
||||
"7.2.10"
|
||||
"7.2.7",
|
||||
"7.2.8"
|
||||
],
|
||||
"patch_sha256": "ed5f06622f9045ac3938e7c89270622f647252dd6e9e6c699b7ff8368a829b1a"
|
||||
"patch_sha256": "c1aca3cbd113866e5660cb827403ee73a8ca6f0eb5dcb497fad90561e059ef75"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 must be configured explicitly before opting in.
|
||||
# Write access comes from an ACL (/etc/portage/env/media-sound/spotify).
|
||||
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. Review docs/spotify.md and configure client write access explicitly before opting in." >&2
|
||||
echo "No write access to $client. Run once as root: the optional Spotify ACL setup described in docs/spotify.md" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# Installed as /etc/modules-load.d/fiw-dots-bbr.conf (Fiw's Ryzen preset)
|
||||
tcp_bbr
|
||||
3
packages/audit-exceptions.json
Normal file
3
packages/audit-exceptions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"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."
|
||||
}
|
||||
|
|
@ -14,4 +14,3 @@ dev-qt/qtdeclarative
|
|||
media-video/pipewire
|
||||
dev-db/sqlite
|
||||
llvm-core/clang
|
||||
gui-apps/grim
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
gui-wm/tidewm::fiw-dots
|
||||
gui-apps/xwayland-satellite::guru
|
||||
gui-apps/wlr-randr::guru
|
||||
gui-apps/mpvpaper
|
||||
gui-apps/grim
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
sys-kernel/gentoo-kernel fiw-dots-notmpfs.conf
|
||||
sys-devel/gcc fiw-dots-notmpfs.conf
|
||||
sys-kernel/gentoo-kernel notmpfs.conf
|
||||
sys-devel/gcc notmpfs.conf
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* sse sse2
|
||||
media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* avx avx2 f16c fma3 popcnt sse sse2 sse3 sse4_1 sse4_2 ssse3
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
*/* 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
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ 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
|
||||
|
|
|
|||
108
tests/test_configs.py
Normal file
108
tests/test_configs.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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()
|
||||
119
tests/test_devices_capture.py
Normal file
119
tests/test_devices_capture.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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()
|
||||
131
tests/test_proposals.py
Normal file
131
tests/test_proposals.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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()
|
||||
81
tests/test_release.py
Normal file
81
tests/test_release.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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()
|
||||
119
tests/test_restoration.py
Normal file
119
tests/test_restoration.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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()
|
||||
143
tests/test_setup.py
Normal file
143
tests/test_setup.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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()
|
||||
92
tools/audit-packages.py
Normal file
92
tools/audit-packages.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/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())
|
||||
|
|
@ -183,16 +183,10 @@ 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: portable_profile(t, name))
|
||||
lambda t: re.sub(r'(?m)^linux_display_server\s+wayland\s*$',
|
||||
'linux_display_server auto', t))
|
||||
|
||||
|
||||
def capture_neovim():
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
#!/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))
|
||||
Loading…
Add table
Reference in a new issue