diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
| commit | 3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch) | |
| tree | ec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /cmd/krino/undo.go | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip | |
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'cmd/krino/undo.go')
| -rw-r--r-- | cmd/krino/undo.go | 533 |
1 files changed, 533 insertions, 0 deletions
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go new file mode 100644 index 0000000..7ffe428 --- /dev/null +++ b/cmd/krino/undo.go @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "golang.org/x/term" + + "krino/internal/config" + "krino/internal/engine" + "krino/internal/journal" + "krino/internal/lock" + "krino/internal/tui" + "krino/internal/xdg" +) + +func init() { commands["undo"] = cmdUndo } + +// cmdUndo reverses a run: the one named on the command line, or (spec §10) +// the most recent one otherwise. Since the newest run in the log can never +// itself be marked Undone - that would require a still-later run to have +// reversed it - "the most recent run" and "the most recent run that has not +// been undone" are the same run in every case, including the one this +// task's own test exercises: undoing an undo run a second time with no RUN +// argument targets that very undo run, which PlanUndo then refuses by name. +// +// Undo builds a plan like any other, shown and approved the same way (spec +// §10) - reviewUndoDir/-Files/-PerFile below are undo's own counterpart to +// review.go's reviewChains/reviewPerFile, not a call into them: an undo plan +// is []engine.UndoFile, which can span several directories in one flat +// list, so two files from different directories can share the same Rel and +// approval here is keyed by index rather than by name. +func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int { + fs := flagSet("undo", g) + fs.BoolVar(&g.yes, "y", false, "") + fs.BoolVar(&g.dry, "n", false, "") + if code, ok := parse(fs, args, stdout, stderr); !ok { + return code + } + if g.yes && g.dry { + return usageError(stderr, "-y and -n cannot be used together") + } + rest := fs.Args() + if len(rest) > 1 { + return usageError(stderr, "usage: krino undo [RUN]") + } + + e, errs := engine.Load(mainFile(g)) + if len(errs) > 0 { + printDiags(stderr, errs) + return 2 + } + + // Spec §8.4/§10: with neither -y nor -n, krino asks; a non-terminal + // stdin would just hang, so it refuses instead - the same check + // cmdSort makes before it ever shows a plan. + if !g.yes && !g.dry && !term.IsTerminal(int(stdin.Fd())) { + return usageError(stderr, "refusing to prompt: stdin is not a terminal (use -y or -n)") + } + + // Installed here, before any paging or review, not just around + // ApplyUndo: Ruling 5 (Task 7) is that SIGTERM landing between + // keystrokes or during the pager needs the terminal restored, and that + // window starts as soon as this command might show something on a + // terminal. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stopSignals := installSignalHandler(cancel) + defer stopSignals() + + runID := "" + if len(rest) == 1 { + runID = rest[0] + } else { + runs, err := e.Runs(1) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintln(stdout, "nothing logged yet; nothing to undo") + return 0 + } + fmt.Fprintf(stderr, "krino: %v\n", err) + return 1 + } + if len(runs) == 0 { + fmt.Fprintln(stdout, "nothing logged yet; nothing to undo") + return 0 + } + runID = runs[0].ID + } + + // PlanUndo only reads the log; nothing is touched yet (spec §10), which + // is what makes it safe to call before any lock is taken. + up, err := e.PlanUndo(runID) + if err != nil { + fmt.Fprintf(stderr, "krino: %v\n", err) + return 1 + } + + // Fix round 2026-09-12 (widened per the coordinator's follow-up + // ruling): an undo moves files just as an apply does, so it needs + // cmdSort's same per-directory guard (spec §11: a second krino on the + // same directory waits for the lock, or fails immediately with -y), + // held across the SAME window cmdSort holds its own lock across - the + // plan display and the review, not just the apply. Failing before the + // plan is even shown is strictly kinder than making the user review + // (potentially hundreds of files) only to be refused afterward, and it + // means a plan actually reviewed cannot go stale under the reader's + // eyes from another krino moving those same files mid-review. -n never + // reaches this: it changes nothing, so it takes no lock either. One + // undo run can span several directories (UndoFile.Dir is per file), so + // every distinct one up.Files touches is locked, in a fixed (sorted) + // order, and released on every path below - including [s]/[q], every + // early return, and a later ApplyUndo failure - by the single defer + // right after acquisition. + var locks []*lock.Lock + if !g.dry { + locks, err = acquireUndoLocks(ctx, e.Config, undoDirNames(up.Files), !g.yes) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return 130 + } + fmt.Fprintf(stderr, "krino: %v\n", err) + return 1 + } + defer func() { + for _, rerr := range releaseUndoLocks(locks) { + fmt.Fprintf(stderr, "krino: %v\n", rerr) + } + }() + } + + fmt.Fprintf(stdout, "krino: undo %s\n", up.Run) + var buf bytes.Buffer + printUndoPlan(&buf, up) + text := colourRefused(buf.String(), tui.Colour(stdout)) + // Ruling 6 (Task 7), carried over: the plan goes through tui.Page for + // -n as much as for -y and the interactive path. + if err := tui.Page(stdout, text); err != nil { + fmt.Fprintf(stderr, "krino: %v\n", err) + return 1 + } + + if g.dry { + return 0 + } + + if undoActionableCount(up.Files) == 0 { + // Every file was refused, or the run touched none at all: neither + // -y nor the interactive menu has anything useful to do (mirrors + // cmdSort's identical check before it ever asks). + fmt.Fprintln(stdout, zeroOutcome) + return 0 + } + + var approved map[int]bool + var action rune + if g.yes { + approved, action = approveAllUndo(up.Files), 'a' + } else { + var rerr error + approved, action, rerr = reviewUndoDir(stdout, up.Files) + if rerr != nil { + fmt.Fprintf(stderr, "krino: %v\n", rerr) + return 1 + } + } + + if action == 's' || action == 'q' { + fmt.Fprintln(stdout, zeroOutcome) + return 0 + } + + toApply := finalizeUndoPlan(up, approved) + + // Ruling 2 (Task 7), carried over: journal.Open creates the state + // directory and the log file as a side effect of merely being called, + // so it is opened only once we know something will actually be + // applied - never for -n (returned above), and not merely because -y + // or a review session ran, unlike cmdSort's own eager-open (which opens + // before it knows whether anything is actionable, a difference forced + // by cmdSort not yet having a plan to inspect at that point in its + // flow; undo already does, so it opens later, and never opens if + // undoActionableCount was 0 or the user chose [s]/[q] above). + j, err := journal.Open(e.Config.LogFile()) + if err != nil { + fmt.Fprintf(stderr, "krino: %v\n", err) + return 1 + } + defer func() { + if cerr := j.Close(); cerr != nil { + fmt.Fprintf(stderr, "krino: %v\n", cerr) + } + }() + run := journal.NewRunID(e.Now()) + + res, aerr := e.ApplyUndo(ctx, toApply, j, run) + if aerr != nil { + if errors.Is(aerr, context.Canceled) || errors.Is(aerr, context.DeadlineExceeded) { + // Interrupted mid-apply: the ctx.Err() check below turns this + // into exit 130, same as cmdSort. + return 130 + } + fmt.Fprintf(stderr, "krino: %v\n", aerr) + return 1 + } + fmt.Fprintf(stdout, "%d applied · %d failed · %d declined\n", res.Applied, res.Failed, res.Declined) + + if ctx.Err() != nil { + return 130 + } + if res.Failed > 0 { + return 1 + } + return 0 +} + +// undoActionableCount counts the files in files that PlanUndo has not +// already refused - the same "is there anything to even ask about" gate +// cmdSort's actionableChains serves for a sort plan. +func undoActionableCount(files []engine.UndoFile) int { + n := 0 + for _, f := range files { + if f.Refused == "" { + n++ + } + } + return n +} + +// undoDirNames returns the distinct directory names an undo plan's files +// touch, sorted: a fixed order so two processes each locking a plan that +// shares more than one directory always acquire them in the same sequence, +// the standard way to avoid a lock-order deadlock. UndoFile.Dir is the +// journal's own `dir` column - the directory's config NAME (e.g. "dl"), not +// a filesystem path - which is exactly what config.Config.LockFile takes. +func undoDirNames(files []engine.UndoFile) []string { + seen := map[string]bool{} + var out []string + for _, f := range files { + if f.Dir != "" && !seen[f.Dir] { + seen[f.Dir] = true + out = append(out, f.Dir) + } + } + sort.Strings(out) + return out +} + +// acquireUndoLocks takes the lock for every name in dirs, in order, +// mirroring cmdSort's per-directory lock.Acquire call. If any acquisition +// fails - held with wait false, or ctx cancelled while waiting - every lock +// already taken is released before returning, so a partial lock set is +// never left held while the caller reports the error and stops. +func acquireUndoLocks(ctx context.Context, cfg *config.Config, dirs []string, wait bool) ([]*lock.Lock, error) { + locks := make([]*lock.Lock, 0, len(dirs)) + for _, name := range dirs { + l, err := lock.Acquire(ctx, cfg.LockFile(name), wait) + if err != nil { + releaseUndoLocks(locks) + return nil, fmt.Errorf("%s: %w", name, err) + } + locks = append(locks, l) + } + return locks, nil +} + +// releaseUndoLocks releases every lock in locks and returns any release +// errors, one lock's failure never stopping the rest from being released - +// the same "release on every path" guarantee cmdSort gives its own single +// lock, extended to however many an undo plan needed. +func releaseUndoLocks(locks []*lock.Lock) []error { + var errs []error + for _, l := range locks { + if err := l.Release(); err != nil { + errs = append(errs, err) + } + } + return errs +} + +// finalizeUndoPlan builds the *engine.UndoPlan ApplyUndo actually runs, +// preserving up.Files' own order: every refused file rides along unchanged +// (ApplyUndo declines these itself, silently, exactly as it already does +// when handed the unfiltered plan - spec §10's refusal is not this task's +// to make noisier); every actionable file approved marks true rides along +// unchanged too. A file the user said no to, or left unmarked when [d] or +// [q] cut a per-file review short, is not dropped - fix round 2026-09-12, +// item 2 of Task 8's review: spec §9 says a declined file is logged even +// though nothing happens to it, the same as the forward path already does, +// so it is kept with Declined set, which tells ApplyUndo to log its steps +// as declined rather than reverse them. +func finalizeUndoPlan(up *engine.UndoPlan, approved map[int]bool) *engine.UndoPlan { + out := &engine.UndoPlan{Run: up.Run} + for i, f := range up.Files { + if f.Refused == "" && !approved[i] { + f.Declined = true + } + out.Files = append(out.Files, f) + } + return out +} + +// reviewUndoDir drives the interactive review over the real terminal, +// mirroring review.go's reviewDir for the forward path. +func reviewUndoDir(out io.Writer, files []engine.UndoFile) (map[int]bool, rune, error) { + return reviewUndoFiles(keyReader{stdin}, out, files) +} + +// reviewUndoFiles is spec §10's approval flow for an undo plan: the +// top-level +// +// [a] apply all [c] choose per file [s] skip [q] quit +// +// menu, and, for [c], the per-file +// +// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing +// +// prompt - the same shape as review.go's reviewChains/reviewPerFile, over a +// different plan shape (approved is keyed by index into files, not by +// name). action is always one of 'a', 'c', 's' or 'q', with the same [q] +// folding rule reviewChains uses: a [c] session's own [q] becomes the same +// top-level 'q', and approved is emptied to match. +func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile) (map[int]bool, rune, error) { + fmt.Fprint(out, "\n[a] apply all [c] choose per file [s] skip [q] quit\n") + for { + key, err := readKey(in) + if err != nil { + return nil, 0, err + } + switch key { + case 'a': + return approveAllUndo(files), 'a', nil + case 's': + return map[int]bool{}, 's', nil + case 'q': + return map[int]bool{}, 'q', nil + case 'c': + approved, quit, err := reviewUndoPerFile(in, out, files) + if err != nil { + return nil, 0, err + } + if quit { + return map[int]bool{}, 'q', nil + } + return approved, 'c', nil + default: + fmt.Fprintf(out, "%q is not a, c, s or q\n", key) + } + } +} + +// reviewUndoPerFile is the per-file half of reviewUndoFiles. A file already +// refused at planning time is never asked about - spec §10 shows it with +// its reason and reverses nothing of it regardless of anything chosen here +// - but it still gets its own [i/N] line, so the numbering accounts for +// every file in the plan, not just the reversible ones. +func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile) (approved map[int]bool, quit bool, err error) { + approved = map[int]bool{} + yesRest := false + for i, f := range files { + fmt.Fprintf(out, "\n[%d/%d] %s/%s\n", i+1, len(files), f.Dir, f.File) + for _, s := range f.Steps { + fmt.Fprintf(out, " %s\n", undoActionCell(s)) + } + if f.Refused != "" { + fmt.Fprintf(out, " refused: %s\n", f.Refused) + continue + } + if yesRest { + approved[i] = true + continue + } + + fmt.Fprint(out, " [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing\n") + for { + key, kerr := readKey(in) + if kerr != nil { + return nil, false, kerr + } + switch key { + case 'y': + approved[i] = true + case 'n': + // leave unapproved + case 'a': + approved[i] = true + yesRest = true + case 'd': + return approved, false, nil + case 'q': + return nil, true, nil + default: + fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key) + continue + } + break + } + } + return approved, false, nil +} + +// approveAllUndo approves every reversible file in files by index - [a] +// apply all, at either the top level or mid per-file review. A refused file +// is never marked true: nothing would happen to it anyway (ApplyUndo skips +// it unconditionally), and leaving it unmarked here keeps this function's +// contract simple - "true means ask ApplyUndo to reverse it" - rather than +// also being the thing that decides refused files ride along regardless +// (finalizeUndoPlan does that, independently of this map). +func approveAllUndo(files []engine.UndoFile) map[int]bool { + approved := make(map[int]bool, len(files)) + for i, f := range files { + if f.Refused == "" { + approved[i] = true + } + } + return approved +} + +// undoStepWidth is the widest undo action word (padCell aligns every +// arrow), matching render.go's actionKindWidth for the forward table. +const undoStepWidth = len("undo-displace") + +// undoActionCell renders one undo step: its own refusal reason when it has +// one (a sibling directory not yet empty for undo-mkdir - spec §10's one +// case where a step's own failure does not refuse its whole file), the +// directory removed for undo-mkdir (no destination to show), the file being +// trashed for undo-copy (fix wave item 3: its Dst is deliberately empty - +// trash.Put only chooses the entry name at execution time - so this is the +// one action with no path to point an arrow at; before this fix the cell +// rendered as a bare "undo-copy → ", the plan's one row that said +// nothing about what it would do to the user's file), or an arrow to where +// the step puts the file back, ~-abbreviated - undo has no single root the +// way a sort plan does (one run can span several directories), so there is +// no root-relative form to render here the way actionCell has. +func undoActionCell(s engine.UndoStep) string { + if s.Refused != "" { + return padCell(s.Action, undoStepWidth) + " refused: " + s.Refused + } + switch s.Action { + case "undo-mkdir": + return padCell(s.Action, undoStepWidth) + " " + xdg.Abbrev(s.Src) + case "undo-copy": + return padCell(s.Action, undoStepWidth) + " " + xdg.Abbrev(s.Src) + " → trash" + } + return padCell(s.Action, undoStepWidth) + " → " + xdg.Abbrev(s.Dst) +} + +// printUndoPlan renders an undo plan the way krino undo shows it, below the +// "krino: undo RUN" header line cmdUndo has already written: a counts line, +// then the numbered table, mirroring printPlan's shape for a sort plan +// (spec §10: "shown ... the same way"). +func printUndoPlan(w io.Writer, up *engine.UndoPlan) { + actionable := undoActionableCount(up.Files) + fmt.Fprintf(w, "%d files · %d to reverse · %d refused\n", len(up.Files), actionable, len(up.Files)-actionable) + if len(up.Files) == 0 { + return + } + fmt.Fprintln(w) + printUndoTable(w, up.Files) +} + +// undoRow is one line of the undo table: a file's first step (num and file +// set) or a continuation line (both blank), the same layout planRow uses +// for a sort plan. +type undoRow struct { + num, file, action string +} + +// undoRows turns files into table rows. A refused file gets exactly one +// row - there is nothing to reverse, so no per-step continuation lines - +// showing its reason in place of any step. +func undoRows(files []engine.UndoFile) []undoRow { + var rows []undoRow + for i, f := range files { + label := f.Dir + "/" + f.File + if f.Refused != "" { + rows = append(rows, undoRow{num: strconv.Itoa(i + 1), file: label, action: "refused: " + f.Refused}) + continue + } + for j, s := range f.Steps { + row := undoRow{action: undoActionCell(s)} + if j == 0 { + row.num = strconv.Itoa(i + 1) + row.file = label + } + rows = append(rows, row) + } + } + return rows +} + +// printUndoTable prints rows in the same #, file, action layout +// printPlanTable uses for a sort plan, reusing its column-width helpers +// (relWidth/padCell/padLeft/colWidth, render.go) rather than re-deriving +// them. +func printUndoTable(w io.Writer, files []engine.UndoFile) { + rows := undoRows(files) + nums := make([]string, len(rows)) + fls := make([]string, len(rows)) + for i, r := range rows { + nums[i], fls[i] = r.num, r.file + } + numW := colWidth(nums, 0) + fileW := relWidth(fls) + + fmt.Fprintf(w, " %s %s steps\n", padLeft("#", numW), padCell("file", fileW)) + for _, r := range rows { + fmt.Fprintf(w, " %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), r.action) + } +} + +// colourRefused highlights "refused:" in the terminal's own ANSI red (bold, +// slot 1 - never hex), the undo counterpart of sort.go's +// colourDeletePermanently: the one thing an undo plan singles out for +// attention is the file or step nothing will be reversed for. Same +// no-op-when-plain guarantee: with colour false this never touches the +// text, which is what keeps every escape byte out of a plan piped to a +// file or read by another tool. +func colourRefused(text string, colour bool) string { + if !colour { + return text + } + return strings.ReplaceAll(text, "refused:", "\x1b[1;31mrefused:\x1b[0m") +} |
