aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/undo.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /cmd/krino/undo.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-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.go533
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")
+}