process_restore_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5
6	snapcodec "github.com/bnema/vev/internal/snapshotcodec"
7)
8
9func TestProcessRestorePlan(t *testing.T) {
10	defaultAllow := map[string]struct{}{
11		"less":     {},
12		"pi":       {},
13		"claude":   {},
14		"opencode": {},
15		"codex":    {},
16	}
17	tests := []struct {
18		name    string
19		proc    *snapcodec.Process
20		allow   map[string]struct{}
21		restore bool
22		command string
23		reason  string
24	}{
25		{
26			name:    "generic less allowed",
27			proc:    &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
28			restore: true,
29			command: "/usr/bin/less README.md",
30		},
31		{
32			name:   "bash denied",
33			proc:   &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
34			reason: "not_allowlisted",
35		},
36		{
37			name:    "pi with ID resumes",
38			proc:    &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
39			restore: true,
40			command: "pi --resume session-123",
41		},
42		{
43			name:    "pi no ID continues",
44			proc:    &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
45			restore: true,
46			command: "pi --continue",
47		},
48		{
49			name:    "claude no ID continues",
50			proc:    &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude},
51			restore: true,
52			command: "claude --continue",
53		},
54		{
55			name:    "opencode session command",
56			proc:    &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
57			restore: true,
58			command: "opencode --session abc",
59		},
60		{
61			name:    "opencode no ID continues",
62			proc:    &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode},
63			restore: true,
64			command: "opencode --continue",
65		},
66		{
67			name:    "codex resume command",
68			proc:    &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
69			restore: true,
70			command: "codex resume abc",
71		},
72		{
73			name:    "codex no ID resumes last",
74			proc:    &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex},
75			restore: true,
76			command: "codex resume --last",
77		},
78		{
79			name:   "agent strategy matches allowlist by strategy not argv basename",
80			proc:   &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
81			allow:  map[string]struct{}{"less": {}},
82			reason: "not_allowlisted",
83		},
84		{
85			name:   "agent ID with control byte rejected",
86			proc:   &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}},
87			reason: "invalid_agent_session_id",
88		},
89		{
90			name:   "empty executable rejected",
91			proc:   &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
92			reason: "missing_process",
93		},
94	}
95
96	for _, tt := range tests {
97		t.Run(tt.name, func(t *testing.T) {
98			allow := tt.allow
99			if allow == nil {
100				allow = defaultAllow
101			}
102			got := planProcessRestore(tt.proc, allow)
103			if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
104				t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
105			}
106		})
107	}
108}
109
110func TestExtractAgentSessionID(t *testing.T) {
111	tests := []struct {
112		name     string
113		strategy string
114		argv     []string
115		want     string
116	}{
117		{name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
118		{name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"},
119		{name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
120		{name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
121		{name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
122		{name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
123		{name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}},
124	}
125
126	for _, tt := range tests {
127		t.Run(tt.name, func(t *testing.T) {
128			if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
129				t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
130			}
131		})
132	}
133}
134
135func TestShellQuoteArgv(t *testing.T) {
136	tests := []struct {
137		name string
138		argv []string
139		want string
140		ok   bool
141	}{
142		{name: "empty argv", ok: false},
143		{name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true},
144		{name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true},
145		{name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true},
146	}
147
148	for _, tt := range tests {
149		t.Run(tt.name, func(t *testing.T) {
150			got, ok := shellQuoteArgv(tt.argv)
151			if ok != tt.ok || got != tt.want {
152				t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
153			}
154		})
155	}
156}

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 12

Tags 11

↑↓ moveenter openesc close