aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-06-16-suggest-flag.md
blob: 6a7c9680c1f49b56c7a10b44f1b32d512078e735 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
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.