v0.3.99 352bb42

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	"time"
15
16	"github.com/patriceckhart/zot/packages/core"
17	"github.com/patriceckhart/zot/packages/provider"
18)
19
20type sessionsPruneOptions struct {
21	dryRun    bool
22	all       bool
23	yes       bool
24	olderThan *sessionAge
25	cwd       string
26}
27
28type sessionAge struct {
29	value int
30	unit  string
31}
32
33type statPathFunc func(string) (fs.FileInfo, error)
34
35// runSessionsCommand dispatches standalone session management commands before
36// normal agent startup, so they do not require a provider or credentials.
37func runSessionsCommand(rawArgs []string) (handled bool, err error) {
38	if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
39		return false, nil
40	}
41	if len(rawArgs) == 1 {
42		printSessionsHelp(os.Stdout)
43		return true, nil
44	}
45	switch rawArgs[1] {
46	case "help", "-h", "--help":
47		printSessionsHelp(os.Stdout)
48		return true, nil
49	case "prune":
50		opts, err := parseSessionsPruneOptions(rawArgs[2:])
51		if errors.Is(err, errSessionsPruneHelp) {
52			printSessionsHelp(os.Stdout)
53			return true, nil
54		}
55		if err != nil {
56			printSessionsHelp(os.Stderr)
57			return true, err
58		}
59		return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
60	default:
61		printSessionsHelp(os.Stderr)
62		return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
63	}
64}
65
66func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
67	var opts sessionsPruneOptions
68	for idx := 0; idx < len(args); idx++ {
69		arg := args[idx]
70		switch arg {
71		case "--dry-run":
72			opts.dryRun = true
73		case "--all":
74			opts.all = true
75		case "-y", "--yes":
76			opts.yes = true
77		case "--older-than":
78			if idx+1 >= len(args) {
79				return opts, fmt.Errorf("--older-than requires an age")
80			}
81			idx++
82			age, err := parseSessionAge(args[idx])
83			if err != nil {
84				return opts, err
85			}
86			opts.olderThan = &age
87		case "--cwd":
88			if idx+1 >= len(args) {
89				return opts, fmt.Errorf("--cwd requires a path")
90			}
91			idx++
92			if strings.TrimSpace(args[idx]) == "" {
93				return opts, fmt.Errorf("--cwd requires a non-empty path")
94			}
95			opts.cwd = args[idx]
96		case "-h", "--help":
97			return opts, errSessionsPruneHelp
98		default:
99			return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
100		}
101	}
102	if opts.yes && !opts.all {
103		return opts, fmt.Errorf("--yes requires --all")
104	}
105	if opts.yes && opts.dryRun {
106		return opts, fmt.Errorf("--yes cannot be used with --dry-run")
107	}
108	if opts.cwd != "" && opts.olderThan == nil {
109		return opts, fmt.Errorf("--cwd requires --older-than")
110	}
111	return opts, nil
112}
113
114func parseSessionAge(value string) (sessionAge, error) {
115	value = strings.TrimSpace(value)
116	digitEnd := 0
117	for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' {
118		digitEnd++
119	}
120	if digitEnd == 0 || digitEnd == len(value) {
121		return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value)
122	}
123	amount, err := strconv.ParseInt(value[:digitEnd], 10, 32)
124	if err != nil || amount <= 0 {
125		return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value)
126	}
127	unit := value[digitEnd:]
128	switch unit {
129	case "m", "h", "d", "w", "mo", "y":
130	default:
131		return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit)
132	}
133	age := sessionAge{value: int(amount), unit: unit}
134	if _, err := age.cutoff(time.Unix(0, 0)); err != nil {
135		return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err)
136	}
137	return age, nil
138}
139
140func (age sessionAge) cutoff(now time.Time) (time.Time, error) {
141	var unit time.Duration
142	switch age.unit {
143	case "m":
144		unit = time.Minute
145	case "h":
146		unit = time.Hour
147	case "d":
148		unit = 24 * time.Hour
149	case "w":
150		unit = 7 * 24 * time.Hour
151	case "mo":
152		return subtractCalendarAge(now, 0, age.value), nil
153	case "y":
154		return subtractCalendarAge(now, age.value, 0), nil
155	default:
156		return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit)
157	}
158	if int64(age.value) > int64((time.Duration(1<<63-1))/unit) {
159		return time.Time{}, fmt.Errorf("duration is too large")
160	}
161	return now.Add(-time.Duration(age.value) * unit), nil
162}
163
164// subtractCalendarAge clamps the day to the target month's final day. This
165// makes one month before March 31 the end of February rather than early March.
166func subtractCalendarAge(now time.Time, years, months int) time.Time {
167	first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
168	target := first.AddDate(-years, -months, 0)
169	lastDay := target.AddDate(0, 1, -1).Day()
170	day := now.Day()
171	if day > lastDay {
172		day = lastDay
173	}
174	return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
175}
176
177var errSessionsPruneHelp = errors.New("sessions prune help requested")
178
179func printSessionsHelp(out io.Writer) {
180	fmt.Fprintln(out, `zot sessions - manage stored sessions
181
182usage:
183  zot sessions prune                         select missing directory groups
184  zot sessions prune --older-than 30d        select sessions inactive for 30 days
185  zot sessions prune --older-than 1mo --cwd PATH
186  zot sessions prune --dry-run               list matches without deleting
187  zot sessions prune --all [--yes]           select every match, optionally without prompting
188
189Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks),
190mo (calendar months), and y (calendar years). Age uses last session activity.
191Without --older-than, only sessions whose recorded directory is missing match.
192A missing directory cannot be distinguished from some unmounted filesystems.
193Unreadable and malformed entries are preserved.`)
194}
195
196func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
197	groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
198	for _, issue := range scanIssues {
199		fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
200	}
201
202	candidates := make([]core.StoredSessionGroup, 0, len(groups))
203	var cutoff time.Time
204	if opts.olderThan != nil {
205		var err error
206		cutoff, err = opts.olderThan.cutoff(time.Now())
207		if err != nil {
208			return err
209		}
210		cwd := opts.cwd
211		if cwd != "" {
212			cwd, err = filepath.Abs(cwd)
213			if err != nil {
214				return fmt.Errorf("resolve --cwd: %w", err)
215			}
216			cwd = filepath.Clean(cwd)
217		}
218		for _, group := range groups {
219			if cwd != "" && filepath.Clean(group.CWD) != cwd {
220				continue
221			}
222			matching := core.StoredSessionGroup{CWD: group.CWD}
223			for _, path := range group.Paths {
224				info, err := stat(path)
225				if err != nil {
226					fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err)
227					continue
228				}
229				if info.ModTime().Before(cutoff) {
230					matching.Paths = append(matching.Paths, path)
231					matching.SizeBytes += info.Size()
232				}
233			}
234			if len(matching.Paths) > 0 {
235				candidates = append(candidates, matching)
236			}
237		}
238	} else {
239		for _, group := range groups {
240			if !filepath.IsAbs(group.CWD) {
241				fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
242				continue
243			}
244			_, err := stat(group.CWD)
245			switch {
246			case err == nil:
247				continue
248			case errors.Is(err, fs.ErrNotExist):
249				candidates = append(candidates, group)
250			default:
251				fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
252			}
253		}
254	}
255	if len(candidates) == 0 {
256		if opts.olderThan != nil {
257			fmt.Fprintln(out, "no sessions older than the requested age found")
258		} else {
259			fmt.Fprintln(out, "no stale session directories found")
260		}
261		return nil
262	}
263
264	if opts.olderThan != nil {
265		fmt.Fprintln(out, "session directories with inactive sessions:")
266	} else {
267		fmt.Fprintln(out, "missing session directories:")
268	}
269	for idx, group := range candidates {
270		fmt.Fprintf(out, "  %d. %s (%s, %s)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), provider.FormatBytes(group.SizeBytes))
271	}
272	if opts.dryRun {
273		fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
274		return nil
275	}
276
277	reader := bufio.NewReader(in)
278	selected := make([]int, 0, len(candidates))
279	if opts.all {
280		for idx := range candidates {
281			selected = append(selected, idx)
282		}
283	} else {
284		fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
285		line, err := reader.ReadString('\n')
286		if err != nil && !errors.Is(err, io.EOF) {
287			return fmt.Errorf("read selection: %w", err)
288		}
289		selected, err = parseSessionSelection(line, len(candidates))
290		if err != nil {
291			return err
292		}
293		if len(selected) == 0 {
294			fmt.Fprintln(out, "no sessions deleted")
295			return nil
296		}
297	}
298
299	selectedSessions := 0
300	for _, idx := range selected {
301		selectedSessions += len(candidates[idx].Paths)
302	}
303	if !opts.yes {
304		fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
305		line, err := reader.ReadString('\n')
306		if err != nil && !errors.Is(err, io.EOF) {
307			return fmt.Errorf("read confirmation: %w", err)
308		}
309		answer := strings.ToLower(strings.TrimSpace(line))
310		if answer != "y" && answer != "yes" {
311			fmt.Fprintln(out, "no sessions deleted")
312			return nil
313		}
314	}
315
316	deletedSessions := 0
317	deletedGroups := 0
318	var deleteErrors []error
319	for _, idx := range selected {
320		group := candidates[idx]
321		if opts.olderThan == nil {
322			_, err := stat(group.CWD)
323			if err == nil {
324				fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
325				continue
326			}
327			if !errors.Is(err, fs.ErrNotExist) {
328				fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
329				continue
330			}
331		}
332
333		groupDeleted := 0
334		for _, path := range group.Paths {
335			if opts.olderThan != nil {
336				info, err := stat(path)
337				if err != nil {
338					fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err)
339					continue
340				}
341				if !info.ModTime().Before(cutoff) {
342					fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path)
343					continue
344				}
345			}
346			if err := core.DeleteSession(path); err != nil {
347				deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
348				continue
349			}
350			groupDeleted++
351			_ = os.Remove(filepath.Dir(path))
352		}
353		deletedSessions += groupDeleted
354		if groupDeleted == len(group.Paths) {
355			deletedGroups++
356		}
357	}
358	fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
359	return errors.Join(deleteErrors...)
360}
361
362func parseSessionSelection(input string, count int) ([]int, error) {
363	input = strings.ToLower(strings.TrimSpace(input))
364	switch input {
365	case "", "none", "n":
366		return nil, nil
367	case "all", "a":
368		selected := make([]int, count)
369		for idx := range selected {
370			selected[idx] = idx
371		}
372		return selected, nil
373	}
374
375	seen := make(map[int]bool)
376	for _, field := range strings.Split(input, ",") {
377		field = strings.TrimSpace(field)
378		if field == "" {
379			return nil, fmt.Errorf("invalid empty selection")
380		}
381		startText, endText, isRange := strings.Cut(field, "-")
382		start, err := strconv.Atoi(strings.TrimSpace(startText))
383		if err != nil {
384			return nil, fmt.Errorf("invalid selection %q", field)
385		}
386		end := start
387		if isRange {
388			end, err = strconv.Atoi(strings.TrimSpace(endText))
389			if err != nil || end < start {
390				return nil, fmt.Errorf("invalid selection range %q", field)
391			}
392		}
393		if start < 1 || end > count {
394			return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
395		}
396		for value := start; value <= end; value++ {
397			seen[value-1] = true
398		}
399	}
400	selected := make([]int, 0, len(seen))
401	for idx := range seen {
402		selected = append(selected, idx)
403	}
404	sort.Ints(selected)
405	return selected, nil
406}
407
408func totalSessions(groups []core.StoredSessionGroup) int {
409	total := 0
410	for _, group := range groups {
411		total += len(group.Paths)
412	}
413	return total
414}
415
416func sessionCount(count int) string {
417	if count == 1 {
418		return "1 session"
419	}
420	return fmt.Sprintf("%d sessions", count)
421}
422
423func directoryCount(count int) string {
424	if count == 1 {
425		return "1 directory"
426	}
427	return fmt.Sprintf("%d directories", count)
428}

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