v0.3.48 9b7bb6a

sessionscmd_test.go RawHistoryFolder

1package agent
2
3import (
4	"bytes"
5	"io/fs"
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/patriceckhart/zot/packages/core"
14	"github.com/patriceckhart/zot/packages/provider"
15)
16
17func TestParseSessionSelection(t *testing.T) {
18	tests := []struct {
19		input   string
20		count   int
21		want    []int
22		wantErr bool
23	}{
24		{input: "", count: 3},
25		{input: "none", count: 3},
26		{input: "all", count: 3, want: []int{0, 1, 2}},
27		{input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
28		{input: "0", count: 3, wantErr: true},
29		{input: "4", count: 3, wantErr: true},
30		{input: "3-2", count: 3, wantErr: true},
31		{input: "1,,2", count: 3, wantErr: true},
32	}
33	for _, tt := range tests {
34		t.Run(tt.input, func(t *testing.T) {
35			got, err := parseSessionSelection(tt.input, tt.count)
36			if (err != nil) != tt.wantErr {
37				t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
38			}
39			if !reflect.DeepEqual(got, tt.want) {
40				t.Fatalf("selection = %v, want %v", got, tt.want)
41			}
42		})
43	}
44}
45
46func TestParseSessionAge(t *testing.T) {
47	now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC)
48	tests := []struct {
49		input   string
50		want    time.Time
51		wantErr bool
52	}{
53		{input: "30m", want: now.Add(-30 * time.Minute)},
54		{input: "4h", want: now.Add(-4 * time.Hour)},
55		{input: "30d", want: now.Add(-30 * 24 * time.Hour)},
56		{input: "2w", want: now.Add(-14 * 24 * time.Hour)},
57		{input: "1mo", want: now.AddDate(0, -1, 0)},
58		{input: "1y", want: now.AddDate(-1, 0, 0)},
59		{input: "", wantErr: true},
60		{input: "0d", wantErr: true},
61		{input: "-1d", wantErr: true},
62		{input: "1.5h", wantErr: true},
63		{input: "1s", wantErr: true},
64	}
65	for _, tt := range tests {
66		t.Run(tt.input, func(t *testing.T) {
67			age, err := parseSessionAge(tt.input)
68			if (err != nil) != tt.wantErr {
69				t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
70			}
71			if tt.wantErr {
72				return
73			}
74			got, err := age.cutoff(now)
75			if err != nil {
76				t.Fatal(err)
77			}
78			if !got.Equal(tt.want) {
79				t.Fatalf("cutoff = %v, want %v", got, tt.want)
80			}
81		})
82	}
83}
84
85func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
86	home := t.TempDir()
87	t.Setenv("ZOT_HOME", home)
88	missing := filepath.Join(home, "missing-project")
89	existing := t.TempDir()
90	missingPath := createPruneTestSession(t, home, missing)
91	existingPath := createPruneTestSession(t, home, existing)
92
93	var out, errOut bytes.Buffer
94	if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
95		t.Fatal(err)
96	}
97	if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
98		t.Fatalf("output = %q, want only missing cwd", out.String())
99	}
100	if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
101		t.Fatalf("output = %q, want dry-run summary", out.String())
102	}
103	for _, path := range []string{missingPath, existingPath} {
104		if _, err := os.Stat(path); err != nil {
105			t.Fatalf("dry run removed %s: %v", path, err)
106		}
107	}
108}
109
110func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
111	home := t.TempDir()
112	t.Setenv("ZOT_HOME", home)
113	firstCWD := filepath.Join(home, "gone-a")
114	secondCWD := filepath.Join(home, "gone-b")
115	first := createPruneTestSession(t, home, firstCWD)
116	second := createPruneTestSession(t, home, secondCWD)
117
118	var out, errOut bytes.Buffer
119	if err := runSessionsPrune(sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
120		t.Fatal(err)
121	}
122	if _, err := os.Stat(first); err != nil {
123		t.Fatalf("unselected session was removed: %v", err)
124	}
125	if _, err := os.Stat(second); !os.IsNotExist(err) {
126		t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
127	}
128	if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
129		t.Fatalf("output = %q", out.String())
130	}
131}
132
133func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
134	home := t.TempDir()
135	t.Setenv("ZOT_HOME", home)
136	cwd := filepath.Join(home, "unavailable-mount")
137	path := createPruneTestSession(t, home, cwd)
138	stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
139
140	var out, errOut bytes.Buffer
141	if err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
142		t.Fatal(err)
143	}
144	if _, err := os.Stat(path); err != nil {
145		t.Fatalf("session was removed after inconclusive stat: %v", err)
146	}
147	if !strings.Contains(errOut.String(), "permission denied") {
148		t.Fatalf("stderr = %q, want stat warning", errOut.String())
149	}
150	if !strings.Contains(out.String(), "no stale session directories found") {
151		t.Fatalf("output = %q", out.String())
152	}
153}
154
155func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
156	month, err := parseSessionAge("1mo")
157	if err != nil {
158		t.Fatal(err)
159	}
160	got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
161	if err != nil {
162		t.Fatal(err)
163	}
164	want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
165	if !got.Equal(want) {
166		t.Fatalf("one month cutoff = %v, want %v", got, want)
167	}
168
169	year, err := parseSessionAge("1y")
170	if err != nil {
171		t.Fatal(err)
172	}
173	got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
174	if err != nil {
175		t.Fatal(err)
176	}
177	want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
178	if !got.Equal(want) {
179		t.Fatalf("one year cutoff = %v, want %v", got, want)
180	}
181}
182
183func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
184	home := t.TempDir()
185	t.Setenv("ZOT_HOME", home)
186	cwd := t.TempDir()
187	oldPath := createPruneTestSession(t, home, cwd)
188	newPath := createPruneTestSession(t, home, cwd)
189	oldTime := time.Now().Add(-48 * time.Hour)
190	if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
191		t.Fatal(err)
192	}
193
194	age, err := parseSessionAge("24h")
195	if err != nil {
196		t.Fatal(err)
197	}
198	var out, errOut bytes.Buffer
199	opts := sessionsPruneOptions{dryRun: true, olderThan: &age}
200	if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
201		t.Fatal(err)
202	}
203	if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
204		t.Fatalf("output = %q", out.String())
205	}
206	for _, path := range []string{oldPath, newPath} {
207		if _, err := os.Stat(path); err != nil {
208			t.Fatalf("dry run removed %s: %v", path, err)
209		}
210	}
211}
212
213func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
214	home := t.TempDir()
215	t.Setenv("ZOT_HOME", home)
216	firstCWD := t.TempDir()
217	secondCWD := t.TempDir()
218	first := createPruneTestSession(t, home, firstCWD)
219	second := createPruneTestSession(t, home, secondCWD)
220	oldTime := time.Now().Add(-48 * time.Hour)
221	for _, path := range []string{first, second} {
222		if err := os.Chtimes(path, oldTime, oldTime); err != nil {
223			t.Fatal(err)
224		}
225	}
226
227	age, err := parseSessionAge("24h")
228	if err != nil {
229		t.Fatal(err)
230	}
231	var out, errOut bytes.Buffer
232	opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
233	if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
234		t.Fatal(err)
235	}
236	if _, err := os.Stat(first); !os.IsNotExist(err) {
237		t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
238	}
239	if _, err := os.Stat(second); err != nil {
240		t.Fatalf("session from another cwd was removed: %v", err)
241	}
242}
243
244func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
245	home := t.TempDir()
246	t.Setenv("ZOT_HOME", home)
247	cwd := filepath.Join(home, "temporarily-missing")
248	path := createPruneTestSession(t, home, cwd)
249	calls := 0
250	stat := func(path string) (fs.FileInfo, error) {
251		calls++
252		if calls == 1 {
253			return nil, fs.ErrNotExist
254		}
255		return nil, nil
256	}
257
258	var out, errOut bytes.Buffer
259	err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat)
260	if err != nil {
261		t.Fatal(err)
262	}
263	if _, err := os.Stat(path); err != nil {
264		t.Fatalf("session was removed after cwd reappeared: %v", err)
265	}
266	if !strings.Contains(errOut.String(), "directory now exists") {
267		t.Fatalf("stderr = %q, want recheck warning", errOut.String())
268	}
269}
270
271func TestParseSessionsPruneOptions(t *testing.T) {
272	if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
273		t.Fatal("--yes without --all was accepted")
274	}
275	if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
276		t.Fatal("--cwd without --older-than was accepted")
277	}
278	opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
279	if err != nil {
280		t.Fatal(err)
281	}
282	if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
283		t.Fatalf("options = %#v", opts)
284	}
285}
286
287func createPruneTestSession(t *testing.T, root, cwd string) string {
288	t.Helper()
289	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
290	if err != nil {
291		t.Fatal(err)
292	}
293	if err := session.AppendMessage(provider.Message{
294		Role:    provider.RoleUser,
295		Content: []provider.Content{provider.TextBlock{Text: "hello"}},
296	}); err != nil {
297		t.Fatal(err)
298	}
299	if err := session.Close(); err != nil {
300		t.Fatal(err)
301	}
302	return session.Path
303}

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