v0.38.1 969e52a

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/bnema/zut/packages/core"
17)
18
19type sessionsPruneOptions struct {
20	dryRun    bool
21	all       bool
22	yes       bool
23	olderThan *sessionAge
24	cwd       string
25}
26
27type sessionAge struct {
28	value int
29	unit  string
30}
31
32type statPathFunc func(string) (fs.FileInfo, error)
33
34// runSessionsCommand dispatches standalone session management commands
35// before normal agent startup, so they do not require a provider or
36// 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(SessionsPath(), 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, `zut sessions - manage stored sessions
181
182usage:
183  zut sessions prune                         select missing directory groups
184  zut sessions prune --older-than 30d        select sessions inactive for 30 days
185  zut sessions prune --older-than 1mo --cwd PATH
186  zut sessions prune --dry-run               list matches without deleting
187  zut 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(sessionsRoot string, opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
197	groups, scanIssues := core.ScanStoredSessionGroups(sessionsRoot)
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, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
271	}
272	if opts.dryRun {
273		if opts.all {
274			fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
275		} else {
276			fmt.Fprintln(out, "dry run: nothing selected; add --all to preview deletion of every match")
277		}
278		return nil
279	}
280
281	reader := bufio.NewReader(in)
282	selected := make([]int, 0, len(candidates))
283	if opts.all {
284		for idx := range candidates {
285			selected = append(selected, idx)
286		}
287	} else {
288		fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
289		line, err := reader.ReadString('\n')
290		if err != nil && !errors.Is(err, io.EOF) {
291			return fmt.Errorf("read selection: %w", err)
292		}
293		selected, err = parseSessionSelection(line, len(candidates))
294		if err != nil {
295			return err
296		}
297		if len(selected) == 0 {
298			fmt.Fprintln(out, "no sessions deleted")
299			return nil
300		}
301	}
302
303	selectedSessions := 0
304	for _, idx := range selected {
305		selectedSessions += len(candidates[idx].Paths)
306	}
307	if !opts.yes {
308		fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
309		line, err := reader.ReadString('\n')
310		if err != nil && !errors.Is(err, io.EOF) {
311			return fmt.Errorf("read confirmation: %w", err)
312		}
313		answer := strings.ToLower(strings.TrimSpace(line))
314		if answer != "y" && answer != "yes" {
315			fmt.Fprintln(out, "no sessions deleted")
316			return nil
317		}
318	}
319
320	deletedSessions := 0
321	deletedGroups := 0
322	var deleteErrors []error
323	for _, idx := range selected {
324		group := candidates[idx]
325		if opts.olderThan == nil {
326			// Recheck just before deleting: a directory that reappeared
327			// (remount, restore) must not lose its sessions.
328			_, err := stat(group.CWD)
329			if err == nil {
330				fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
331				continue
332			}
333			if !errors.Is(err, fs.ErrNotExist) {
334				fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
335				continue
336			}
337		}
338
339		groupDeleted := 0
340		for _, path := range group.Paths {
341			if opts.olderThan != nil {
342				info, err := stat(path)
343				if err != nil {
344					fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err)
345					continue
346				}
347				if !info.ModTime().Before(cutoff) {
348					fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path)
349					continue
350				}
351			}
352			if err := core.DeleteSession(path); err != nil {
353				deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
354				continue
355			}
356			groupDeleted++
357		}
358		deletedSessions += groupDeleted
359		if groupDeleted == len(group.Paths) {
360			deletedGroups++
361		}
362	}
363	fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
364	return errors.Join(deleteErrors...)
365}
366
367func parseSessionSelection(input string, count int) ([]int, error) {
368	input = strings.ToLower(strings.TrimSpace(input))
369	switch input {
370	case "", "none", "n":
371		return nil, nil
372	case "all", "a":
373		selected := make([]int, count)
374		for idx := range selected {
375			selected[idx] = idx
376		}
377		return selected, nil
378	}
379
380	seen := make(map[int]bool)
381	for _, field := range strings.Split(input, ",") {
382		field = strings.TrimSpace(field)
383		if field == "" {
384			return nil, fmt.Errorf("invalid empty selection")
385		}
386		startText, endText, isRange := strings.Cut(field, "-")
387		start, err := strconv.Atoi(strings.TrimSpace(startText))
388		if err != nil {
389			return nil, fmt.Errorf("invalid selection %q", field)
390		}
391		end := start
392		if isRange {
393			end, err = strconv.Atoi(strings.TrimSpace(endText))
394			if err != nil || end < start {
395				return nil, fmt.Errorf("invalid selection range %q", field)
396			}
397		}
398		if start < 1 || end > count {
399			return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
400		}
401		for value := start; value <= end; value++ {
402			seen[value-1] = true
403		}
404	}
405	selected := make([]int, 0, len(seen))
406	for idx := range seen {
407		selected = append(selected, idx)
408	}
409	sort.Ints(selected)
410	return selected, nil
411}
412
413func totalSessions(groups []core.StoredSessionGroup) int {
414	total := 0
415	for _, group := range groups {
416		total += len(group.Paths)
417	}
418	return total
419}
420
421func sessionCount(count int) string {
422	if count == 1 {
423		return "1 session"
424	}
425	return fmt.Sprintf("%d sessions", count)
426}
427
428func directoryCount(count int) string {
429	if count == 1 {
430		return "1 directory"
431	}
432	return fmt.Sprintf("%d directories", count)
433}

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