Compare commits
2 Commits
c28029681d
...
272866c7e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 272866c7e5 | |||
| f7b9f1b251 |
46
.config/dot/.claude/spec/dot-setup-nvim.md
Normal file
46
.config/dot/.claude/spec/dot-setup-nvim.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## Problem Statement
|
||||
|
||||
On a freshly cloned dotfiles checkout (or any machine where `~/.local/share/nvim/lazy/` is empty or stale), `lazy.nvim` only discovers that plugins are missing when `nvim` is actually launched. The first interactive launch then silently spends a long time cloning `nord.nvim`, `nvim-treesitter`, and `render-markdown.nvim` and compiling every `nvim-treesitter` parser listed in `ensure_installed`, with no obvious progress indication in a normal terminal session — it reads as "nvim isn't starting" rather than "nvim is installing plugins." Nothing in `dot` proactively drives this sync, even though the exact plugin versions are already pinned and tracked in `~/.config/nvim/lazy-lock.json`.
|
||||
|
||||
Separately, `nvim-treesitter`'s parser build step has a known race: concurrent parser installs can collide on a relative `tree-sitter-<lang>-tmp` directory, causing one parser (e.g. `bash`) to fail to compile. Because the compiled `.so` never lands in `~/.local/share/nvim/lazy/nvim-treesitter/parser/`, that parser gets retried (and can fail again) on every subsequent `nvim` launch until it eventually succeeds — a silent, recurring cost with no clear signal to the user that anything is wrong.
|
||||
|
||||
## Solution
|
||||
|
||||
Add an `nvim` task to the `dot setup` family (introduced by the `dot-setup-folders` spec as the general home for idempotent, re-runnable machine-setup tasks). `dot setup nvim` drives a headless `nvim` session that syncs installed plugins to exactly what `lazy-lock.json` already pins, and verifies afterward that every pinned plugin actually landed on disk — turning a silent, ambiguous first-launch stall into an explicit, scriptable, pass/fail setup step. Bare `dot setup` (no task name) runs this alongside `folders` (and any future tasks).
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As the machine owner, I want `dot setup nvim` to install/sync every plugin pinned in `lazy-lock.json` before I ever open `nvim` interactively, so that my first real editing session isn't interrupted by an unexplained multi-second-to-multi-minute stall that looks like a hang.
|
||||
2. As the machine owner, I want `dot setup nvim` to use the already-tracked `lazy-lock.json` as the source of truth (not re-resolve latest versions), so that a fresh machine ends up with the exact plugin commits I've already vetted, not whatever is newest upstream that day.
|
||||
3. As the machine owner, I want `dot setup nvim` to exit non-zero and say clearly which plugin(s) failed to install, so that a partial/broken sync is an obvious, actionable failure rather than something I only notice later inside nvim.
|
||||
4. As the machine owner, I want re-running `dot setup nvim` when everything is already in sync to be a fast no-op that still exits 0, so that it's safe to include unconditionally in `dot setup`'s bare "run everything" mode without slowing down every re-run.
|
||||
5. As the machine owner, I want to be able to run `dot setup nvim` in isolation (not just as part of bare `dot setup`), so that I can re-sync plugins on their own after e.g. manually editing `lazy-lock.json` or clearing the plugin directory.
|
||||
6. As the machine owner, I want `dot setup nvim help` to print usage without touching any plugin state, so that it's consistent with every other `dot` subcommand's `help` behavior.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- **Subcommand family**: lives under the `dot setup` dispatcher established by the `dot-setup-folders` spec — same nested-subcommand convention (`help`-then-`argparse`, `_dot_setup_nvim_usage`), same dual-mode shape (bare `dot setup` runs every task; `dot setup nvim` runs just this one). This spec does not re-describe the shared dispatcher scaffolding itself; see `dot-setup-folders.md` for that.
|
||||
- **Core action**: run `nvim --headless "+Lazy! restore" +qa`. `Lazy! restore` checks out every plugin in the spec to the exact commit recorded in `lazy-lock.json` (installing it first via clone if missing), so it both fixes "missing plugin" and "plugin present but on the wrong commit" in one call. No separate `TSUpdate`/`TSInstall` step is needed: because none of the current plugins (`nord.nvim`, `nvim-treesitter`, `render-markdown.nvim`) declare a lazy-loading trigger (`event`/`cmd`/`ft`), they load eagerly as part of this same headless session, which drives `nvim-treesitter`'s own `ensure_installed` parser-compilation step as a natural side effect — matching what was observed when reproducing the issue.
|
||||
- **Failure detection**: `nvim`'s process exit code from `--headless ... +qa` does not reliably reflect whether `Lazy! restore` itself succeeded (Lazy reports failures via its own UI/messages, not necessarily the process exit status). `dot setup nvim` must independently verify success after the headless run completes, by checking that every plugin name declared in `lazy-lock.json` has a corresponding directory under `~/.local/share/nvim/lazy/`. Any pinned plugin missing a directory is treated as a failure: print which plugin(s) didn't install and exit non-zero.
|
||||
- **Parser-compile failures are out of scope for pass/fail**: the `tree-sitter-<lang>-tmp` collision race affects `nvim-treesitter`'s internal parser build, not the plugin-directory check above (nvim-treesitter's own directory will exist regardless of whether an individual parser compiled). `dot setup nvim`'s success criterion is "all pinned plugins are present," not "all treesitter parsers compiled" — a parser-level compile flake is expected to self-heal on a later `nvim` launch or `:TSUpdate`, per the `Further Notes` in this spec's investigation. Detecting and retrying individual parser build failures is not attempted here.
|
||||
- **No package-list file**: unlike `dot install`, there's nothing to record — `lazy-lock.json` is already the tracked source of truth, so `dot setup nvim` never writes to it.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- **Guiding principle**: test `dot setup nvim`'s own logic (that it invokes `nvim` correctly, that it correctly detects success vs. a missing plugin) through the real CLI entry point, faking only the external `nvim` binary — not real plugin installs, real git clones, or real compilation, which would be slow and network-dependent in tests.
|
||||
- **Primary seam**: full CLI invocation of `dot setup nvim` (and bare `dot setup`), run against a scratch `$HOME` per test case — the existing project convention (see `dot install`'s and the planned `dot setup folders`' tests). No new seam is introduced.
|
||||
- **Faking `nvim`**: a `PATH`-prepended fake `nvim` binary, mirroring the fake-`pacman`/fake-`sudo`/fake-`xdg-user-dirs-update` technique already used/planned in `tests/dot.fish`. The fake logs its invocation args (so a test can assert `dot setup nvim` called it with `--headless "+Lazy! restore" +qa`) and, driven by an env var or scratch-`$HOME` fixture, can simulate "all plugins present" vs. "one plugin missing" by controlling whether it creates the expected directories under the scratch `~/.local/share/nvim/lazy/`.
|
||||
- **Cases to cover**: a successful sync (fake `nvim` creates all pinned plugin directories) exits 0; a plugin missing after the fake run exits non-zero and names the missing plugin; re-running against an already-fully-synced scratch `$HOME` is still a pass (idempotency) without requiring the fake to do anything different; bare `dot setup` runs the `nvim` task alongside `folders`; `dot setup nvim help` prints usage and never invokes the fake `nvim` at all.
|
||||
- **Prior art**: `tests/dot.fish`'s scratch-`$HOME`-plus-`fishtape` pattern, and specifically the fake-binary-via-`PATH` technique used for `dot install` (and planned for `dot setup folders`'s `xdg-user-dirs-update` fake).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- The `dot setup` dispatcher scaffolding itself (bare-runs-everything, per-task dispatch, `_dot_setup_usage`) — already specified in `dot-setup-folders.md`; this spec only adds the `nvim` task onto it.
|
||||
- The `folders` and any future (e.g. `groups`) `dot setup` tasks — unaffected by this spec beyond now running alongside `nvim` in bare `dot setup`.
|
||||
- Fixing the underlying `nvim-treesitter` `tree-sitter-<lang>-tmp` race itself (an upstream plugin behavior) — `dot setup nvim` tolerates it rather than working around it.
|
||||
- Any change to `~/.config/nvim`'s plugin specs, `lazy-lock.json` contents, or which plugins/parsers are installed — this spec only adds a way to proactively sync to what's already pinned.
|
||||
- A `~/.github/README.md` command-table row — not written here, but required by the project's standard "adding a subcommand" checklist at implementation time.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- This spec grew out of debugging a real "nvim isn't starting" report: the actual cause was an empty `lazy.nvim` plugin directory triggering a full, slow reinstall on first launch, compounded by a `tree-sitter-bash-tmp` mkdir collision that made the `bash` parser fail and re-attempt on every subsequent launch until it happened to succeed. `dot setup nvim` addresses the first (silent first-launch stall) directly; the second (parser race) is a pre-existing upstream flake this spec does not attempt to fix.
|
||||
@@ -17,8 +17,16 @@ Add a README row for `dot kde apply`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `dot kde apply` pushes every manifest entry's declared value onto the live system via `kwriteconfig6`
|
||||
- [ ] Re-running `dot kde apply` against a system already matching the manifest changes nothing (idempotent)
|
||||
- [ ] `dot kde apply help` prints usage without writing anything
|
||||
- [ ] Tests run against a scratch `$HOME`, exercising apply over a manifest with schema-backed entries, verifying resulting rc-file contents and idempotence on a second run
|
||||
- [ ] README has a row for `dot kde apply`
|
||||
- [x] `dot kde apply` pushes every manifest entry's declared value onto the live system via `kwriteconfig6`
|
||||
- [x] Re-running `dot kde apply` against a system already matching the manifest changes nothing (idempotent)
|
||||
- [x] `dot kde apply help` prints usage without writing anything
|
||||
- [x] Tests run against a scratch `$HOME`, exercising apply over a manifest with schema-backed entries, verifying resulting rc-file contents and idempotence on a second run
|
||||
- [x] README has a row for `dot kde apply`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- File layout mirrors `save`'s: `write_live_value` (the `kwriteconfig6` counterpart to `read_live_value`) and `apply_one` (mirroring `save_one`'s `parse_identifier` → `resolve_mechanism` → schema-only gate) added to `commands/kde/kde.py`; `cmd_apply` mirrors `cmd_save`'s help/argument/error-handling scaffold. `kde.fish` gained an `apply` dispatch case above `save`.
|
||||
- `apply` takes no arguments (unlike `save`, which supports an optional identifier) — the task only specifies pushing the whole manifest, and the parent spec's `apply` user story has no per-identifier mode, so `dot kde apply <extra-arg>` is rejected as misuse rather than silently ignored.
|
||||
- `write_live_value` passes the value positionally after a `--` separator (`kwriteconfig6 --file ... --group ... --key ... -- <value>`) rather than via a `--value` flag, since `kwriteconfig6` takes the value as a mandatory positional argument, not a flag; `--` guards against a value that itself looks like an option.
|
||||
- Non-schema (shortcuts/freeform) manifest entries are rejected with the same "not yet supported" error `save_one` already raises for those mechanisms, kept out of scope per this task's title ("...apply for schema-backed settings"); those mechanisms are added in later tasks (0004, 0005) without needing to restructure `cmd_apply`.
|
||||
- `/review-uncommitted` flagged two baseline duplication smells (`apply_one`/`cmd_apply` mirroring `save_one`/`cmd_save`'s shape) and one observation (a failing entry mid-manifest halts `apply` immediately, leaving earlier writes already applied — a partial-apply state, untested either way). Left as-is: the duplication mirrors an already-established local convention from task 0001 rather than introducing a new one, and the partial-apply behavior is consistent with `cmd_save`'s pre-existing control flow, not a new risk introduced by this task.
|
||||
|
||||
@@ -2,6 +2,7 @@ function _dot_kde_usage
|
||||
echo "usage: dot kde <command>
|
||||
|
||||
Commands:
|
||||
apply push manifest entries onto the live system
|
||||
save write live KDE settings into the manifest
|
||||
help show this message
|
||||
|
||||
@@ -17,6 +18,9 @@ function _dot_kde
|
||||
set -l helper_dir (status dirname)
|
||||
|
||||
switch "$argv[1]"
|
||||
case apply
|
||||
python3 $helper_dir/kde.py apply $argv[2..-1]
|
||||
return $status
|
||||
case save
|
||||
python3 $helper_dir/kde.py save $argv[2..-1]
|
||||
return $status
|
||||
|
||||
@@ -21,6 +21,11 @@ SAVE_USAGE = """usage: dot kde save [identifier]
|
||||
(no args) refresh every already-declared manifest entry from the live system
|
||||
help show this message"""
|
||||
|
||||
APPLY_USAGE = """usage: dot kde apply
|
||||
|
||||
Pushes every manifest entry's declared value onto the live system.
|
||||
help show this message"""
|
||||
|
||||
Setting = namedtuple("Setting", ["file", "group", "key"])
|
||||
|
||||
|
||||
@@ -138,6 +143,22 @@ def read_live_value(setting, default):
|
||||
return result.stdout.rstrip("\n")
|
||||
|
||||
|
||||
def write_live_value(setting, value):
|
||||
cmd = [
|
||||
"kwriteconfig6",
|
||||
"--file", setting.file,
|
||||
"--group", setting.group,
|
||||
"--key", setting.key,
|
||||
"--",
|
||||
value,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"kwriteconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def save_one(identifier, kcfg_map):
|
||||
setting = parse_identifier(identifier)
|
||||
mechanism, default = resolve_mechanism(setting, kcfg_map)
|
||||
@@ -146,6 +167,14 @@ def save_one(identifier, kcfg_map):
|
||||
return read_live_value(setting, default)
|
||||
|
||||
|
||||
def apply_one(identifier, value, kcfg_map):
|
||||
setting = parse_identifier(identifier)
|
||||
mechanism, _default = resolve_mechanism(setting, kcfg_map)
|
||||
if mechanism != "schema":
|
||||
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
|
||||
write_live_value(setting, value)
|
||||
|
||||
|
||||
def cmd_save(args, manifest_path, schema_dir):
|
||||
if args and args[0] == "help":
|
||||
print(SAVE_USAGE)
|
||||
@@ -172,6 +201,28 @@ def cmd_save(args, manifest_path, schema_dir):
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_apply(args, manifest_path, schema_dir):
|
||||
if args and args[0] == "help":
|
||||
print(APPLY_USAGE)
|
||||
return 0
|
||||
|
||||
if args:
|
||||
print("dot kde apply: too many arguments", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
kcfg_map = build_kcfg_map(schema_dir)
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
try:
|
||||
for identifier, value in manifest.items():
|
||||
apply_one(identifier, value, kcfg_map)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
print(f"dot kde apply: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_complete(schema_dir):
|
||||
kcfg_map = build_kcfg_map(schema_dir)
|
||||
for identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
|
||||
@@ -191,6 +242,9 @@ def main(argv):
|
||||
if command == "save":
|
||||
return cmd_save(rest, manifest_path, schema_dir)
|
||||
|
||||
if command == "apply":
|
||||
return cmd_apply(rest, manifest_path, schema_dir)
|
||||
|
||||
# Internal, not a user-facing `dot kde` subcommand -- called directly by
|
||||
# completions/dot.fish to source candidates from the live schema, never
|
||||
# dispatched to via kde.fish.
|
||||
|
||||
@@ -392,6 +392,71 @@ dot kde save nodots >/dev/null 2>&1
|
||||
set -l bad_identifier_status $status
|
||||
@test "dot kde save rejects an identifier without file.group.key structure" $bad_identifier_status -ne 0
|
||||
|
||||
# --- dot kde apply help touches neither the manifest nor kwriteconfig6 ---
|
||||
set -gx HOME (mktemp -d)
|
||||
dot init --url $remote >/dev/null 2>&1
|
||||
mkdir -p $HOME/.config/dot/commands/kde
|
||||
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
|
||||
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
|
||||
|
||||
set -l fake_bin_kwrite (mktemp -d)
|
||||
set -gx KWRITECONFIG_LOG (mktemp)
|
||||
echo '#!/bin/sh
|
||||
echo "$@" >>"$KWRITECONFIG_LOG"
|
||||
exit 1' >$fake_bin_kwrite/kwriteconfig6
|
||||
chmod +x $fake_bin_kwrite/kwriteconfig6
|
||||
set -gx PATH $fake_bin_kwrite $path_before_fake_kreadconfig
|
||||
|
||||
set -l kde_apply_help_output (dot kde apply help)
|
||||
set -l kde_apply_help_status $status
|
||||
set -l kwriteconfig_called_for_apply_help (test -s $KWRITECONFIG_LOG; and echo yes; or echo no)
|
||||
set -l manifest_exists_after_apply_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
|
||||
|
||||
@test "dot kde apply help succeeds" $kde_apply_help_status -eq 0
|
||||
@test "dot kde apply help mentions manifest" (string match -q '*manifest*' -- $kde_apply_help_output; echo $status) -eq 0
|
||||
@test "dot kde apply help never invokes kwriteconfig6" $kwriteconfig_called_for_apply_help = no
|
||||
@test "dot kde apply help does not create a manifest" $manifest_exists_after_apply_help = no
|
||||
|
||||
set -gx PATH $path_before_fake_kreadconfig
|
||||
|
||||
# --- dot kde apply: pushes every declared manifest entry onto the live rc file ---
|
||||
set -gx HOME (mktemp -d)
|
||||
dot init --url $remote >/dev/null 2>&1
|
||||
mkdir -p $HOME/.config/dot/commands/kde
|
||||
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
|
||||
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
|
||||
mkdir -p $HOME/.config/dot
|
||||
printf 'testrc.General.Greeting=Applied Greeting\ntestrc.General.RealKey=Hi=There\n' >$HOME/.config/dot/kde-manifest
|
||||
|
||||
dot kde apply >/dev/null 2>&1
|
||||
set -l apply_status $status
|
||||
set -l testrc_after_apply (cat $HOME/.config/testrc)
|
||||
|
||||
@test "dot kde apply succeeds" $apply_status -eq 0
|
||||
@test "dot kde apply writes a declared value onto the live rc file" (string match -q '*Greeting=Applied Greeting*' -- $testrc_after_apply; echo $status) -eq 0
|
||||
@test "dot kde apply preserves an embedded '=' in the applied value" (string match -q '*RealKey=Hi=There*' -- $testrc_after_apply; echo $status) -eq 0
|
||||
|
||||
# re-running against a system already matching the manifest changes nothing
|
||||
dot kde apply >/dev/null 2>&1
|
||||
set -l reapply_status $status
|
||||
set -l testrc_after_reapply (cat $HOME/.config/testrc)
|
||||
|
||||
@test "re-running dot kde apply succeeds" $reapply_status -eq 0
|
||||
@test "re-running dot kde apply against an already-applied system is idempotent" "$testrc_after_reapply" = "$testrc_after_apply"
|
||||
|
||||
# a manifest entry whose rc file isn't schema-backed (freeform, not yet
|
||||
# implemented) is rejected rather than silently mis-applied
|
||||
printf 'testrc.General.Greeting=Applied Greeting\nsomefreeform.Group.Key=Value\n' >$HOME/.config/dot/kde-manifest
|
||||
dot kde apply >/dev/null 2>&1
|
||||
set -l apply_freeform_status $status
|
||||
@test "dot kde apply rejects a manifest entry whose mechanism isn't schema-backed yet" $apply_freeform_status -ne 0
|
||||
|
||||
# misuse: apply takes no arguments
|
||||
printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest
|
||||
dot kde apply extra-arg >/dev/null 2>&1
|
||||
set -l apply_extra_arg_status $status
|
||||
@test "dot kde apply rejects an unexpected argument" $apply_extra_arg_status -ne 0
|
||||
|
||||
# --- kde.py complete: tab-completion candidates, sourced from the live
|
||||
# schema mapping table rather than a hardcoded list. This is the
|
||||
# underlying data completions/dot.fish shells out to; the fish
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
|
||||
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
|
||||
"nord.nvim": { "branch": "main", "commit": "87394d4fc35c901bbe38326a78d31ab1ead826b6" },
|
||||
"nvim-treesitter": { "branch": "master", "commit": "cf12346a3414fa1b06af75c79faebe7f76df080a" },
|
||||
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }
|
||||
|
||||
1
.github/README.md
vendored
1
.github/README.md
vendored
@@ -20,6 +20,7 @@ fish -c 'dot init'
|
||||
| `dot init` | Bootstraps the dotfiles repo on a new machine. |
|
||||
| `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). |
|
||||
| `dot install --restore` | Reinstalls every package from the tracked list. |
|
||||
| `dot kde apply` | Pushes every manifest entry's declared value onto the live system. |
|
||||
| `dot kde help` | Lists `dot kde`'s subcommands. |
|
||||
| `dot kde save <identifier>` | Reads a KDE setting's current live value and declares it in the manifest (`~/.config/dot/kde-manifest`). |
|
||||
| `dot kde save` | Refreshes every already-declared manifest entry's value from the live system. |
|
||||
|
||||
Reference in New Issue
Block a user