feat(state): opt-in auto-commit of state mutations (Phase 4)
settings.autoCommit — toggled from System › Auto-commit (self-gated on the flake being a git repo) — makes every nomarchy-theme-sync apply/set also git-commit theme-state.json in the downstream flake, so settings history is `git log`. Off by default. Design: the flag is live-read by the tool on each write (nothing in Nix consumes it → instant toggle, no rebuild, no new option); the commit is pathspec-limited to theme-state.json so unrelated dirty files are never swept up; it fires when the flag is on before OR after the write, so the disable-toggle itself lands in history; same-value writes no-op (diff against HEAD); missing git identity falls back to Nomarchy <nomarchy@localhost>; `bg next` is deliberately excluded. Rider fix: `get` now prints booleans JSON-style (true, not Python's True) — un-sticking the System menu's "Auto timezone (on/off)" label, whose `= true` comparison could never match. All shell consumers already normalise true|True, so no other behavior change. Verified: V0 (py_compile, nix flake check) + V1 (HM generation builds, generated nomarchy-menu passes bash -n, wiring present in the rendered script) + a 7-assertion sandbox-repo round-trip covering enable/set/ same-value/disable/post-disable/apply paths and the identity fallback. Remaining: on-machine check queued in agent/HARDWARE-QUEUE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -311,6 +311,14 @@ instant. Internally it's two keys: `settings.nightlight.installed` (sticky —
|
|||||||
gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
|
gates the unit, the first enable rebuilds) and `settings.nightlight.on` (the
|
||||||
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
|
instant on/off). Expect more `nomarchy.*` toggles to migrate to this pattern.
|
||||||
|
|
||||||
|
**Auto-commit (opt-in):** System › Auto-commit makes every `apply`/`set`
|
||||||
|
mutation also `git commit` theme-state.json in your flake — *only* that
|
||||||
|
file, so unrelated dirty work is never swept up — turning your settings
|
||||||
|
history into `git log`. Off by default; the toggle is instant (nothing in
|
||||||
|
Nix consumes the flag), the off-write is itself committed so history stays
|
||||||
|
consistent, and wallpaper cycling (`bg next`) is deliberately excluded —
|
||||||
|
the current wallpaper rides along with the next real commit.
|
||||||
|
|
||||||
### Per-theme app assets (`themes/<slug>/`)
|
### Per-theme app assets (`themes/<slug>/`)
|
||||||
|
|
||||||
Recoloring covers 95% of theming; the rest is one optional assets directory
|
Recoloring covers 95% of theming; the rest is one optional assets directory
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ next, in what order*.
|
|||||||
- Agents take the topmost actionable item (see LOOP.md). Finished items
|
- Agents take the topmost actionable item (see LOOP.md). Finished items
|
||||||
are **deleted** here — the journal + git log are the record; durable
|
are **deleted** here — the journal + git log are the record; durable
|
||||||
design notes get a ✓-entry in docs/ROADMAP.md if worth keeping.
|
design notes get a ✓-entry in docs/ROADMAP.md if worth keeping.
|
||||||
|
- Item numbers are **stable IDs** — never renumbered or reused. A gap in
|
||||||
|
the sequence means shipped (or dropped) work; new items take the next
|
||||||
|
free number regardless of tier.
|
||||||
- Tags: `[blocked:hw]` needs real hardware (see HARDWARE-QUEUE.md) ·
|
- Tags: `[blocked:hw]` needs real hardware (see HARDWARE-QUEUE.md) ·
|
||||||
`[human]` needs Bernardo · `[stuck]` two failed attempts, needs help ·
|
`[human]` needs Bernardo · `[stuck]` two failed attempts, needs help ·
|
||||||
`[big]` must be split before starting.
|
`[big]` must be split before starting.
|
||||||
@@ -19,19 +22,6 @@ next, in what order*.
|
|||||||
|
|
||||||
## NOW
|
## NOW
|
||||||
|
|
||||||
### 1. Opt-in auto-commit of the flake on state mutations
|
|
||||||
Phase 4 of the in-flake-state principle (phases 1–3: night-light,
|
|
||||||
keyboard memory, display resolution — shipped). Every menu write
|
|
||||||
(`nomarchy-theme-sync set/apply`) already does `git add -N`; add an
|
|
||||||
opt-in (a `settings.autoCommit` flag, itself menu-toggled) that commits
|
|
||||||
**owned files only** (theme-state.json) with a generated message after
|
|
||||||
each mutation. **Why:** closes the loop on "your checkout is your
|
|
||||||
machine" — settings changes become history. **Done when:** off by
|
|
||||||
default; toggleable from the System menu; commits fire on apply/set;
|
|
||||||
never commits unrelated dirty files; graceful when the repo has no user
|
|
||||||
git identity. **Verify:** V1 + a round-trip on the theme-sync CLI in a
|
|
||||||
sandbox repo; V2 if wired into a check.
|
|
||||||
|
|
||||||
### 2. CI: run the check suite on every push (Forgejo Actions)
|
### 2. CI: run the check suite on every push (Forgejo Actions)
|
||||||
First slice of ROADMAP § Automated upstream lock bumps, promoted to NOW
|
First slice of ROADMAP § Automated upstream lock bumps, promoted to NOW
|
||||||
because agents now push to `main` autonomously — an always-on net under
|
because agents now push to `main` autonomously — an always-on net under
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ QA machine), the **T14s** (webcam case).
|
|||||||
- [ ] **Visual theme pass** — live ISO: all six identity themes (bars) +
|
- [ ] **Visual theme pass** — live ISO: all six identity themes (bars) +
|
||||||
the four authored rofi `.rasi` (nord/retro-82/lumon/kanagawa) look
|
the four authored rofi `.rasi` (nord/retro-82/lumon/kanagawa) look
|
||||||
right, not just parse.
|
right, not just parse.
|
||||||
|
- [ ] **Auto-commit on a real machine** — System › Auto-commit toggles on
|
||||||
|
(row shows state, notification fires); a theme apply from SUPER+T then
|
||||||
|
shows a `nomarchy: apply theme …` commit in `~/.nomarchy` (`git log`);
|
||||||
|
unrelated dirty files in the checkout stay uncommitted; toggle off is
|
||||||
|
itself the last commit. Also: the "Auto timezone (on/off)" row label
|
||||||
|
now reflects the real state (the `= true` comparison fix).
|
||||||
|
|
||||||
## AMD dev box only
|
## AMD dev box only
|
||||||
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ Template:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-04 — Opt-in auto-commit shipped (loop iteration #1)
|
||||||
|
- **Task:** BACKLOG NOW#1 — auto-commit of state mutations (in-flake
|
||||||
|
state Phase 4). First autonomous /loop iteration.
|
||||||
|
- **Did:** `settings.autoCommit` live-read by nomarchy-theme-sync;
|
||||||
|
pathspec-limited commit of theme-state.json on apply/set (fires if flag
|
||||||
|
on before OR after the write → the off-toggle commits too); identity
|
||||||
|
fallback; same-value no-op; `bg` excluded. Menu: System › Auto-commit
|
||||||
|
(instant, self-gated on `.git`). Rider: `get` prints booleans as JSON
|
||||||
|
`true`/`false`, fixing the stuck "Auto timezone (on/off)" menu label.
|
||||||
|
BACKLOG numbering switched to stable IDs (no renumbering).
|
||||||
|
- **Verified:** V0 (py_compile, flake check) + V1 (HM generation builds;
|
||||||
|
generated menu `bash -n`) + a 7-assertion sandbox git-repo round-trip
|
||||||
|
(enable/set/same-value/disable/post-disable/apply/identity-fallback).
|
||||||
|
- **Pending:** V3 queued in HARDWARE-QUEUE (on-machine toggle + apply).
|
||||||
|
- **Next suggestion:** NOW#2 (CI checks-on-push) — but its runner half
|
||||||
|
may need Bernardo to register a Forgejo runner; deliver the workflow
|
||||||
|
regardless.
|
||||||
|
|
||||||
## 2026-07-04 — Backlog revision pass (human-requested)
|
## 2026-07-04 — Backlog revision pass (human-requested)
|
||||||
- **Task:** Bernardo asked for a philosophy-aligned revision of BACKLOG.md
|
- **Task:** Bernardo asked for a philosophy-aligned revision of BACKLOG.md
|
||||||
("I'm sure I'm forgetting important items").
|
("I'm sure I'm forgetting important items").
|
||||||
|
|||||||
@@ -784,6 +784,24 @@ how to override it. Items marked ✓ are shipped.
|
|||||||
has **no CI today** (manual `nix flake update` only). Explicitly *not* a
|
has **no CI today** (manual `nix flake update` only). Explicitly *not* a
|
||||||
binary cache: compile-from-source is a deliberate values call (Gentoo-style),
|
binary cache: compile-from-source is a deliberate values call (Gentoo-style),
|
||||||
so this automates the *config/lock* channel, not artifact distribution.
|
so this automates the *config/lock* channel, not artifact distribution.
|
||||||
|
- ✓ **Opt-in auto-commit of state mutations (in-flake state, Phase 4):**
|
||||||
|
`settings.autoCommit` (menu: **System › Auto-commit**, self-gated on the
|
||||||
|
flake being a git repo) makes every `apply`/`set` also `git commit`
|
||||||
|
theme-state.json in the downstream flake. Design decisions: the flag is
|
||||||
|
**live-read by the tool** on each write — nothing in Nix consumes it, so
|
||||||
|
the toggle is instant, no rebuild, no option-surface addition; the commit
|
||||||
|
is **pathspec-limited** (`git commit -- theme-state.json`) so unrelated
|
||||||
|
dirty files are never swept up; it fires when the flag is on before *or*
|
||||||
|
after the write, so the disable-toggle itself lands in history instead of
|
||||||
|
staying forever-dirty; a same-value `set` no-ops (diff against HEAD); a
|
||||||
|
missing git identity falls back to `Nomarchy <nomarchy@localhost>`;
|
||||||
|
`bg next` is deliberately excluded (runtime wallpaper churn — the path
|
||||||
|
rides along with the next real commit). Verified: V1 (HM generation
|
||||||
|
builds, generated menu `bash -n` green) + a 7-assertion sandbox-repo
|
||||||
|
round-trip incl. the apply path; on-machine check queued. Rider fix:
|
||||||
|
`get` now prints booleans JSON-style (`true`, not Python's `True`) —
|
||||||
|
which also un-sticks the System menu's "Auto timezone (on/off)" label,
|
||||||
|
whose `= true` comparison could never match before.
|
||||||
- **"nomarchy" control center:** a single TUI/GUI front-end over the common
|
- **"nomarchy" control center:** a single TUI/GUI front-end over the common
|
||||||
toggles — theme, power profile, opt-in services, display, DND — built on
|
toggles — theme, power profile, opt-in services, display, DND — built on
|
||||||
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already
|
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already
|
||||||
|
|||||||
@@ -387,6 +387,22 @@ ${themeRows}
|
|||||||
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
|
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
|
||||||
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
|
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
|
||||||
|
|
||||||
|
autocommit)
|
||||||
|
# Toggle opt-in auto-commit: every menu/theme mutation also commits
|
||||||
|
# theme-state.json (that file only) in the downstream flake, so
|
||||||
|
# settings history is `git log`. Nothing in Nix consumes the flag —
|
||||||
|
# the tool reads the live state on each write — so the toggle is
|
||||||
|
# instant, no rebuild. The off-write commits too (the tool fires
|
||||||
|
# when the flag was on before OR after), keeping history consistent.
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then
|
||||||
|
nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch
|
||||||
|
notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake."
|
||||||
|
else
|
||||||
|
nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
|
||||||
|
notify-send "Auto-commit" "On — each change commits theme-state.json in your flake."
|
||||||
|
fi
|
||||||
|
exit 0 ;;
|
||||||
|
|
||||||
snapshot)
|
snapshot)
|
||||||
# btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so launch
|
# btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so launch
|
||||||
# the keyboard-driven snapper browser instead: a terminal running it
|
# the keyboard-driven snapper browser instead: a terminal running it
|
||||||
@@ -443,6 +459,12 @@ ${themeRows}
|
|||||||
then row "Auto timezone (on)" preferences-system-time
|
then row "Auto timezone (on)" preferences-system-time
|
||||||
else row "Auto timezone (off)" preferences-system-time
|
else row "Auto timezone (off)" preferences-system-time
|
||||||
fi
|
fi
|
||||||
|
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
|
||||||
|
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
|
||||||
|
then row "Auto-commit (on)" git
|
||||||
|
else row "Auto-commit (off)" git
|
||||||
|
fi
|
||||||
|
fi
|
||||||
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
|
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
|
||||||
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
||||||
row "Power profile" preferences-system-power
|
row "Power profile" preferences-system-power
|
||||||
@@ -460,6 +482,7 @@ ${themeRows}
|
|||||||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||||||
*"Night light"*) exec "$0" nightlight ;;
|
*"Night light"*) exec "$0" nightlight ;;
|
||||||
*"Auto timezone"*) exec "$0" autotimezone ;;
|
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||||||
|
*"Auto-commit"*) exec "$0" autocommit ;;
|
||||||
*Snapshots*) exec "$0" snapshot ;;
|
*Snapshots*) exec "$0" snapshot ;;
|
||||||
*"Power profile"*) exec "$0" power-profile ;;
|
*"Power profile"*) exec "$0" power-profile ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
@@ -486,7 +509,7 @@ ${themeRows}
|
|||||||
esac ;;
|
esac ;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|vpn|snapshot]" >&2
|
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2
|
||||||
exit 64 ;;
|
exit 64 ;;
|
||||||
esac
|
esac
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -116,6 +116,39 @@ def write_state(state: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_commit_enabled(state: dict) -> bool:
|
||||||
|
return bool((state.get("settings") or {}).get("autoCommit"))
|
||||||
|
|
||||||
|
|
||||||
|
def auto_commit(message: str) -> None:
|
||||||
|
"""Opt-in (settings.autoCommit): commit theme-state.json — and nothing
|
||||||
|
else — after a mutation, so settings history is `git log`. The pathspec
|
||||||
|
keeps unrelated dirty files out of the commit; a missing git identity
|
||||||
|
falls back to a Nomarchy one so a fresh machine never errors. Callers
|
||||||
|
fire this when the flag is on before OR after the write, so the
|
||||||
|
disable-toggle itself lands in history instead of staying dirty.
|
||||||
|
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
|
||||||
|
path rides along with the next apply/set commit."""
|
||||||
|
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
|
||||||
|
return
|
||||||
|
git = ["git", "-C", str(FLAKE_DIR)]
|
||||||
|
# No-op when the file already matches HEAD (a `set` to the same value).
|
||||||
|
# On a repo with no commits yet this diff errors — then just commit.
|
||||||
|
if subprocess.run(git + ["diff", "--quiet", "HEAD", "--", "theme-state.json"],
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
return
|
||||||
|
if not subprocess.run(git + ["config", "user.email"],
|
||||||
|
capture_output=True, text=True).stdout.strip():
|
||||||
|
git += ["-c", "user.name=Nomarchy", "-c", "user.email=nomarchy@localhost"]
|
||||||
|
result = subprocess.run(
|
||||||
|
git + ["commit", "--quiet", "-m", message, "--", "theme-state.json"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
log(f"auto-committed: {message}")
|
||||||
|
else:
|
||||||
|
log(f"auto-commit skipped: {(result.stderr or result.stdout).strip()}")
|
||||||
|
|
||||||
|
|
||||||
def deep_merge(base: dict, override: dict) -> dict:
|
def deep_merge(base: dict, override: dict) -> dict:
|
||||||
out = dict(base)
|
out = dict(base)
|
||||||
for k, v in override.items():
|
for k, v in override.items():
|
||||||
@@ -264,8 +297,11 @@ def cmd_apply(args) -> None:
|
|||||||
preset = json.loads(preset_path.read_text())
|
preset = json.loads(preset_path.read_text())
|
||||||
# Merge over current state: presets define palette/name/wallpaper,
|
# Merge over current state: presets define palette/name/wallpaper,
|
||||||
# user tweaks (gaps, fonts) outside the preset survive.
|
# user tweaks (gaps, fonts) outside the preset survive.
|
||||||
state = deep_merge(load_state(), preset)
|
old = load_state()
|
||||||
|
state = deep_merge(old, preset)
|
||||||
write_state(state)
|
write_state(state)
|
||||||
|
if auto_commit_enabled(old) or auto_commit_enabled(state):
|
||||||
|
auto_commit(f"nomarchy: apply theme {state.get('name', args.theme)}")
|
||||||
log(f"theme: {state.get('name', args.theme)}")
|
log(f"theme: {state.get('name', args.theme)}")
|
||||||
check_fonts(state)
|
check_fonts(state)
|
||||||
if not args.no_switch:
|
if not args.no_switch:
|
||||||
@@ -280,6 +316,7 @@ def cmd_set(args) -> None:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
value = args.value # bare string ("#7aa2f7", font names, paths)
|
value = args.value # bare string ("#7aa2f7", font names, paths)
|
||||||
|
|
||||||
|
was_on = auto_commit_enabled(state)
|
||||||
node = state
|
node = state
|
||||||
keys = args.path.split(".")
|
keys = args.path.split(".")
|
||||||
for key in keys[:-1]:
|
for key in keys[:-1]:
|
||||||
@@ -289,6 +326,8 @@ def cmd_set(args) -> None:
|
|||||||
node[keys[-1]] = value
|
node[keys[-1]] = value
|
||||||
|
|
||||||
write_state(state)
|
write_state(state)
|
||||||
|
if was_on or auto_commit_enabled(state):
|
||||||
|
auto_commit(f"nomarchy: set {args.path} = {json.dumps(value)}")
|
||||||
log(f"set {args.path} = {value!r}")
|
log(f"set {args.path} = {value!r}")
|
||||||
if args.path.startswith("fonts."):
|
if args.path.startswith("fonts."):
|
||||||
check_fonts(state)
|
check_fonts(state)
|
||||||
@@ -310,7 +349,10 @@ def cmd_get(args) -> None:
|
|||||||
node = node[key]
|
node = node[key]
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
die(f"no such key: {args.path}")
|
die(f"no such key: {args.path}")
|
||||||
print(json.dumps(node, indent=2) if isinstance(node, (dict, list)) else node)
|
# Booleans print JSON-style (true/false, not Python's True/False) so
|
||||||
|
# shell consumers can compare against the same literal they `set`.
|
||||||
|
print(json.dumps(node, indent=2)
|
||||||
|
if isinstance(node, (dict, list, bool)) else node)
|
||||||
else:
|
else:
|
||||||
print(json.dumps(state, indent=2))
|
print(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user