aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-06-16-suggest-flag-design.md
blob: fb9ad5efe9795b8a557d3dceb4efc0a656bc11bb (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
# Design: `--suggest` flag

**Date:** 2026-06-16
**Status:** Approved (pending implementation)

## Goal

Add a `-s|--suggest` flag to `dot-backup.sh` that surfaces config
directories and files created since the last backup that are not yet
tracked in `DOTFILES`, and prints a paste-ready block for `files.list`.

The flag is read-only: it never copies, commits, pushes, or mutates any
config. It scans, prints, and exits.

## Behavior

### Flag

- New flag `-s` / `--suggest`, registered in the arg loop and `usage()`.
- When set, run suggest logic, then **exit 0**. No backup/restore/copy/commit.
- Mutually exclusive with `--restore` in practice (suggest exits first if both
  given; suggest takes precedence).

### Scan sources

Three sources, all scanned one level deep only:

1. `~/.config/*/` — direct child directories of `~/.config`.
   Emitted as `.config/<name>`.
2. `~/.*/` — top-level hidden directories in `$HOME`.
   Emitted as `.<name>`.
3. `~/.*` (files) — top-level hidden regular files in `$HOME`.
   Emitted as `.<name>`.

Always skip the special entries `.` and `..`.

### Candidate filter

A scanned path is suggested only if **all** of the following hold:

1. **Newer than last backup** — its mtime is greater than the last-backup
   timestamp (see "lastupdate change" below). If the timestamp file is
   missing (never run, or an older backup that predates this feature), treat
   every path as new.
2. **Not already covered** by a `DOTFILES` entry (see "Dedup matching").
3. **Not ignored** — its basename is not in `SUGGEST_IGNORE`.

### Dedup matching

The emitted path of a candidate is `c` (e.g. `.config/foobar`, `.newtool`).
For each existing `DOTFILES` entry `e`, `c` is considered **covered** if any
of:

- `c == e` (exact match), OR
- `c` is under `e/` (e is an ancestor — e.g. `e=.config`, `c=.config/foobar`), OR
- `e` is under `c/` (c is an ancestor — e.g. `c=.config`, `e=.config/nvim`).

If covered by any entry, the candidate is dropped. This prevents suggesting a
path already listed, a path whose parent is listed, or a parent whose child is
already listed.

Note: `DOTFILES` entries are compared as-emitted (relative home paths and
absolute system paths). System entries (starting `/`) never match home-relative
candidates, which is correct — suggest only scans `$HOME`.

### SUGGEST_IGNORE

A bash array of basenames to skip. If unset (not defined in the config file),
the script falls back to a baked-in default:

```bash
SUGGEST_IGNORE=(.cache .local .git .ssh .gnupg .Trash .pki .nv \
                .mozilla .thunderbird .npm .cargo .rustup .java \
                .dbus .config)
```

`.config` is in the list to exclude it from the **home-dir** scan only (its
children are scanned separately as source 1). The home-dir dir/file scans check
basenames against this list; the `~/.config/*` scan does **not** apply the
`.config` skip to its children (only to deeper basenames if present).

The user may override by defining `SUGGEST_IGNORE` in
`~/.config/dot-backup/config`. A commented example is added to `config.example`.

Detection of "unset": use `${SUGGEST_IGNORE+set}` test so an explicitly empty
array from the user is respected and not overwritten by the default.

## lastupdate change (required)

The current `lastupdate()` writes a human string:

```
Last update: <date>
```

This is not machine-parseable for mtime comparison. Change `lastupdate()` to
**also** write epoch seconds to a sibling file:

- `${DEFAULT_OUTPUT_DIR}/lastupdate` — unchanged human-readable file (kept for
  display and backward compatibility).
- `${DEFAULT_OUTPUT_DIR}/lastupdate.epoch` — new file containing a single
  integer: `date +%s`.

The suggest logic reads `lastupdate.epoch`. If the file is absent or
unparseable, the last-backup timestamp is treated as `0` (everything is new).

This is the only change to existing backup behavior.

## Output

When candidates exist:

```
New since last backup (<human date of last backup>):
  .config/foobar
  .newtool
  .somerc

Paste into files.list:
.config/foobar
.newtool
.somerc
```

- The indented top section is for human reading (colored like other output).
- The bottom block is unindented, plain, ready to paste/append to `files.list`.
- Candidates sorted alphabetically for stable output.

When no candidates:

```
No new config dirs/files since last backup.
```

When `lastupdate.epoch` missing:

- Still works; header reads `New since last backup (never):` and all unlisted,
  unignored paths are shown.

## Interaction with `--quiet`

`--suggest` always writes to the real stdout, even when `--quiet` is also given.
A suggest report in a log file is useless — the user needs to see paste-ready
output on the terminal.

Implementation: the quiet redirect (currently early in the script, saving the
original stdout to fd 3) must **not** apply to suggest. Either:

- skip the quiet redirect entirely when `SUGGEST == true`, OR
- have `do_suggest` write to the saved fd 3.

Skipping the redirect is simpler and preferred: `--suggest` exits before the
backup flow anyway, so quiet has nothing else to suppress. Guard the redirect
block with `if [[ "$QUIET" == true && "$SUGGEST" == false ]]`.

## Scope cuts (YAGNI)

- No interactive prompt, no `y/n`, no appending to `files.list`. Print only.
- No recursion deeper than one level in any scan source.
- No mtime tracking per-path beyond the single last-backup epoch.
- No new dependencies (pure bash + coreutils `date`, `stat`).

## Files touched

- `dot-backup.sh`:
  - Add `SUGGEST=false` flag var + `-s|--suggest` case + usage line.
  - Modify `lastupdate()` to also write `lastupdate.epoch`.
  - Add `SUGGEST_IGNORE` default (guarded by unset test).
  - Add `do_suggest()` function (scan, filter, dedup, print).
  - Early dispatch: if `SUGGEST == true`, call `do_suggest` and exit before
    the backup/restore flow.
- `config.example`: add commented `SUGGEST_IGNORE` example.
- `README.md`: document the `--suggest` flag in the options/usage section.
- `CLAUDE.md`: note the flag and the `lastupdate.epoch` file.

## Testing (manual)

No test harness in repo. Manual verification steps:

1. `./dot-backup.sh --suggest` with no `lastupdate.epoch` → lists all unlisted,
   unignored hidden dirs/files and `.config` children.
2. Run a real backup, then `--suggest` → list shrinks to only paths newer than
   the backup.
3. `mkdir ~/.config/zzztest`, then `--suggest``zzztest` appears.
4. Add `.config/zzztest` to `files.list`, re-run `--suggest` → it disappears
   (dedup).
5. Add `zzztest` basename to `SUGGEST_IGNORE` in config (after removing from
   list) → disappears (ignore).
6. `--dry-run --suggest` and `--suggest --restore` → suggest wins, exits clean.