detached dc9b97d

feat(chroot): add persistent session infrastructure

Add ChrootSession interface and implementation to support multi-step
operations that require state persistence across chroot commands.

Key features:
- Session maintains /tmp and other filesystem state across commands
- Marker-based exit code detection (ARCHUP_CMD_DONE_$?)
- Context support for timeouts and cancellation
- Concurrent stdout/stderr handling to prevent deadlocks
- Methods: Exec, ExecWithOutput, ExecWithContext, Close

This infrastructure enables cleaner multi-step chroot operations
without cramming everything into giant bash commands.

Files modified:
- internal/interfaces/interfaces.go: Add ChrootSession interface
- internal/system/chroot.go: Implement DefaultChrootSession
- internal/interfaces/mocks/mock_interfaces.go: Regenerated mocks
commit
dc9b97dcf21669a94bc0338a290cad27ba622b67
author
bnema · · 2025-10-31 05:59 UTC
parent
017ee10
signature
GPG Unverified2B82207D370127A0 The key is unknown
3 files · +468 -0
M internal/interfaces/interfaces.go +39 -0
@@ -34,15 +34,42 @@ type SystemExecutor interface {
3434 DetectCPUInfo() (*system.CPUInfo, error)
3535 }
3636
37+// ChrootSession represents a persistent chroot session that can execute
38+// multiple commands while maintaining state (e.g., /tmp directory contents).
39+// Sessions must be explicitly closed when done to clean up resources.
40+type ChrootSession interface {
41+ // Exec executes a command in the chroot session
42+ Exec(command string) error
43+
44+ // ExecWithOutput executes a command and returns its output
45+ ExecWithOutput(command string) (string, error)
46+
47+ // ExecWithContext executes a command with context support for cancellation
48+ ExecWithContext(ctx context.Context, command string) error
49+
50+ // ExecWithOutputAndContext executes a command with context and returns output
51+ ExecWithOutputAndContext(ctx context.Context, command string) (string, error)
52+
53+ // Close terminates the session and cleans up resources
54+ Close() error
55+}
56+
3757 // ChrootExecutor abstracts chroot operations
3858 type ChrootExecutor interface {
3959 ChrootExec(logPath, mountPoint, command string, args ...string) error
4060 ChrootExecWithOutput(logPath, mountPoint, command string, args ...string) (string, error)
61+ ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error)
4162 ChrootSystemctl(logPath, mountPoint, action, service string) error
4263 ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error
4364 ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error
4465 ChrootPacman(logPath, mountPoint, operation string, packages ...string) error
4566 DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error
67+
68+ // BeginSession starts a persistent chroot session
69+ BeginSession(logPath, mountPoint string) (ChrootSession, error)
70+
71+ // BeginSessionWithContext starts a persistent chroot session with context support
72+ BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (ChrootSession, error)
4673 }
4774
4875 // HTTPClient abstracts HTTP operations
@@ -130,6 +157,10 @@ func (d *DefaultChrootExecutor) ChrootExecWithOutput(logPath, mountPoint, comman
130157 return system.ChrootExecWithOutput(logPath, mountPoint, command, args...)
131158 }
132159
160+func (d *DefaultChrootExecutor) ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
161+ return system.ChrootExecWithOutputAndContext(ctx, logPath, mountPoint, command, args...)
162+}
163+
133164 func (d *DefaultChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
134165 return system.ChrootSystemctl(logPath, mountPoint, action, service)
135166 }
@@ -149,3 +180,11 @@ func (d *DefaultChrootExecutor) ChrootPacman(logPath, mountPoint, operation stri
149180 func (d *DefaultChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
150181 return system.DownloadAndInstallPackages(logPath, chrootPath, urls...)
151182 }
183+
184+func (d *DefaultChrootExecutor) BeginSession(logPath, mountPoint string) (ChrootSession, error) {
185+ return system.BeginSession(logPath, mountPoint)
186+}
187+
188+func (d *DefaultChrootExecutor) BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (ChrootSession, error) {
189+ return system.BeginSessionWithContext(ctx, logPath, mountPoint)
190+}
@@ -34,15 +34,42 @@ type SystemExecutor interface {
34 DetectCPUInfo() (*system.CPUInfo, error)34 DetectCPUInfo() (*system.CPUInfo, error)
35 }35 }
36 36
37+// ChrootSession represents a persistent chroot session that can execute
38+// multiple commands while maintaining state (e.g., /tmp directory contents).
39+// Sessions must be explicitly closed when done to clean up resources.
40+type ChrootSession interface {
41+ // Exec executes a command in the chroot session
42+ Exec(command string) error
43+
44+ // ExecWithOutput executes a command and returns its output
45+ ExecWithOutput(command string) (string, error)
46+
47+ // ExecWithContext executes a command with context support for cancellation
48+ ExecWithContext(ctx context.Context, command string) error
49+
50+ // ExecWithOutputAndContext executes a command with context and returns output
51+ ExecWithOutputAndContext(ctx context.Context, command string) (string, error)
52+
53+ // Close terminates the session and cleans up resources
54+ Close() error
55+}
56+
37 // ChrootExecutor abstracts chroot operations57 // ChrootExecutor abstracts chroot operations
38 type ChrootExecutor interface {58 type ChrootExecutor interface {
39 ChrootExec(logPath, mountPoint, command string, args ...string) error59 ChrootExec(logPath, mountPoint, command string, args ...string) error
40 ChrootExecWithOutput(logPath, mountPoint, command string, args ...string) (string, error)60 ChrootExecWithOutput(logPath, mountPoint, command string, args ...string) (string, error)
61+ ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error)
41 ChrootSystemctl(logPath, mountPoint, action, service string) error62 ChrootSystemctl(logPath, mountPoint, action, service string) error
42 ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error63 ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error
43 ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error64 ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error
44 ChrootPacman(logPath, mountPoint, operation string, packages ...string) error65 ChrootPacman(logPath, mountPoint, operation string, packages ...string) error
45 DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error66 DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error
67+
68+ // BeginSession starts a persistent chroot session
69+ BeginSession(logPath, mountPoint string) (ChrootSession, error)
70+
71+ // BeginSessionWithContext starts a persistent chroot session with context support
72+ BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (ChrootSession, error)
46 }73 }
47 74
48 // HTTPClient abstracts HTTP operations75 // HTTPClient abstracts HTTP operations
@@ -130,6 +157,10 @@ func (d *DefaultChrootExecutor) ChrootExecWithOutput(logPath, mountPoint, comman
130 return system.ChrootExecWithOutput(logPath, mountPoint, command, args...)157 return system.ChrootExecWithOutput(logPath, mountPoint, command, args...)
131 }158 }
132 159
160+func (d *DefaultChrootExecutor) ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
161+ return system.ChrootExecWithOutputAndContext(ctx, logPath, mountPoint, command, args...)
162+}
163+
133 func (d *DefaultChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {164 func (d *DefaultChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
134 return system.ChrootSystemctl(logPath, mountPoint, action, service)165 return system.ChrootSystemctl(logPath, mountPoint, action, service)
135 }166 }
@@ -149,3 +180,11 @@ func (d *DefaultChrootExecutor) ChrootPacman(logPath, mountPoint, operation stri
149 func (d *DefaultChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {180 func (d *DefaultChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
150 return system.DownloadAndInstallPackages(logPath, chrootPath, urls...)181 return system.DownloadAndInstallPackages(logPath, chrootPath, urls...)
151 }182 }
183+
184+func (d *DefaultChrootExecutor) BeginSession(logPath, mountPoint string) (ChrootSession, error) {
185+ return system.BeginSession(logPath, mountPoint)
186+}
187+
188+func (d *DefaultChrootExecutor) BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (ChrootSession, error) {
189+ return system.BeginSessionWithContext(ctx, logPath, mountPoint)
190+}
M internal/interfaces/mocks/mock_interfaces.go +147 -0
@@ -16,6 +16,7 @@ import (
1616 os "os"
1717 reflect "reflect"
1818
19+ interfaces "github.com/bnema/archup/internal/interfaces"
1920 system "github.com/bnema/archup/internal/system"
2021 gomock "go.uber.org/mock/gomock"
2122 )
@@ -276,6 +277,102 @@ func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *g
276277 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)
277278 }
278279
280+// MockChrootSession is a mock of ChrootSession interface.
281+type MockChrootSession struct {
282+ ctrl *gomock.Controller
283+ recorder *MockChrootSessionMockRecorder
284+ isgomock struct{}
285+}
286+
287+// MockChrootSessionMockRecorder is the mock recorder for MockChrootSession.
288+type MockChrootSessionMockRecorder struct {
289+ mock *MockChrootSession
290+}
291+
292+// NewMockChrootSession creates a new mock instance.
293+func NewMockChrootSession(ctrl *gomock.Controller) *MockChrootSession {
294+ mock := &MockChrootSession{ctrl: ctrl}
295+ mock.recorder = &MockChrootSessionMockRecorder{mock}
296+ return mock
297+}
298+
299+// EXPECT returns an object that allows the caller to indicate expected use.
300+func (m *MockChrootSession) EXPECT() *MockChrootSessionMockRecorder {
301+ return m.recorder
302+}
303+
304+// Close mocks base method.
305+func (m *MockChrootSession) Close() error {
306+ m.ctrl.T.Helper()
307+ ret := m.ctrl.Call(m, "Close")
308+ ret0, _ := ret[0].(error)
309+ return ret0
310+}
311+
312+// Close indicates an expected call of Close.
313+func (mr *MockChrootSessionMockRecorder) Close() *gomock.Call {
314+ mr.mock.ctrl.T.Helper()
315+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockChrootSession)(nil).Close))
316+}
317+
318+// Exec mocks base method.
319+func (m *MockChrootSession) Exec(command string) error {
320+ m.ctrl.T.Helper()
321+ ret := m.ctrl.Call(m, "Exec", command)
322+ ret0, _ := ret[0].(error)
323+ return ret0
324+}
325+
326+// Exec indicates an expected call of Exec.
327+func (mr *MockChrootSessionMockRecorder) Exec(command any) *gomock.Call {
328+ mr.mock.ctrl.T.Helper()
329+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockChrootSession)(nil).Exec), command)
330+}
331+
332+// ExecWithContext mocks base method.
333+func (m *MockChrootSession) ExecWithContext(ctx context.Context, command string) error {
334+ m.ctrl.T.Helper()
335+ ret := m.ctrl.Call(m, "ExecWithContext", ctx, command)
336+ ret0, _ := ret[0].(error)
337+ return ret0
338+}
339+
340+// ExecWithContext indicates an expected call of ExecWithContext.
341+func (mr *MockChrootSessionMockRecorder) ExecWithContext(ctx, command any) *gomock.Call {
342+ mr.mock.ctrl.T.Helper()
343+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithContext", reflect.TypeOf((*MockChrootSession)(nil).ExecWithContext), ctx, command)
344+}
345+
346+// ExecWithOutput mocks base method.
347+func (m *MockChrootSession) ExecWithOutput(command string) (string, error) {
348+ m.ctrl.T.Helper()
349+ ret := m.ctrl.Call(m, "ExecWithOutput", command)
350+ ret0, _ := ret[0].(string)
351+ ret1, _ := ret[1].(error)
352+ return ret0, ret1
353+}
354+
355+// ExecWithOutput indicates an expected call of ExecWithOutput.
356+func (mr *MockChrootSessionMockRecorder) ExecWithOutput(command any) *gomock.Call {
357+ mr.mock.ctrl.T.Helper()
358+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithOutput", reflect.TypeOf((*MockChrootSession)(nil).ExecWithOutput), command)
359+}
360+
361+// ExecWithOutputAndContext mocks base method.
362+func (m *MockChrootSession) ExecWithOutputAndContext(ctx context.Context, command string) (string, error) {
363+ m.ctrl.T.Helper()
364+ ret := m.ctrl.Call(m, "ExecWithOutputAndContext", ctx, command)
365+ ret0, _ := ret[0].(string)
366+ ret1, _ := ret[1].(error)
367+ return ret0, ret1
368+}
369+
370+// ExecWithOutputAndContext indicates an expected call of ExecWithOutputAndContext.
371+func (mr *MockChrootSessionMockRecorder) ExecWithOutputAndContext(ctx, command any) *gomock.Call {
372+ mr.mock.ctrl.T.Helper()
373+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithOutputAndContext", reflect.TypeOf((*MockChrootSession)(nil).ExecWithOutputAndContext), ctx, command)
374+}
375+
279376 // MockChrootExecutor is a mock of ChrootExecutor interface.
280377 type MockChrootExecutor struct {
281378 ctrl *gomock.Controller
@@ -300,6 +397,36 @@ func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
300397 return m.recorder
301398 }
302399
400+// BeginSession mocks base method.
401+func (m *MockChrootExecutor) BeginSession(logPath, mountPoint string) (interfaces.ChrootSession, error) {
402+ m.ctrl.T.Helper()
403+ ret := m.ctrl.Call(m, "BeginSession", logPath, mountPoint)
404+ ret0, _ := ret[0].(interfaces.ChrootSession)
405+ ret1, _ := ret[1].(error)
406+ return ret0, ret1
407+}
408+
409+// BeginSession indicates an expected call of BeginSession.
410+func (mr *MockChrootExecutorMockRecorder) BeginSession(logPath, mountPoint any) *gomock.Call {
411+ mr.mock.ctrl.T.Helper()
412+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginSession", reflect.TypeOf((*MockChrootExecutor)(nil).BeginSession), logPath, mountPoint)
413+}
414+
415+// BeginSessionWithContext mocks base method.
416+func (m *MockChrootExecutor) BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (interfaces.ChrootSession, error) {
417+ m.ctrl.T.Helper()
418+ ret := m.ctrl.Call(m, "BeginSessionWithContext", ctx, logPath, mountPoint)
419+ ret0, _ := ret[0].(interfaces.ChrootSession)
420+ ret1, _ := ret[1].(error)
421+ return ret0, ret1
422+}
423+
424+// BeginSessionWithContext indicates an expected call of BeginSessionWithContext.
425+func (mr *MockChrootExecutorMockRecorder) BeginSessionWithContext(ctx, logPath, mountPoint any) *gomock.Call {
426+ mr.mock.ctrl.T.Helper()
427+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginSessionWithContext", reflect.TypeOf((*MockChrootExecutor)(nil).BeginSessionWithContext), ctx, logPath, mountPoint)
428+}
429+
303430 // ChrootExec mocks base method.
304431 func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
305432 m.ctrl.T.Helper()
@@ -353,6 +480,26 @@ func (mr *MockChrootExecutorMockRecorder) ChrootExecWithOutput(logPath, mountPoi
353480 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithOutput", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithOutput), varargs...)
354481 }
355482
483+// ChrootExecWithOutputAndContext mocks base method.
484+func (m *MockChrootExecutor) ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
485+ m.ctrl.T.Helper()
486+ varargs := []any{ctx, logPath, mountPoint, command}
487+ for _, a := range args {
488+ varargs = append(varargs, a)
489+ }
490+ ret := m.ctrl.Call(m, "ChrootExecWithOutputAndContext", varargs...)
491+ ret0, _ := ret[0].(string)
492+ ret1, _ := ret[1].(error)
493+ return ret0, ret1
494+}
495+
496+// ChrootExecWithOutputAndContext indicates an expected call of ChrootExecWithOutputAndContext.
497+func (mr *MockChrootExecutorMockRecorder) ChrootExecWithOutputAndContext(ctx, logPath, mountPoint, command any, args ...any) *gomock.Call {
498+ mr.mock.ctrl.T.Helper()
499+ varargs := append([]any{ctx, logPath, mountPoint, command}, args...)
500+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithOutputAndContext", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithOutputAndContext), varargs...)
501+}
502+
356503 // ChrootExecWithStdin mocks base method.
357504 func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
358505 m.ctrl.T.Helper()
@@ -16,6 +16,7 @@ import (
16 os "os"16 os "os"
17 reflect "reflect"17 reflect "reflect"
18 18
19+ interfaces "github.com/bnema/archup/internal/interfaces"
19 system "github.com/bnema/archup/internal/system"20 system "github.com/bnema/archup/internal/system"
20 gomock "go.uber.org/mock/gomock"21 gomock "go.uber.org/mock/gomock"
21 )22 )
@@ -276,6 +277,102 @@ func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *g
276 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)277 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)
277 }278 }
278 279
280+// MockChrootSession is a mock of ChrootSession interface.
281+type MockChrootSession struct {
282+ ctrl *gomock.Controller
283+ recorder *MockChrootSessionMockRecorder
284+ isgomock struct{}
285+}
286+
287+// MockChrootSessionMockRecorder is the mock recorder for MockChrootSession.
288+type MockChrootSessionMockRecorder struct {
289+ mock *MockChrootSession
290+}
291+
292+// NewMockChrootSession creates a new mock instance.
293+func NewMockChrootSession(ctrl *gomock.Controller) *MockChrootSession {
294+ mock := &MockChrootSession{ctrl: ctrl}
295+ mock.recorder = &MockChrootSessionMockRecorder{mock}
296+ return mock
297+}
298+
299+// EXPECT returns an object that allows the caller to indicate expected use.
300+func (m *MockChrootSession) EXPECT() *MockChrootSessionMockRecorder {
301+ return m.recorder
302+}
303+
304+// Close mocks base method.
305+func (m *MockChrootSession) Close() error {
306+ m.ctrl.T.Helper()
307+ ret := m.ctrl.Call(m, "Close")
308+ ret0, _ := ret[0].(error)
309+ return ret0
310+}
311+
312+// Close indicates an expected call of Close.
313+func (mr *MockChrootSessionMockRecorder) Close() *gomock.Call {
314+ mr.mock.ctrl.T.Helper()
315+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockChrootSession)(nil).Close))
316+}
317+
318+// Exec mocks base method.
319+func (m *MockChrootSession) Exec(command string) error {
320+ m.ctrl.T.Helper()
321+ ret := m.ctrl.Call(m, "Exec", command)
322+ ret0, _ := ret[0].(error)
323+ return ret0
324+}
325+
326+// Exec indicates an expected call of Exec.
327+func (mr *MockChrootSessionMockRecorder) Exec(command any) *gomock.Call {
328+ mr.mock.ctrl.T.Helper()
329+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockChrootSession)(nil).Exec), command)
330+}
331+
332+// ExecWithContext mocks base method.
333+func (m *MockChrootSession) ExecWithContext(ctx context.Context, command string) error {
334+ m.ctrl.T.Helper()
335+ ret := m.ctrl.Call(m, "ExecWithContext", ctx, command)
336+ ret0, _ := ret[0].(error)
337+ return ret0
338+}
339+
340+// ExecWithContext indicates an expected call of ExecWithContext.
341+func (mr *MockChrootSessionMockRecorder) ExecWithContext(ctx, command any) *gomock.Call {
342+ mr.mock.ctrl.T.Helper()
343+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithContext", reflect.TypeOf((*MockChrootSession)(nil).ExecWithContext), ctx, command)
344+}
345+
346+// ExecWithOutput mocks base method.
347+func (m *MockChrootSession) ExecWithOutput(command string) (string, error) {
348+ m.ctrl.T.Helper()
349+ ret := m.ctrl.Call(m, "ExecWithOutput", command)
350+ ret0, _ := ret[0].(string)
351+ ret1, _ := ret[1].(error)
352+ return ret0, ret1
353+}
354+
355+// ExecWithOutput indicates an expected call of ExecWithOutput.
356+func (mr *MockChrootSessionMockRecorder) ExecWithOutput(command any) *gomock.Call {
357+ mr.mock.ctrl.T.Helper()
358+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithOutput", reflect.TypeOf((*MockChrootSession)(nil).ExecWithOutput), command)
359+}
360+
361+// ExecWithOutputAndContext mocks base method.
362+func (m *MockChrootSession) ExecWithOutputAndContext(ctx context.Context, command string) (string, error) {
363+ m.ctrl.T.Helper()
364+ ret := m.ctrl.Call(m, "ExecWithOutputAndContext", ctx, command)
365+ ret0, _ := ret[0].(string)
366+ ret1, _ := ret[1].(error)
367+ return ret0, ret1
368+}
369+
370+// ExecWithOutputAndContext indicates an expected call of ExecWithOutputAndContext.
371+func (mr *MockChrootSessionMockRecorder) ExecWithOutputAndContext(ctx, command any) *gomock.Call {
372+ mr.mock.ctrl.T.Helper()
373+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecWithOutputAndContext", reflect.TypeOf((*MockChrootSession)(nil).ExecWithOutputAndContext), ctx, command)
374+}
375+
279 // MockChrootExecutor is a mock of ChrootExecutor interface.376 // MockChrootExecutor is a mock of ChrootExecutor interface.
280 type MockChrootExecutor struct {377 type MockChrootExecutor struct {
281 ctrl *gomock.Controller378 ctrl *gomock.Controller
@@ -300,6 +397,36 @@ func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
300 return m.recorder397 return m.recorder
301 }398 }
302 399
400+// BeginSession mocks base method.
401+func (m *MockChrootExecutor) BeginSession(logPath, mountPoint string) (interfaces.ChrootSession, error) {
402+ m.ctrl.T.Helper()
403+ ret := m.ctrl.Call(m, "BeginSession", logPath, mountPoint)
404+ ret0, _ := ret[0].(interfaces.ChrootSession)
405+ ret1, _ := ret[1].(error)
406+ return ret0, ret1
407+}
408+
409+// BeginSession indicates an expected call of BeginSession.
410+func (mr *MockChrootExecutorMockRecorder) BeginSession(logPath, mountPoint any) *gomock.Call {
411+ mr.mock.ctrl.T.Helper()
412+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginSession", reflect.TypeOf((*MockChrootExecutor)(nil).BeginSession), logPath, mountPoint)
413+}
414+
415+// BeginSessionWithContext mocks base method.
416+func (m *MockChrootExecutor) BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (interfaces.ChrootSession, error) {
417+ m.ctrl.T.Helper()
418+ ret := m.ctrl.Call(m, "BeginSessionWithContext", ctx, logPath, mountPoint)
419+ ret0, _ := ret[0].(interfaces.ChrootSession)
420+ ret1, _ := ret[1].(error)
421+ return ret0, ret1
422+}
423+
424+// BeginSessionWithContext indicates an expected call of BeginSessionWithContext.
425+func (mr *MockChrootExecutorMockRecorder) BeginSessionWithContext(ctx, logPath, mountPoint any) *gomock.Call {
426+ mr.mock.ctrl.T.Helper()
427+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginSessionWithContext", reflect.TypeOf((*MockChrootExecutor)(nil).BeginSessionWithContext), ctx, logPath, mountPoint)
428+}
429+
303 // ChrootExec mocks base method.430 // ChrootExec mocks base method.
304 func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {431 func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
305 m.ctrl.T.Helper()432 m.ctrl.T.Helper()
@@ -353,6 +480,26 @@ func (mr *MockChrootExecutorMockRecorder) ChrootExecWithOutput(logPath, mountPoi
353 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithOutput", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithOutput), varargs...)480 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithOutput", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithOutput), varargs...)
354 }481 }
355 482
483+// ChrootExecWithOutputAndContext mocks base method.
484+func (m *MockChrootExecutor) ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
485+ m.ctrl.T.Helper()
486+ varargs := []any{ctx, logPath, mountPoint, command}
487+ for _, a := range args {
488+ varargs = append(varargs, a)
489+ }
490+ ret := m.ctrl.Call(m, "ChrootExecWithOutputAndContext", varargs...)
491+ ret0, _ := ret[0].(string)
492+ ret1, _ := ret[1].(error)
493+ return ret0, ret1
494+}
495+
496+// ChrootExecWithOutputAndContext indicates an expected call of ChrootExecWithOutputAndContext.
497+func (mr *MockChrootExecutorMockRecorder) ChrootExecWithOutputAndContext(ctx, logPath, mountPoint, command any, args ...any) *gomock.Call {
498+ mr.mock.ctrl.T.Helper()
499+ varargs := append([]any{ctx, logPath, mountPoint, command}, args...)
500+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithOutputAndContext", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithOutputAndContext), varargs...)
501+}
502+
356 // ChrootExecWithStdin mocks base method.503 // ChrootExecWithStdin mocks base method.
357 func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {504 func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
358 m.ctrl.T.Helper()505 m.ctrl.T.Helper()
M internal/system/chroot.go +282 -0
@@ -6,6 +6,7 @@ import (
66 "os"
77 "os/exec"
88 "strings"
9+ "time"
910 )
1011
1112 // ChrootExec executes a command inside a chroot environment
@@ -38,6 +39,39 @@ func ChrootExecWithOutput(logPath, mountPoint, command string, args ...string) (
3839 return result.Output, nil
3940 }
4041
42+// ChrootExecWithOutputAndContext executes a command inside chroot with context and captures output
43+func ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
44+ // Construct the command to run in chroot
45+ fullCommand := command
46+ if len(args) > 0 {
47+ fullCommand = command + " " + strings.Join(args, " ")
48+ }
49+
50+ cmd := exec.CommandContext(ctx, "arch-chroot", mountPoint, "bash", "-c", fullCommand)
51+
52+ // Capture output
53+ output, err := cmd.CombinedOutput()
54+
55+ // Also log to file
56+ if logPath != "" {
57+ logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
58+ if logErr == nil {
59+ defer logFile.Close()
60+ logFile.Write(output)
61+ }
62+ }
63+
64+ if err != nil {
65+ // Check if it was a context deadline exceeded
66+ if ctx.Err() == context.DeadlineExceeded {
67+ return string(output), fmt.Errorf("command timed out: %w", err)
68+ }
69+ return string(output), fmt.Errorf("failed to execute in chroot: %w", err)
70+ }
71+
72+ return string(output), nil
73+}
74+
4175 // ChrootExecWithContext executes a command inside chroot with timeout support via context
4276 // The context deadline will terminate the command if it takes too long
4377 func ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error {
@@ -162,3 +196,251 @@ func Genfstab(logPath, mountPoint, fstabPath string) error {
162196 // This should be done using file operations
163197 return nil
164198 }
199+
200+// DefaultChrootSession represents a persistent chroot session
201+type DefaultChrootSession struct {
202+ cmd *exec.Cmd
203+ stdin *os.File
204+ stdoutPipe *os.File
205+ stderrPipe *os.File
206+ logFile *os.File
207+ logPath string
208+ mountPoint string
209+ ctx context.Context
210+ cancel context.CancelFunc
211+ outputChan chan string
212+ errorChan chan error
213+ closed bool
214+}
215+
216+// BeginSession starts a persistent chroot session
217+func BeginSession(logPath, mountPoint string) (*DefaultChrootSession, error) {
218+ return BeginSessionWithContext(context.Background(), logPath, mountPoint)
219+}
220+
221+// BeginSessionWithContext starts a persistent chroot session with context support
222+func BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (*DefaultChrootSession, error) {
223+ // Create a cancellable context
224+ sessionCtx, cancel := context.WithCancel(ctx)
225+
226+ // Start arch-chroot with bash
227+ cmd := exec.CommandContext(sessionCtx, "arch-chroot", mountPoint, "bash", "--norc", "--noprofile")
228+
229+ // Create pipes for stdin, stdout, stderr
230+ stdinPipe, err := cmd.StdinPipe()
231+ if err != nil {
232+ cancel()
233+ return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
234+ }
235+
236+ stdoutPipe, err := cmd.StdoutPipe()
237+ if err != nil {
238+ cancel()
239+ return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
240+ }
241+
242+ stderrPipe, err := cmd.StderrPipe()
243+ if err != nil {
244+ cancel()
245+ return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
246+ }
247+
248+ // Open log file
249+ var logFile *os.File
250+ if logPath != "" {
251+ logFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
252+ if err != nil {
253+ cancel()
254+ return nil, fmt.Errorf("failed to open log file: %w", err)
255+ }
256+ }
257+
258+ // Start the command
259+ if err := cmd.Start(); err != nil {
260+ if logFile != nil {
261+ logFile.Close()
262+ }
263+ cancel()
264+ return nil, fmt.Errorf("failed to start chroot session: %w", err)
265+ }
266+
267+ session := &DefaultChrootSession{
268+ cmd: cmd,
269+ stdin: stdinPipe.(*os.File),
270+ stdoutPipe: stdoutPipe.(*os.File),
271+ stderrPipe: stderrPipe.(*os.File),
272+ logFile: logFile,
273+ logPath: logPath,
274+ mountPoint: mountPoint,
275+ ctx: sessionCtx,
276+ cancel: cancel,
277+ outputChan: make(chan string, 100),
278+ errorChan: make(chan error, 1),
279+ closed: false,
280+ }
281+
282+ // Start goroutines to handle output
283+ go session.handleOutput()
284+ go session.handleError()
285+
286+ return session, nil
287+}
288+
289+// handleOutput continuously reads from stdout and logs it
290+func (s *DefaultChrootSession) handleOutput() {
291+ buf := make([]byte, 4096)
292+ for {
293+ n, err := s.stdoutPipe.Read(buf)
294+ if n > 0 {
295+ data := string(buf[:n])
296+ if s.logFile != nil {
297+ s.logFile.WriteString(data)
298+ s.logFile.Sync()
299+ }
300+ s.outputChan <- data
301+ }
302+ if err != nil {
303+ close(s.outputChan)
304+ return
305+ }
306+ }
307+}
308+
309+// handleError continuously reads from stderr and logs it
310+func (s *DefaultChrootSession) handleError() {
311+ buf := make([]byte, 4096)
312+ for {
313+ n, err := s.stderrPipe.Read(buf)
314+ if n > 0 {
315+ data := string(buf[:n])
316+ if s.logFile != nil {
317+ s.logFile.WriteString(data)
318+ s.logFile.Sync()
319+ }
320+ }
321+ if err != nil {
322+ return
323+ }
324+ }
325+}
326+
327+// Exec executes a command in the chroot session
328+func (s *DefaultChrootSession) Exec(command string) error {
329+ return s.ExecWithContext(s.ctx, command)
330+}
331+
332+// ExecWithOutput executes a command and returns its output
333+func (s *DefaultChrootSession) ExecWithOutput(command string) (string, error) {
334+ return s.ExecWithOutputAndContext(s.ctx, command)
335+}
336+
337+// ExecWithContext executes a command with context support
338+func (s *DefaultChrootSession) ExecWithContext(ctx context.Context, command string) error {
339+ _, err := s.ExecWithOutputAndContext(ctx, command)
340+ return err
341+}
342+
343+// ExecWithOutputAndContext executes a command with context and returns output
344+func (s *DefaultChrootSession) ExecWithOutputAndContext(ctx context.Context, command string) (string, error) {
345+ if s.closed {
346+ return "", fmt.Errorf("session is closed")
347+ }
348+
349+ // Clear the output channel
350+ for len(s.outputChan) > 0 {
351+ <-s.outputChan
352+ }
353+
354+ // Write the command followed by a marker
355+ marker := fmt.Sprintf("ARCHUP_CMD_DONE_%d", os.Getpid())
356+ fullCommand := fmt.Sprintf("%s\necho \"%s_$?\"\n", command, marker)
357+
358+ if _, err := s.stdin.WriteString(fullCommand); err != nil {
359+ return "", fmt.Errorf("failed to write command: %w", err)
360+ }
361+
362+ // Collect output until we see the marker
363+ var output strings.Builder
364+ var exitCode string
365+
366+ for {
367+ select {
368+ case <-ctx.Done():
369+ return output.String(), fmt.Errorf("command context cancelled: %w", ctx.Err())
370+ case data, ok := <-s.outputChan:
371+ if !ok {
372+ return output.String(), fmt.Errorf("session stdout closed unexpectedly")
373+ }
374+
375+ // Check if output contains the marker
376+ if strings.Contains(data, marker) {
377+ // Extract exit code from marker line
378+ lines := strings.Split(data, "\n")
379+ for _, line := range lines {
380+ if strings.HasPrefix(line, marker+"_") {
381+ exitCode = strings.TrimPrefix(line, marker+"_")
382+ exitCode = strings.TrimSpace(exitCode)
383+
384+ // Add any output before the marker
385+ beforeMarker := strings.Split(data, marker)[0]
386+ output.WriteString(beforeMarker)
387+
388+ // Check exit code
389+ if exitCode != "0" {
390+ return output.String(), fmt.Errorf("command exited with code %s", exitCode)
391+ }
392+ return output.String(), nil
393+ }
394+ }
395+ } else {
396+ output.WriteString(data)
397+ }
398+ }
399+ }
400+}
401+
402+// Close terminates the session and cleans up resources
403+func (s *DefaultChrootSession) Close() error {
404+ if s.closed {
405+ return nil
406+ }
407+ s.closed = true
408+
409+ // Send exit command
410+ if s.stdin != nil {
411+ s.stdin.WriteString("exit\n")
412+ s.stdin.Close()
413+ }
414+
415+ // Wait for the command to finish with timeout
416+ done := make(chan error, 1)
417+ go func() {
418+ done <- s.cmd.Wait()
419+ }()
420+
421+ select {
422+ case <-time.After(5 * time.Second):
423+ // Force kill if it doesn't exit gracefully
424+ if s.cmd.Process != nil {
425+ s.cmd.Process.Kill()
426+ }
427+ case <-done:
428+ // Command exited normally
429+ }
430+
431+ // Close pipes and log file
432+ if s.stdoutPipe != nil {
433+ s.stdoutPipe.Close()
434+ }
435+ if s.stderrPipe != nil {
436+ s.stderrPipe.Close()
437+ }
438+ if s.logFile != nil {
439+ s.logFile.Close()
440+ }
441+
442+ // Cancel context
443+ s.cancel()
444+
445+ return nil
446+}
@@ -6,6 +6,7 @@ import (
6 "os"6 "os"
7 "os/exec"7 "os/exec"
8 "strings"8 "strings"
9+ "time"
9 )10 )
10 11
11 // ChrootExec executes a command inside a chroot environment12 // ChrootExec executes a command inside a chroot environment
@@ -38,6 +39,39 @@ func ChrootExecWithOutput(logPath, mountPoint, command string, args ...string) (
38 return result.Output, nil39 return result.Output, nil
39 }40 }
40 41
42+// ChrootExecWithOutputAndContext executes a command inside chroot with context and captures output
43+func ChrootExecWithOutputAndContext(ctx context.Context, logPath, mountPoint, command string, args ...string) (string, error) {
44+ // Construct the command to run in chroot
45+ fullCommand := command
46+ if len(args) > 0 {
47+ fullCommand = command + " " + strings.Join(args, " ")
48+ }
49+
50+ cmd := exec.CommandContext(ctx, "arch-chroot", mountPoint, "bash", "-c", fullCommand)
51+
52+ // Capture output
53+ output, err := cmd.CombinedOutput()
54+
55+ // Also log to file
56+ if logPath != "" {
57+ logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
58+ if logErr == nil {
59+ defer logFile.Close()
60+ logFile.Write(output)
61+ }
62+ }
63+
64+ if err != nil {
65+ // Check if it was a context deadline exceeded
66+ if ctx.Err() == context.DeadlineExceeded {
67+ return string(output), fmt.Errorf("command timed out: %w", err)
68+ }
69+ return string(output), fmt.Errorf("failed to execute in chroot: %w", err)
70+ }
71+
72+ return string(output), nil
73+}
74+
41 // ChrootExecWithContext executes a command inside chroot with timeout support via context75 // ChrootExecWithContext executes a command inside chroot with timeout support via context
42 // The context deadline will terminate the command if it takes too long76 // The context deadline will terminate the command if it takes too long
43 func ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error {77 func ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error {
@@ -162,3 +196,251 @@ func Genfstab(logPath, mountPoint, fstabPath string) error {
162 // This should be done using file operations196 // This should be done using file operations
163 return nil197 return nil
164 }198 }
199+
200+// DefaultChrootSession represents a persistent chroot session
201+type DefaultChrootSession struct {
202+ cmd *exec.Cmd
203+ stdin *os.File
204+ stdoutPipe *os.File
205+ stderrPipe *os.File
206+ logFile *os.File
207+ logPath string
208+ mountPoint string
209+ ctx context.Context
210+ cancel context.CancelFunc
211+ outputChan chan string
212+ errorChan chan error
213+ closed bool
214+}
215+
216+// BeginSession starts a persistent chroot session
217+func BeginSession(logPath, mountPoint string) (*DefaultChrootSession, error) {
218+ return BeginSessionWithContext(context.Background(), logPath, mountPoint)
219+}
220+
221+// BeginSessionWithContext starts a persistent chroot session with context support
222+func BeginSessionWithContext(ctx context.Context, logPath, mountPoint string) (*DefaultChrootSession, error) {
223+ // Create a cancellable context
224+ sessionCtx, cancel := context.WithCancel(ctx)
225+
226+ // Start arch-chroot with bash
227+ cmd := exec.CommandContext(sessionCtx, "arch-chroot", mountPoint, "bash", "--norc", "--noprofile")
228+
229+ // Create pipes for stdin, stdout, stderr
230+ stdinPipe, err := cmd.StdinPipe()
231+ if err != nil {
232+ cancel()
233+ return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
234+ }
235+
236+ stdoutPipe, err := cmd.StdoutPipe()
237+ if err != nil {
238+ cancel()
239+ return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
240+ }
241+
242+ stderrPipe, err := cmd.StderrPipe()
243+ if err != nil {
244+ cancel()
245+ return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
246+ }
247+
248+ // Open log file
249+ var logFile *os.File
250+ if logPath != "" {
251+ logFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
252+ if err != nil {
253+ cancel()
254+ return nil, fmt.Errorf("failed to open log file: %w", err)
255+ }
256+ }
257+
258+ // Start the command
259+ if err := cmd.Start(); err != nil {
260+ if logFile != nil {
261+ logFile.Close()
262+ }
263+ cancel()
264+ return nil, fmt.Errorf("failed to start chroot session: %w", err)
265+ }
266+
267+ session := &DefaultChrootSession{
268+ cmd: cmd,
269+ stdin: stdinPipe.(*os.File),
270+ stdoutPipe: stdoutPipe.(*os.File),
271+ stderrPipe: stderrPipe.(*os.File),
272+ logFile: logFile,
273+ logPath: logPath,
274+ mountPoint: mountPoint,
275+ ctx: sessionCtx,
276+ cancel: cancel,
277+ outputChan: make(chan string, 100),
278+ errorChan: make(chan error, 1),
279+ closed: false,
280+ }
281+
282+ // Start goroutines to handle output
283+ go session.handleOutput()
284+ go session.handleError()
285+
286+ return session, nil
287+}
288+
289+// handleOutput continuously reads from stdout and logs it
290+func (s *DefaultChrootSession) handleOutput() {
291+ buf := make([]byte, 4096)
292+ for {
293+ n, err := s.stdoutPipe.Read(buf)
294+ if n > 0 {
295+ data := string(buf[:n])
296+ if s.logFile != nil {
297+ s.logFile.WriteString(data)
298+ s.logFile.Sync()
299+ }
300+ s.outputChan <- data
301+ }
302+ if err != nil {
303+ close(s.outputChan)
304+ return
305+ }
306+ }
307+}
308+
309+// handleError continuously reads from stderr and logs it
310+func (s *DefaultChrootSession) handleError() {
311+ buf := make([]byte, 4096)
312+ for {
313+ n, err := s.stderrPipe.Read(buf)
314+ if n > 0 {
315+ data := string(buf[:n])
316+ if s.logFile != nil {
317+ s.logFile.WriteString(data)
318+ s.logFile.Sync()
319+ }
320+ }
321+ if err != nil {
322+ return
323+ }
324+ }
325+}
326+
327+// Exec executes a command in the chroot session
328+func (s *DefaultChrootSession) Exec(command string) error {
329+ return s.ExecWithContext(s.ctx, command)
330+}
331+
332+// ExecWithOutput executes a command and returns its output
333+func (s *DefaultChrootSession) ExecWithOutput(command string) (string, error) {
334+ return s.ExecWithOutputAndContext(s.ctx, command)
335+}
336+
337+// ExecWithContext executes a command with context support
338+func (s *DefaultChrootSession) ExecWithContext(ctx context.Context, command string) error {
339+ _, err := s.ExecWithOutputAndContext(ctx, command)
340+ return err
341+}
342+
343+// ExecWithOutputAndContext executes a command with context and returns output
344+func (s *DefaultChrootSession) ExecWithOutputAndContext(ctx context.Context, command string) (string, error) {
345+ if s.closed {
346+ return "", fmt.Errorf("session is closed")
347+ }
348+
349+ // Clear the output channel
350+ for len(s.outputChan) > 0 {
351+ <-s.outputChan
352+ }
353+
354+ // Write the command followed by a marker
355+ marker := fmt.Sprintf("ARCHUP_CMD_DONE_%d", os.Getpid())
356+ fullCommand := fmt.Sprintf("%s\necho \"%s_$?\"\n", command, marker)
357+
358+ if _, err := s.stdin.WriteString(fullCommand); err != nil {
359+ return "", fmt.Errorf("failed to write command: %w", err)
360+ }
361+
362+ // Collect output until we see the marker
363+ var output strings.Builder
364+ var exitCode string
365+
366+ for {
367+ select {
368+ case <-ctx.Done():
369+ return output.String(), fmt.Errorf("command context cancelled: %w", ctx.Err())
370+ case data, ok := <-s.outputChan:
371+ if !ok {
372+ return output.String(), fmt.Errorf("session stdout closed unexpectedly")
373+ }
374+
375+ // Check if output contains the marker
376+ if strings.Contains(data, marker) {
377+ // Extract exit code from marker line
378+ lines := strings.Split(data, "\n")
379+ for _, line := range lines {
380+ if strings.HasPrefix(line, marker+"_") {
381+ exitCode = strings.TrimPrefix(line, marker+"_")
382+ exitCode = strings.TrimSpace(exitCode)
383+
384+ // Add any output before the marker
385+ beforeMarker := strings.Split(data, marker)[0]
386+ output.WriteString(beforeMarker)
387+
388+ // Check exit code
389+ if exitCode != "0" {
390+ return output.String(), fmt.Errorf("command exited with code %s", exitCode)
391+ }
392+ return output.String(), nil
393+ }
394+ }
395+ } else {
396+ output.WriteString(data)
397+ }
398+ }
399+ }
400+}
401+
402+// Close terminates the session and cleans up resources
403+func (s *DefaultChrootSession) Close() error {
404+ if s.closed {
405+ return nil
406+ }
407+ s.closed = true
408+
409+ // Send exit command
410+ if s.stdin != nil {
411+ s.stdin.WriteString("exit\n")
412+ s.stdin.Close()
413+ }
414+
415+ // Wait for the command to finish with timeout
416+ done := make(chan error, 1)
417+ go func() {
418+ done <- s.cmd.Wait()
419+ }()
420+
421+ select {
422+ case <-time.After(5 * time.Second):
423+ // Force kill if it doesn't exit gracefully
424+ if s.cmd.Process != nil {
425+ s.cmd.Process.Kill()
426+ }
427+ case <-done:
428+ // Command exited normally
429+ }
430+
431+ // Close pipes and log file
432+ if s.stdoutPipe != nil {
433+ s.stdoutPipe.Close()
434+ }
435+ if s.stderrPipe != nil {
436+ s.stderrPipe.Close()
437+ }
438+ if s.logFile != nil {
439+ s.logFile.Close()
440+ }
441+
442+ // Cancel context
443+ s.cancel()
444+
445+ return nil
446+}

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 0

    ↑↓ moveenter openesc close