main 4509ec7

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/bnema/zut/packages/core"
14	"github.com/bnema/zut/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("select/"+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		{input: "99999999999999999999d", wantErr: true},
65	}
66	for _, tt := range tests {
67		t.Run("age/"+tt.input, func(t *testing.T) {
68			age, err := parseSessionAge(tt.input)
69			if (err != nil) != tt.wantErr {
70				t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
71			}
72			if tt.wantErr {
73				return
74			}
75			got, err := age.cutoff(now)
76			if err != nil {
77				t.Fatal(err)
78			}
79			if !got.Equal(tt.want) {
80				t.Fatalf("cutoff = %v, want %v", got, tt.want)
81			}
82		})
83	}
84}
85
86func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
87	month, err := parseSessionAge("1mo")
88	if err != nil {
89		t.Fatal(err)
90	}
91	got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
92	if err != nil {
93		t.Fatal(err)
94	}
95	want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
96	if !got.Equal(want) {
97		t.Fatalf("one month cutoff = %v, want %v", got, want)
98	}
99
100	year, err := parseSessionAge("1y")
101	if err != nil {
102		t.Fatal(err)
103	}
104	got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
105	if err != nil {
106		t.Fatal(err)
107	}
108	want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
109	if !got.Equal(want) {
110		t.Fatalf("one year cutoff = %v, want %v", got, want)
111	}
112}
113
114func TestParseSessionsPruneOptions(t *testing.T) {
115	if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
116		t.Fatal("--yes without --all was accepted")
117	}
118	if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
119		t.Fatal("--cwd without --older-than was accepted")
120	}
121	if _, err := parseSessionsPruneOptions([]string{"--all", "--yes", "--dry-run"}); err == nil {
122		t.Fatal("--yes with --dry-run was accepted")
123	}
124	if _, err := parseSessionsPruneOptions([]string{"--bogus"}); err == nil {
125		t.Fatal("unknown flag was accepted")
126	}
127	if _, err := parseSessionsPruneOptions([]string{"--older-than"}); err == nil {
128		t.Fatal("missing --older-than value was accepted")
129	}
130	opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
131	if err != nil {
132		t.Fatal(err)
133	}
134	if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
135		t.Fatalf("options = %#v", opts)
136	}
137}
138
139func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
140	root := t.TempDir()
141	missing := filepath.Join(root, "missing-project")
142	existing := t.TempDir()
143	missingPath := createPruneTestSession(t, root, missing)
144	existingPath := createPruneTestSession(t, root, existing)
145
146	var out, errOut bytes.Buffer
147	if err := runSessionsPrune(root, sessionsPruneOptions{dryRun: true, all: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
148		t.Fatal(err)
149	}
150	if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
151		t.Fatalf("output = %q, want only missing cwd", out.String())
152	}
153	if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
154		t.Fatalf("output = %q, want dry-run summary", out.String())
155	}
156	for _, path := range []string{missingPath, existingPath} {
157		if _, err := os.Stat(path); err != nil {
158			t.Fatalf("dry run removed %s: %v", path, err)
159		}
160	}
161}
162
163func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
164	root := t.TempDir()
165	firstCWD := filepath.Join(root, "gone-a")
166	secondCWD := filepath.Join(root, "gone-b")
167	first := createPruneTestSession(t, root, firstCWD)
168	second := createPruneTestSession(t, root, secondCWD)
169
170	var out, errOut bytes.Buffer
171	if err := runSessionsPrune(root, sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
172		t.Fatal(err)
173	}
174	if _, err := os.Stat(first); err != nil {
175		t.Fatalf("unselected session was removed: %v", err)
176	}
177	if _, err := os.Stat(second); !os.IsNotExist(err) {
178		t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
179	}
180	if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
181		t.Fatalf("output = %q", out.String())
182	}
183}
184
185func TestSessionsPruneDeclinedConfirmationDeletesNothing(t *testing.T) {
186	root := t.TempDir()
187	path := createPruneTestSession(t, root, filepath.Join(root, "gone"))
188
189	var out, errOut bytes.Buffer
190	if err := runSessionsPrune(root, sessionsPruneOptions{all: true}, strings.NewReader("n\n"), &out, &errOut, os.Stat); err != nil {
191		t.Fatal(err)
192	}
193	if _, err := os.Stat(path); err != nil {
194		t.Fatalf("declined confirmation removed the session: %v", err)
195	}
196	if !strings.Contains(out.String(), "no sessions deleted") {
197		t.Fatalf("output = %q, want declined message", out.String())
198	}
199}
200
201func TestSessionsPruneAllYesDeletesWithoutPrompting(t *testing.T) {
202	root := t.TempDir()
203	first := createPruneTestSession(t, root, filepath.Join(root, "gone-a"))
204	second := createPruneTestSession(t, root, filepath.Join(root, "gone-b"))
205
206	var out, errOut bytes.Buffer
207	if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
208		t.Fatal(err)
209	}
210	for _, path := range []string{first, second} {
211		if _, err := os.Stat(path); !os.IsNotExist(err) {
212			t.Fatalf("session %s still exists: %v", path, err)
213		}
214	}
215	if !strings.Contains(out.String(), "deleted 2 sessions from 2 directories") {
216		t.Fatalf("output = %q", out.String())
217	}
218}
219
220func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
221	root := t.TempDir()
222	cwd := filepath.Join(root, "unavailable-mount")
223	path := createPruneTestSession(t, root, cwd)
224	stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
225
226	var out, errOut bytes.Buffer
227	if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
228		t.Fatal(err)
229	}
230	if _, err := os.Stat(path); err != nil {
231		t.Fatalf("session was removed after inconclusive stat: %v", err)
232	}
233	if !strings.Contains(errOut.String(), "permission denied") {
234		t.Fatalf("stderr = %q, want stat warning", errOut.String())
235	}
236	if !strings.Contains(out.String(), "no stale session directories found") {
237		t.Fatalf("output = %q", out.String())
238	}
239}
240
241func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
242	root := t.TempDir()
243	cwd := filepath.Join(root, "temporarily-missing")
244	path := createPruneTestSession(t, root, cwd)
245	calls := 0
246	stat := func(string) (fs.FileInfo, error) {
247		calls++
248		if calls == 1 {
249			return nil, fs.ErrNotExist
250		}
251		return nil, nil
252	}
253
254	var out, errOut bytes.Buffer
255	if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
256		t.Fatal(err)
257	}
258	if _, err := os.Stat(path); err != nil {
259		t.Fatalf("session was removed after cwd reappeared: %v", err)
260	}
261	if !strings.Contains(errOut.String(), "directory now exists") {
262		t.Fatalf("stderr = %q, want recheck warning", errOut.String())
263	}
264}
265
266func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
267	root := t.TempDir()
268	cwd := t.TempDir()
269	oldPath := createPruneTestSession(t, root, cwd)
270	newPath := createPruneTestSession(t, root, cwd)
271	oldTime := time.Now().Add(-48 * time.Hour)
272	if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
273		t.Fatal(err)
274	}
275
276	age, err := parseSessionAge("24h")
277	if err != nil {
278		t.Fatal(err)
279	}
280	var out, errOut bytes.Buffer
281	opts := sessionsPruneOptions{dryRun: true, all: true, olderThan: &age}
282	if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
283		t.Fatal(err)
284	}
285	if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
286		t.Fatalf("output = %q", out.String())
287	}
288	for _, path := range []string{oldPath, newPath} {
289		if _, err := os.Stat(path); err != nil {
290			t.Fatalf("dry run removed %s: %v", path, err)
291		}
292	}
293}
294
295func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
296	root := t.TempDir()
297	firstCWD := t.TempDir()
298	secondCWD := t.TempDir()
299	first := createPruneTestSession(t, root, firstCWD)
300	second := createPruneTestSession(t, root, secondCWD)
301	oldTime := time.Now().Add(-48 * time.Hour)
302	for _, path := range []string{first, second} {
303		if err := os.Chtimes(path, oldTime, oldTime); err != nil {
304			t.Fatal(err)
305		}
306	}
307
308	age, err := parseSessionAge("24h")
309	if err != nil {
310		t.Fatal(err)
311	}
312	var out, errOut bytes.Buffer
313	opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
314	if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
315		t.Fatal(err)
316	}
317	if _, err := os.Stat(first); !os.IsNotExist(err) {
318		t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
319	}
320	if _, err := os.Stat(second); err != nil {
321		t.Fatalf("session from another cwd was removed: %v", err)
322	}
323}
324
325func TestSessionsPruneWarnsOnScanIssues(t *testing.T) {
326	root := t.TempDir()
327	kept := createPruneTestSession(t, root, filepath.Join(root, "gone"))
328	if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil {
329		t.Fatal(err)
330	}
331
332	var out, errOut bytes.Buffer
333	if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
334		t.Fatal(err)
335	}
336	if !strings.Contains(errOut.String(), "warning: preserving") {
337		t.Fatalf("stderr = %q, want scan-issue warning", errOut.String())
338	}
339	if _, err := os.Stat(filepath.Join(root, "broken.jsonl")); err != nil {
340		t.Fatalf("malformed entry was removed: %v", err)
341	}
342	if _, err := os.Stat(kept); !os.IsNotExist(err) {
343		t.Fatalf("stale session still exists: %v", err)
344	}
345}
346
347func TestSessionsCommandDispatch(t *testing.T) {
348	if handled, err := runSessionsCommand(nil); handled || err != nil {
349		t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
350	}
351	if handled, err := runSessionsCommand([]string{"other"}); handled || err != nil {
352		t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
353	}
354	if handled, err := runSessionsCommand([]string{"sessions", "bogus"}); !handled || err == nil {
355		t.Fatalf("handled=%v err=%v, want handled error", handled, err)
356	}
357}
358
359func createPruneTestSession(t *testing.T, root, cwd string) string {
360	t.Helper()
361	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
362	if err != nil {
363		t.Fatal(err)
364	}
365	if err := session.AppendMessage(provider.Message{
366		Role:    provider.RoleUser,
367		Content: []provider.Content{provider.TextBlock{Text: "hello"}},
368	}); err != nil {
369		_ = session.Close()
370		t.Fatal(err)
371	}
372	if err := session.Close(); err != nil {
373		t.Fatal(err)
374	}
375	return session.Path
376}

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