dotcli: add dot setup folders (XDG short-name migration)

Adds a dot setup subcommand family (idempotent, re-runnable machine-setup
tasks) with a folders task that brings the 8 standard XDG user
directories under a fixed short-name convention (Desktop -> .desktop,
Documents -> doc, Downloads -> dwn, Music -> mus, Pictures -> pic,
Videos -> vid, Templates/Public -> .ignoreme), hardcoded rather than read
from ~/.config/user-dirs.dirs -- that file is a separate, manually
tracked dotfile whose values can drift or go stale, and the migration
must not depend on it being correct. The nested Pictures/Screenshots
folder is renamed to pic/screenshots in the same pass.

Content left behind in a legacy full-named folder by a fresh XDG-defaults
install -- empty or not -- is merged into its short-named replacement.
A same-named collision between a legacy folder and its target is never
overwritten: it is skipped, reported, and the legacy folder is left in
place rather than deleted while something in it could not be merged.
--dry-run previews what would move/skip without touching the filesystem.
xdg-user-dirs-update runs once afterward to notify running apps/portals.
This commit is contained in:
2026-07-06 16:19:06 -04:00
parent 540928f24f
commit 1fd8e7e773
11 changed files with 783 additions and 16 deletions

View File

@@ -41,20 +41,50 @@ duplication point, and a README command-table row.
## Acceptance criteria
- [ ] `dot setup folders` on a fresh scratch `$HOME` (all 8 legacy folders
- [x] `dot setup folders` on a fresh scratch `$HOME` (all 8 legacy folders
present and empty) renames them to their short-name targets per the
mapping table, including `Pictures/Screenshots→pic/screenshots`, and
leaves the tracked `user-dirs.dirs` short names in place
- [ ] The fake `xdg-user-dirs-update` (PATH-prepended, logging its invocation
- [x] The fake `xdg-user-dirs-update` (PATH-prepended, logging its invocation
per the project's existing fake-`sudo`/fake-`pacman` testing pattern)
is invoked exactly once after a successful migration
- [ ] Bare `dot setup` on a fresh scratch `$HOME` runs the `folders` task as
- [x] Bare `dot setup` on a fresh scratch `$HOME` runs the `folders` task as
part of running everything
- [ ] `dot setup folders help` and `dot setup help` print usage and make no
- [x] `dot setup folders help` and `dot setup help` print usage and make no
filesystem changes
- [ ] Re-running `dot setup folders` after a clean migration is a no-op
- [x] Re-running `dot setup folders` after a clean migration is a no-op
(idempotent)
- [ ] `~/.github/README.md` has a command-table row for `dot setup`
- [x] `~/.github/README.md` has a command-table row for `dot setup`
(and its `folders` task) with paths relative to `$HOME`
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The desired short names for `dot setup folders` are read directly from the
tracked `~/.config/user-dirs.dirs` (parsed via `grep`/`string match`, not
sourced as shell), per the parent spec's decision that this file is the
single source of truth. This machine's real `user-dirs.dirs` was
deliberately left untouched/untracked and no live migration was run against
this machine's actual home directory — the user chose "code + tests only"
scope for this task (a real rename of `~/Desktop`, `~/Documents`, etc. is a
separate, explicit action to take later), so only the scratch-`$HOME`
fishtape fixtures exercise the short-name `user-dirs.dirs` content.
Tracking the real file and running the real migration remains open.
- During `/review-uncommitted`, the spec-fidelity pass caught a real bug: the
nested `Pictures/Screenshots→pic/screenshots` move ran unconditionally,
before checking whether `Pictures` held other, unrelated content — so a
`Pictures` folder with both `Screenshots/` and some other file got
partially mutated (Screenshots pulled out) while still being reported as
"left in place." Fixed by gating the Screenshots move on the rest of the
folder being empty too; added a regression test for this case
("Screenshots is not peeled off... when Pictures still has other
content").
- Completions (`~/.config/fish/completions/dot.fish`) got a `dot setup`
block mirroring `dot kde`'s per-subcommand completion entries, even though
the task's required "completions/help-glob duplication point" is already
satisfied automatically by the existing generic directory glob (no changes
were needed there for `dot setup`/`dot help` to discover the new nested
command). The added completions are a small polish addition beyond the
strict letter of the acceptance criteria, consistent with the existing
`kde` subcommand's treatment.

View File

@@ -19,12 +19,34 @@ rule.
## Acceptance criteria
- [ ] A legacy folder with real content (a real file, not just an empty
- [x] A legacy folder with real content (a real file, not just an empty
directory) refuses to migrate without `--yes`, prints what would have
been moved, and leaves the folder and its contents untouched
- [ ] The same legacy folder migrates successfully when `--yes` is passed
- [ ] A legacy folder containing only a stray dotfile/metadata file (e.g. a
- [x] The same legacy folder migrates successfully when `--yes` is passed
- [x] A legacy folder containing only a stray dotfile/metadata file (e.g. a
fake `.directory`) is still treated as non-empty and triggers the same
confirmation gate
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- `--yes`'s actual move reuses the exact same branch shape as the existing
silent-empty path (rename `Screenshots``screenshots` when present, then
`rmdir` the legacy folder), extended to also `mv` any remaining top-level
entries into the target first. Screenshots is always moved as one atomic
unit — its individual files are never mv'd/reported separately — so a
non-empty `Screenshots` (own acceptance criterion in the parent spec) is
gated and migrated the same way a non-empty top-level file would be.
- Collision handling (no-clobber `mv -n`, reporting skipped files, leaving the
legacy folder in place on a collision) is explicitly out of scope here —
it's owned by 0008-folders-collision-handling.md, per that task's own
frontmatter/spec section. The `--yes` path added here uses a plain `mv`.
- `/review-uncommitted` flagged two minor issues, both fixed: a stale comment
claiming a helper variable was used by both the silent-empty and `--yes`
paths when it was only read by the latter, and a duplicated `find`
invocation computing the same top-level listing twice under one condition
(now computed once and reused). It also flagged the non-empty "would move"
preview listing recursively-nested files individually instead of treating
`Screenshots` as one unit like the real move does — fixed so the preview
and the actual move share the same top-level-entries list.

View File

@@ -18,15 +18,38 @@ $to`, which had no collision protection at all.
## Acceptance criteria
- [ ] A filename collision between a legacy folder and its already-populated
- [x] A filename collision between a legacy folder and its already-populated
short-named target is skipped, not overwritten (the target's existing
file is preserved byte-for-byte)
- [ ] The skipped collision is reported to the user
- [ ] The legacy folder is left in place (not removed) when a collision
- [x] The skipped collision is reported to the user
- [x] The legacy folder is left in place (not removed) when a collision
occurred, even though `--yes` was given and other non-colliding files
in it were moved
- [ ] Re-running `dot setup folders` after a collision was reported and left
- [x] Re-running `dot setup folders` after a collision was reported and left
in place behaves consistently (doesn't lose the previously-skipped
file, doesn't re-move already-migrated files)
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The two prior branches (silent-empty merge vs. `--yes`-confirmed merge)
were unified into one `if test (count $other_entries) -eq 0; or set -q
_flag_yes` branch, since the collision-detection/no-clobber logic is
identical either way. This has one side effect beyond the letter of the
acceptance criteria (which frame collision handling around the `--yes`
path): a legacy folder that's otherwise "empty" except for an emptyish
nested `Screenshots` dir now also gets collision-checked against an
already-populated `pic/screenshots` on the silent, no-`--yes` path. This
closes the same unguarded-`mv` gap the spec calls out as the motivating
problem (the old code's silent-path `mv $screenshots_path
$target_path/screenshots` had no collision protection at all either), so
it was kept rather than special-cased away. Covered by its own test
("a silent-path Screenshots collision ...").
- Collision detection is a pre-check (`test -e $target_path/...`) before an
actual `mv -n`, rather than relying on `mv -n`'s exit code alone, so each
colliding entry can be individually identified and reported by path.
- `/review-uncommitted` (risk: Medium, standards: 0 hard violations, spec:
0 missing/wrong requirements) raised no changes needed; the one scope note
it flagged (the silent-path Screenshots case above) was a deliberate,
judged-correct decision rather than an oversight.

View File

@@ -0,0 +1,79 @@
---
spec: dot-setup-folders
blocked-by: 0008-folders-collision-handling
---
## What to build
Remove the `--yes` confirmation gate that 0007/0008 built: a legacy folder
with real content in it is migrated unconditionally now, the same as an
empty one, since the collision handling from 0008 already makes the merge
non-destructive on its own (a same-named entry is never overwritten, and the
legacy folder is kept whenever any collision occurred). The `--yes` gate
turned out to protect against a scenario collision handling already
prevents, while making the everyday case — a machine that already has real
files in `~/Documents`, `~/Pictures`, etc. — a silent no-op unless the flag
was remembered, which defeats the point of the task.
In its place:
- `dot setup folders` always attempts the merge for every legacy folder,
content or none.
- A new `--dry-run` flag replaces `--yes` in the flag slot: it reports what
would move and what would be skipped as a collision, without touching the
filesystem at all (no `mkdir`, no `mv`/`rmdir`, no `xdg-user-dirs-update`).
- A real (non-dry-run) run now reports what it moved per legacy folder
(e.g. `moved 12 entries from ~/Documents to ~/doc`), instead of staying
silent on success. A folder where nothing top-level moved (already empty,
or everything in it collided) prints no such line — only non-trivial moves
and collisions produce output.
- `--yes` is removed outright (not kept as a silent no-op): passing it now
fails with argparse's standard unknown-option error.
## Acceptance criteria
- [x] A legacy folder with real content merges on a plain `dot setup
folders`, with no flag required
- [x] A real run prints `moved N entries from ~/<legacy> to ~/<target>` for a
folder where top-level entries actually moved, and nothing for a
folder where none did
- [x] A real run prints a dedicated line when the nested Screenshots folder
itself is moved (e.g. `moved ~/Pictures/Screenshots to ~/pic/screenshots`)
- [x] Collision detection/reporting and the "leave the legacy folder in
place when a collision occurred" behavior from 0008 are unchanged
under the new unconditional default
- [x] `--dry-run` reports the same would-move/would-skip information without
creating any target directory, moving/removing anything, or invoking
`xdg-user-dirs-update`
- [x] `dot setup folders --yes` fails with an unknown-option error (argparse
default), rather than being silently accepted or gated on
- [x] `dot setup folders help` output no longer mentions `--yes` and
documents `--dry-run` instead
- [x] Idempotency holds: re-running after a clean merge, and re-running
after a collision was reported, both behave the same as before
- [x] `~/.config/dot/tests/dot.fish` is updated to exercise the above
(replacing the old `--yes`-gated cases) and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The `--yes` gate and the `screenshots_emptyish`/`other_entries` machinery
that computed it were deleted outright rather than special-cased away:
once merging is unconditional, that machinery had no remaining purpose
(it existed solely to decide "empty enough to skip the gate").
- `mkdir -p $target_path` and the final `xdg-user-dirs-update` are both now
guarded by `not set -q _flag_dry_run`, making `--dry-run` a true no-op
rather than "no-op except for directory scaffolding."
- Collision detection (`test -e $target_path/...`) runs identically in both
modes; `--dry-run` only gates the actual `mv`/`rmdir`/`mkdir` calls, so the
reported would-move/would-skip split is exactly what a real run would do.
- Success reporting is per-legacy-folder and suppressed at zero: a folder
that was already empty (or whose only entries all collided) prints
nothing, so a routine re-run stays quiet like before.
- All prior collision/idempotency/Screenshots test scenarios were kept,
just re-pointed at the plain `dot setup folders` invocation instead of
`--yes`; two scenarios that only differed by which code branch (`--yes`
vs. silent-empty) they exercised now hit the same branch, but were both
kept since they still cover distinct fixture shapes (Pictures with vs.
without unrelated top-level content alongside a colliding Screenshots).
- `fishtape ~/.config/dot/tests/dot.fish` passes (178 tests).

View File

@@ -0,0 +1,64 @@
---
spec: dot-setup-folders
blocked-by: 000b-folders-unconditional-merge
---
## What to build
Stop reading the short-name target from `~/.config/user-dirs.dirs` and
hardcode the legacy-name -> short-name mapping directly in
`_dot_setup_folders`, dropping the dependency on that file entirely.
The original design treated the tracked `user-dirs.dirs` as the single
source of truth for target names, assuming someone would hand-edit it to
the short names before ever running the command. On this machine that
never happened: the tracked file still had the stock XDG defaults
(`XDG_DOCUMENTS_DIR="$HOME/Documents"`, etc.), so `target_path` resolved to
the exact same directory as `legacy_path` for every folder. The migration
logic then reported every entry as a "collision" against itself instead of
moving anything -- a confusing, silent-feeling failure rather than an
actual migration.
The short names are fixed (`.desktop`, `doc`, `dwn`, `mus`, `pic`, `vid`,
`.ignoreme`) and not meant to be configurable, so there's nothing to read
from a file in the first place. `user-dirs.dirs` remains a separate,
manually tracked dotfile (edited and tracked by hand, like any other
dotfile) for apps/`xdg-user-dirs-update` to consult -- `dot setup folders`
itself no longer reads it, requires its presence, or writes to it.
## Acceptance criteria
- [x] `_dot_setup_folders` no longer reads, parses, or requires
`~/.config/user-dirs.dirs`; the legacy->short-name mapping is a fixed
table in the function itself
- [x] Migration works identically whether `user-dirs.dirs` is absent,
empty, or declares stale/full-name values (the exact real-world case)
- [x] `user-dirs.dirs` is left byte-for-byte untouched by `dot setup
folders` when present, and no file is created when absent
- [x] `dot setup folders help` no longer describes reading target names
from `user-dirs.dirs`
- [x] `~/.config/dot/tests/dot.fish` no longer seeds a `user-dirs.dirs`
fixture as a migration precondition, and covers the stale/missing
cases above; `fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- Replaced the `xdg_vars`/`grep`/`string match` parsing of `user-dirs.dirs`
with two parallel hardcoded arrays, `legacy_names` and `target_names`,
indexed together -- same shape the code already used for `legacy_names`
alone, just extended to cover the target side too.
- The early `if not test -f $user_dirs; return 1` guard was deleted outright
rather than kept as a soft check: there's nothing left for the function to
read from that file, so requiring its existence would just be a
vestigial, unjustifiable precondition.
- Removed the `short_name_user_dirs` fixture and its seeding step from every
test scenario (it was previously duplicated into ~15 scenarios as a
migration precondition); added two new scenarios instead: one reproducing
the exact real-machine bug (stale full-name `user-dirs.dirs` values) and
one confirming migration works with no `user-dirs.dirs` file at all.
- Verified against this machine's real, still-stale `~/.config/user-dirs.dirs`
via `_dot_setup_folders --dry-run`: previously reported every entry in
Desktop/Documents/Downloads/Pictures/Videos as a collision against
itself; now correctly reports `would move N entries from ~/Documents to
~/doc` etc.
- `fishtape ~/.config/dot/tests/dot.fish` passes (183 tests).