v0.3.47 56b82c0

sessionscmd.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"errors"
6	"fmt"
7	"io"
8	"io/fs"
9	"os"
10	"path/filepath"
11	"sort"
12	"strconv"
13	"strings"
14
15	"github.com/patriceckhart/zot/packages/core"
16)
17
18type sessionsPruneOptions struct {
19	dryRun bool
20	all    bool
21	yes    bool
22}
23
24type statPathFunc func(string) (fs.FileInfo, error)
25
26// runSessionsCommand dispatches standalone session management commands before
27// normal agent startup, so they do not require a provider or credentials.
28func runSessionsCommand(rawArgs []string) (handled bool, err error) {
29	if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
30		return false, nil
31	}
32	if len(rawArgs) == 1 {
33		printSessionsHelp(os.Stdout)
34		return true, nil
35	}
36	switch rawArgs[1] {
37	case "help", "-h", "--help":
38		printSessionsHelp(os.Stdout)
39		return true, nil
40	case "prune":
41		opts, err := parseSessionsPruneOptions(rawArgs[2:])
42		if errors.Is(err, errSessionsPruneHelp) {
43			printSessionsHelp(os.Stdout)
44			return true, nil
45		}
46		if err != nil {
47			printSessionsHelp(os.Stderr)
48			return true, err
49		}
50		return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
51	default:
52		printSessionsHelp(os.Stderr)
53		return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
54	}
55}
56
57func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
58	var opts sessionsPruneOptions
59	for _, arg := range args {
60		switch arg {
61		case "--dry-run":
62			opts.dryRun = true
63		case "--all":
64			opts.all = true
65		case "-y", "--yes":
66			opts.yes = true
67		case "-h", "--help":
68			return opts, errSessionsPruneHelp
69		default:
70			return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
71		}
72	}
73	if opts.yes && !opts.all {
74		return opts, fmt.Errorf("--yes requires --all")
75	}
76	if opts.yes && opts.dryRun {
77		return opts, fmt.Errorf("--yes cannot be used with --dry-run")
78	}
79	return opts, nil
80}
81
82var errSessionsPruneHelp = errors.New("sessions prune help requested")
83
84func printSessionsHelp(out io.Writer) {
85	fmt.Fprintln(out, `zot sessions - manage stored sessions
86
87usage:
88  zot sessions prune             select stale directory groups and confirm deletion
89  zot sessions prune --dry-run   list stale groups without deleting anything
90  zot sessions prune --all       select every stale group, then confirm
91  zot sessions prune --all --yes delete every stale group without prompting
92
93A group is stale only when its recorded working directory no longer exists.
94Unreadable, malformed, and temporarily inaccessible entries are preserved.`)
95}
96
97func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
98	groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
99	for _, issue := range scanIssues {
100		fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
101	}
102
103	stale := make([]core.StoredSessionGroup, 0, len(groups))
104	for _, group := range groups {
105		if !filepath.IsAbs(group.CWD) {
106			fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
107			continue
108		}
109		_, err := stat(group.CWD)
110		switch {
111		case err == nil:
112			continue
113		case errors.Is(err, fs.ErrNotExist):
114			stale = append(stale, group)
115		default:
116			fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
117		}
118	}
119	if len(stale) == 0 {
120		fmt.Fprintln(out, "no stale session directories found")
121		return nil
122	}
123
124	fmt.Fprintln(out, "stale session directories:")
125	for idx, group := range stale {
126		fmt.Fprintf(out, "  %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
127	}
128	if opts.dryRun {
129		fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale)))
130		return nil
131	}
132
133	reader := bufio.NewReader(in)
134	selected := make([]int, 0, len(stale))
135	if opts.all {
136		for idx := range stale {
137			selected = append(selected, idx)
138		}
139	} else {
140		fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
141		line, err := reader.ReadString('\n')
142		if err != nil && !errors.Is(err, io.EOF) {
143			return fmt.Errorf("read selection: %w", err)
144		}
145		selected, err = parseSessionSelection(line, len(stale))
146		if err != nil {
147			return err
148		}
149		if len(selected) == 0 {
150			fmt.Fprintln(out, "no sessions deleted")
151			return nil
152		}
153	}
154
155	selectedSessions := 0
156	for _, idx := range selected {
157		selectedSessions += len(stale[idx].Paths)
158	}
159	if !opts.yes {
160		fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
161		line, err := reader.ReadString('\n')
162		if err != nil && !errors.Is(err, io.EOF) {
163			return fmt.Errorf("read confirmation: %w", err)
164		}
165		answer := strings.ToLower(strings.TrimSpace(line))
166		if answer != "y" && answer != "yes" {
167			fmt.Fprintln(out, "no sessions deleted")
168			return nil
169		}
170	}
171
172	deletedSessions := 0
173	deletedGroups := 0
174	var deleteErrors []error
175	for _, idx := range selected {
176		group := stale[idx]
177		_, err := stat(group.CWD)
178		if err == nil {
179			fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
180			continue
181		}
182		if !errors.Is(err, fs.ErrNotExist) {
183			fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
184			continue
185		}
186
187		groupDeleted := 0
188		for _, path := range group.Paths {
189			if err := core.DeleteSession(path); err != nil {
190				deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
191				continue
192			}
193			groupDeleted++
194			_ = os.Remove(filepath.Dir(path))
195		}
196		deletedSessions += groupDeleted
197		if groupDeleted == len(group.Paths) {
198			deletedGroups++
199		}
200	}
201	fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
202	return errors.Join(deleteErrors...)
203}
204
205func parseSessionSelection(input string, count int) ([]int, error) {
206	input = strings.ToLower(strings.TrimSpace(input))
207	switch input {
208	case "", "none", "n":
209		return nil, nil
210	case "all", "a":
211		selected := make([]int, count)
212		for idx := range selected {
213			selected[idx] = idx
214		}
215		return selected, nil
216	}
217
218	seen := make(map[int]bool)
219	for _, field := range strings.Split(input, ",") {
220		field = strings.TrimSpace(field)
221		if field == "" {
222			return nil, fmt.Errorf("invalid empty selection")
223		}
224		startText, endText, isRange := strings.Cut(field, "-")
225		start, err := strconv.Atoi(strings.TrimSpace(startText))
226		if err != nil {
227			return nil, fmt.Errorf("invalid selection %q", field)
228		}
229		end := start
230		if isRange {
231			end, err = strconv.Atoi(strings.TrimSpace(endText))
232			if err != nil || end < start {
233				return nil, fmt.Errorf("invalid selection range %q", field)
234			}
235		}
236		if start < 1 || end > count {
237			return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
238		}
239		for value := start; value <= end; value++ {
240			seen[value-1] = true
241		}
242	}
243	selected := make([]int, 0, len(seen))
244	for idx := range seen {
245		selected = append(selected, idx)
246	}
247	sort.Ints(selected)
248	return selected, nil
249}
250
251func totalSessions(groups []core.StoredSessionGroup) int {
252	total := 0
253	for _, group := range groups {
254		total += len(group.Paths)
255	}
256	return total
257}
258
259func sessionCount(count int) string {
260	if count == 1 {
261		return "1 session"
262	}
263	return fmt.Sprintf("%d sessions", count)
264}
265
266func directoryCount(count int) string {
267	if count == 1 {
268		return "1 directory"
269	}
270	return fmt.Sprintf("%d directories", count)
271}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close