diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-06-16-suggest-flag.md | 482 |
1 files changed, 482 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-06-16-suggest-flag.md b/docs/superpowers/plans/2026-06-16-suggest-flag.md new file mode 100644 index 0000000..6a7c968 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-suggest-flag.md @@ -0,0 +1,482 @@ +# `--suggest` Flag Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only `-s|--suggest` flag to `dot-backup.sh` that lists untracked config dirs/files created since the last backup and prints a paste-ready `files.list` block. + +**Architecture:** A new `do_suggest()` function scans `~/.config/*`, `~/.*` dirs, and `~/.*` files one level deep, filters by mtime-vs-last-backup, dedup against `DOTFILES`, and a `SUGGEST_IGNORE` basename skip list. The `lastupdate()` function gains a machine-readable `lastupdate.epoch` sibling file for the mtime comparison. Suggest dispatches early (after the FIRST_RUN guard, before restore/backup) and always writes to real stdout, bypassing `--quiet`. + +**Tech Stack:** Pure bash, coreutils (`date`, `stat`, `sort`). No test framework in repo — verification is manual via documented commands. + +**Spec:** `docs/superpowers/specs/2026-06-16-suggest-flag-design.md` + +**Note on TDD:** This repo has no test harness (CLAUDE.md: "No tests"). Each task uses manual verification commands with expected output in place of automated tests. Run them exactly; they are the acceptance check. + +--- + +## File Structure + +- `dot-backup.sh` — all logic. New flag var, arg case, usage line; quiet-redirect guard; `lastupdate()` modification; `SUGGEST_IGNORE` default; `do_suggest()` function; early dispatch. +- `config.example` — commented `SUGGEST_IGNORE` example. +- `README.md` — document `--suggest` in usage/options. +- `CLAUDE.md` — note the flag and `lastupdate.epoch`. + +All current line numbers are from the working tree at plan time and may drift as edits land — match on the quoted code, not the number. + +--- + +## Task 1: Flag plumbing (var, arg case, usage) + +**Files:** +- Modify: `dot-backup.sh` (flag block ~line 49-54, `usage()` ~line 56-65, arg loop ~line 67-77) + +- [ ] **Step 1: Add the flag variable** + +In the flags block, after `PUSH=false`, add: + +```bash +SUGGEST=false +``` + +Result block: + +```bash +# Flags +DRY_RUN=false +VERBOSE=false +RESTORE=false +QUIET=false +PUSH=false +SUGGEST=false +``` + +- [ ] **Step 2: Add the usage line** + +In `usage()`, after the `--push` line and before `--help`, add: + +```bash + echo " -s, --suggest List untracked config dirs/files new since last backup, then exit" +``` + +- [ ] **Step 3: Add the arg case** + +In the `for arg in "$@"` case statement, after the `-p|--push` line, add: + +```bash + -s|--suggest) SUGGEST=true ;; +``` + +- [ ] **Step 4: Verify the flag parses and help shows it** + +Run: `bash -n dot-backup.sh && ./dot-backup.sh --help` +Expected: no syntax error; help output includes the `-s, --suggest` line. + +- [ ] **Step 5: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: add --suggest flag plumbing" +``` + +--- + +## Task 2: Guard the quiet redirect so suggest stays on stdout + +**Files:** +- Modify: `dot-backup.sh` (quiet redirect block ~line 79-84) + +- [ ] **Step 1: Update the quiet guard** + +Change: + +```bash +if [[ "$QUIET" == true ]]; then +``` + +to: + +```bash +if [[ "$QUIET" == true && "$SUGGEST" == false ]]; then +``` + +The rest of the block (fd 3 save, redirect) is unchanged. `SUGGEST` is parsed in the arg loop above this block, so it is known here. + +- [ ] **Step 2: Verify redirect is skipped under suggest** + +Run: `./dot-backup.sh --suggest --quiet 2>&1 | head -1` +Expected: output appears on the terminal (not silently swallowed to log). Exact content is implemented in Task 5; for now it may error on a not-yet-defined function — that is fine, the point is stdout is NOT redirected. If `do_suggest` is undefined at this stage, expect a "command not found" on stdout, which still proves the redirect was skipped. + +> Note: if running tasks strictly in order, `do_suggest` does not exist yet. You may defer this verification to after Task 5, or temporarily observe the "command not found" lands on the terminal. Either confirms the guard. + +- [ ] **Step 3: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: suggest bypasses --quiet redirect" +``` + +--- + +## Task 3: Write `lastupdate.epoch` alongside human timestamp + +**Files:** +- Modify: `dot-backup.sh` (`lastupdate()` ~line 184-187) + +- [ ] **Step 1: Modify `lastupdate()`** + +Change: + +```bash +lastupdate() { + D=$(date) + echo "Last update: $D" > "${DEFAULT_OUTPUT_DIR}/lastupdate" +} +``` + +to: + +```bash +lastupdate() { + D=$(date) + echo "Last update: $D" > "${DEFAULT_OUTPUT_DIR}/lastupdate" + date +%s > "${DEFAULT_OUTPUT_DIR}/lastupdate.epoch" +} +``` + +- [ ] **Step 2: Verify both files written by a dry-run-free path** + +`lastupdate()` is only called in the real backup flow (not dry-run). Verify in isolation: + +Run: +```bash +DEFAULT_OUTPUT_DIR=$(mktemp -d); export DEFAULT_OUTPUT_DIR +bash -c 'source <(sed -n "184,188p" dot-backup.sh); DEFAULT_OUTPUT_DIR="'"$DEFAULT_OUTPUT_DIR"'"; lastupdate; cat "$DEFAULT_OUTPUT_DIR/lastupdate"; echo "epoch=$(cat "$DEFAULT_OUTPUT_DIR/lastupdate.epoch")"' +``` +Expected: prints `Last update: <date>` and `epoch=<10-digit integer>`. +(If the `sed` line range drifted, adjust to the `lastupdate()` definition lines.) + +- [ ] **Step 3: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: lastupdate also writes machine-readable epoch" +``` + +--- + +## Task 4: Add `SUGGEST_IGNORE` default + +**Files:** +- Modify: `dot-backup.sh` (after config source ~line 47, before flags block) + +- [ ] **Step 1: Add the guarded default** + +After the config-file `source` block (line ~44-47) and before the `# Flags` block, add: + +```bash +# Basenames skipped by --suggest. User may override in config. +if [[ -z "${SUGGEST_IGNORE+set}" ]]; then + SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv \ + .mozilla .thunderbird .npm .cargo .rustup .java \ + .dbus .config) +fi +``` + +The `${SUGGEST_IGNORE+set}` test is empty only when the variable is wholly +unset, so a user who defines an explicit (even empty) array in config is +respected. + +- [ ] **Step 2: Verify default populates and config override wins** + +Run (default): +```bash +bash -c 'source <(sed -n "/Basenames skipped/,/^fi$/p" dot-backup.sh); printf "%s\n" "${SUGGEST_IGNORE[@]}"' | grep -c '^\.' +``` +Expected: `16` (count of default entries). + +Run (override respected): +```bash +bash -c 'SUGGEST_IGNORE=(.foo); source <(sed -n "/Basenames skipped/,/^fi$/p" dot-backup.sh); printf "%s\n" "${SUGGEST_IGNORE[@]}"' +``` +Expected: prints only `.foo` (default did not overwrite). + +- [ ] **Step 3: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: add SUGGEST_IGNORE default skip list" +``` + +--- + +## Task 5: Implement `do_suggest()` + +**Files:** +- Modify: `dot-backup.sh` (add function near other functions, after `do_rsync()` ~line 216, before RESTORE MODE section ~line 218) + +- [ ] **Step 1: Add the `do_suggest()` function** + +Insert after the closing `}` of `do_rsync()` and before the `# ── RESTORE MODE` comment: + +```bash +# ── SUGGEST MODE ────────────────────────────────────────────────────────────── + +# True if candidate path $1 is already covered by any DOTFILES entry +# (exact match, parent of, or child of an existing entry). +_suggest_covered() { + local c="$1" e + for e in "${DOTFILES[@]}"; do + # normalize trailing slash + e="${e%/}" + [[ "$c" == "$e" ]] && return 0 + [[ "$c" == "$e"/* ]] && return 0 + [[ "$e" == "$c"/* ]] && return 0 + done + return 1 +} + +# True if basename $1 is in SUGGEST_IGNORE +_suggest_ignored() { + local b="$1" ig + for ig in "${SUGGEST_IGNORE[@]}"; do + [[ "$b" == "$ig" ]] && return 0 + done + return 1 +} + +do_suggest() { + local epoch_file="${DEFAULT_OUTPUT_DIR}/lastupdate.epoch" + local last_epoch=0 + local last_human="never" + if [[ -r "$epoch_file" ]]; then + local raw + raw="$(cat "$epoch_file")" + if [[ "$raw" =~ ^[0-9]+$ ]]; then + last_epoch="$raw" + last_human="$(date -d "@$last_epoch" 2>/dev/null || echo "$last_epoch")" + fi + fi + + local -a candidates=() + local path emit base mtime + + # Source 1: ~/.config/* (children of .config) + for path in "${HOME}/.config/"*; do + [[ -e "$path" ]] || continue + base="$(basename "$path")" + emit=".config/${base}" + _suggest_ignored "$base" && continue + _suggest_covered "$emit" && continue + mtime="$(stat -c %Y "$path" 2>/dev/null || echo 0)" + (( mtime > last_epoch )) && candidates+=("$emit") + done + + # Source 2 + 3: ~/.* (hidden dirs and files at home top level) + for path in "${HOME}/".*; do + base="$(basename "$path")" + [[ "$base" == "." || "$base" == ".." ]] && continue + [[ -d "$path" || -f "$path" ]] || continue + emit="${base}" + _suggest_ignored "$base" && continue + _suggest_covered "$emit" && continue + mtime="$(stat -c %Y "$path" 2>/dev/null || echo 0)" + (( mtime > last_epoch )) && candidates+=("$emit") + done + + if [[ ${#candidates[@]} -eq 0 ]]; then + echo -e "${GREEN}No new config dirs/files since last backup.${NC}" + return 0 + fi + + # sort alphabetically, stable output + local -a sorted=() + while IFS= read -r line; do sorted+=("$line"); done < <(printf '%s\n' "${candidates[@]}" | sort -u) + + echo -e "${GREEN}New since last backup (${last_human}):${NC}" + for emit in "${sorted[@]}"; do + echo -e " ${BLUE}${emit}${NC}" + done + echo + echo "Paste into files.list:" + for emit in "${sorted[@]}"; do + echo "$emit" + done +} +``` + +- [ ] **Step 2: Verify syntax** + +Run: `bash -n dot-backup.sh` +Expected: no output (clean parse). + +- [ ] **Step 3: Verify the function runs standalone** + +The function is not yet dispatched (Task 6). Smoke-test the parse only here; full behavior is verified in Task 6 once wired up. + +Run: `bash -n dot-backup.sh && echo OK` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: add do_suggest scan/filter/dedup function" +``` + +--- + +## Task 6: Dispatch `--suggest` early and exit + +**Files:** +- Modify: `dot-backup.sh` (MAIN section, after FIRST_RUN guard ~line 309, before DRY_RUN/RESTORE ~line 311-317) + +- [ ] **Step 1: Add the dispatch block** + +After the `FIRST_RUN` `if` block (the one ending `exit 0` / `fi` around line 309) and before the `if [[ "$DRY_RUN" == true ]]` block, add: + +```bash +if [[ "$SUGGEST" == true ]]; then + do_suggest + exit 0 +fi +``` + +Placing it after FIRST_RUN means a brand-new install (no config yet) shows the +first-run message instead of an empty suggest. Placing it before DRY_RUN and +RESTORE means suggest takes precedence over those flags, per spec. + +- [ ] **Step 2: Verify suggest lists and exits without backing up** + +Run: `./dot-backup.sh --suggest` +Expected: either `New since last backup (...)` with an indented list plus a +`Paste into files.list:` block, OR `No new config dirs/files since last backup.` +No `git` commit output, no `Copied` lines — it exited before the backup flow. + +- [ ] **Step 3: Verify dedup — a listed path is not suggested** + +Create a throwaway config dir and confirm it shows, then suppress it: + +```bash +mkdir -p ~/.config/zzztest +touch -d "+1 hour" ~/.config/zzztest # ensure mtime newer than last backup +./dot-backup.sh --suggest | grep zzztest +``` +Expected: `.config/zzztest` appears. + +Now add it to an external list and confirm it disappears: +```bash +mkdir -p ~/.config/dot-backup +echo ".config/zzztest" >> ~/.config/dot-backup/files.list +./dot-backup.sh --suggest | grep zzztest && echo "STILL THERE (bug)" || echo "deduped OK" +``` +Expected: `deduped OK`. + +Cleanup: +```bash +sed -i '/^\.config\/zzztest$/d' ~/.config/dot-backup/files.list +rmdir ~/.config/zzztest +``` +(If `files.list` is now empty and was created only for this test, remove it.) + +- [ ] **Step 4: Verify ignore list suppresses a basename** + +```bash +mkdir -p ~/.config/zzzignore; touch -d "+1 hour" ~/.config/zzzignore +./dot-backup.sh --suggest | grep zzzignore +``` +Expected: `.config/zzzignore` appears (not yet ignored). + +Add to config and re-run: +```bash +echo 'SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv .mozilla .thunderbird .npm .cargo .rustup .java .dbus .config zzzignore)' >> ~/.config/dot-backup/config +./dot-backup.sh --suggest | grep zzzignore && echo "STILL THERE (bug)" || echo "ignored OK" +``` +Expected: `ignored OK`. + +Cleanup: remove the added `SUGGEST_IGNORE=` line from `~/.config/dot-backup/config` and `rmdir ~/.config/zzzignore`. + +- [ ] **Step 5: Verify suggest survives `--quiet` (stdout, not log)** + +Run: `./dot-backup.sh --suggest --quiet` +Expected: output appears on terminal (the Task 2 guard works end-to-end). The +log file is not written for this invocation. + +- [ ] **Step 6: Commit** + +```bash +git add dot-backup.sh +git commit -m "feat: dispatch --suggest early and exit" +``` + +--- + +## Task 7: Document in config.example, README, CLAUDE.md + +**Files:** +- Modify: `config.example` +- Modify: `README.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add SUGGEST_IGNORE to config.example** + +Append to `config.example`: + +```bash + +# Basenames --suggest skips when scanning for new config (override the +# baked-in default). Uncomment and edit to customize. +#SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv \ +# .mozilla .thunderbird .npm .cargo .rustup .java \ +# .dbus .config) +``` + +- [ ] **Step 2: Document the flag in README.md** + +Find the options/usage list in `README.md` and add an entry matching the +existing format for the other flags: + +``` +-s, --suggest List untracked config dirs/files new since last backup, then exit +``` + +If README has a prose section per flag, add a short paragraph: + +> `--suggest` scans `~/.config` children and top-level hidden dirs/files in +> `$HOME`, and prints any that are newer than the last backup and not already +> tracked, formatted for pasting into `files.list`. It never copies or commits. +> Tune what it skips with `SUGGEST_IGNORE` in the config file. + +Match the README's actual heading/format; do not invent a new section style. + +- [ ] **Step 3: Note the flag and epoch file in CLAUDE.md** + +In `CLAUDE.md`, add `--suggest` to the behavior description and note the new +file. Suggested addition under the architecture/running section: + +> `--suggest` lists untracked config dirs/files newer than the last backup +> (read-only; prints a `files.list` paste block, then exits). The backup writes +> a machine-readable `lastupdate.epoch` (epoch seconds) next to `lastupdate`; +> `--suggest` reads it to decide what counts as "new". + +- [ ] **Step 4: Verify docs reference the real flag** + +Run: `grep -l 'suggest' config.example README.md CLAUDE.md` +Expected: all three filenames listed. + +- [ ] **Step 5: Commit** + +```bash +git add config.example README.md CLAUDE.md +git commit -m "docs: document --suggest flag and SUGGEST_IGNORE" +``` + +--- + +## Final verification + +- [ ] `bash -n dot-backup.sh` — clean parse. +- [ ] `./dot-backup.sh --help` — shows `-s, --suggest`. +- [ ] `./dot-backup.sh --suggest` — lists or reports none, exits without backup. +- [ ] `./dot-backup.sh --suggest --quiet` — output on terminal, not log. +- [ ] A real backup run (`./dot-backup.sh`) writes both `lastupdate` and `lastupdate.epoch` in `$DEFAULT_OUTPUT_DIR`. +- [ ] After a real backup, `--suggest` list shrinks to only paths newer than that backup. |
