// 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) // The defaults are the values run() already parsed, so -y or -n written // before "undo" survives registering them again here. fs.BoolVar(&g.yes, "y", g.yes, "") fs.BoolVar(&g.dry, "n", g.dry, "") 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 } p := palette{on: colourOn(g, stdout)} // 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.Fprintln(stdout, p.bold("krino: undo "+up.Run)) var buf bytes.Buffer printUndoPlan(&buf, up) text := colourRefused(buf.String(), p) // 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, p) 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.Fprintln(stdout, outcome(p, 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; p styles the // prompts. func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) { return reviewUndoFiles(keyReader{stdin}, out, files, p) } // 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, p palette) (map[int]bool, rune, error) { fmt.Fprint(out, "\n"+p.keys("[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, p) 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, p palette) (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, " "+p.keys("[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 styles every "refused:" in an undo plan bold red (spec // §8.2): the one thing an undo plan singles out is the file or step nothing // will be reversed for. With the plain palette the text is untouched, which // keeps every escape byte out of a plan piped to a file or read by another // tool. func colourRefused(text string, p palette) string { if !p.on { return text } return strings.ReplaceAll(text, "refused:", p.alarm("refused:")) }