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:
@@ -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:
|
||||
out = dict(base)
|
||||
for k, v in override.items():
|
||||
@@ -264,8 +297,11 @@ def cmd_apply(args) -> None:
|
||||
preset = json.loads(preset_path.read_text())
|
||||
# Merge over current state: presets define palette/name/wallpaper,
|
||||
# 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)
|
||||
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)}")
|
||||
check_fonts(state)
|
||||
if not args.no_switch:
|
||||
@@ -280,6 +316,7 @@ def cmd_set(args) -> None:
|
||||
except json.JSONDecodeError:
|
||||
value = args.value # bare string ("#7aa2f7", font names, paths)
|
||||
|
||||
was_on = auto_commit_enabled(state)
|
||||
node = state
|
||||
keys = args.path.split(".")
|
||||
for key in keys[:-1]:
|
||||
@@ -289,6 +326,8 @@ def cmd_set(args) -> None:
|
||||
node[keys[-1]] = value
|
||||
|
||||
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}")
|
||||
if args.path.startswith("fonts."):
|
||||
check_fonts(state)
|
||||
@@ -310,7 +349,10 @@ def cmd_get(args) -> None:
|
||||
node = node[key]
|
||||
except (KeyError, TypeError):
|
||||
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:
|
||||
print(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user