detached 32c6f5d

fix(review): context timeouts, defer cancel, skip malformed tree lines

commit
32c6f5d050ee4b802fa5785956b42c2bd9869ff0
author
bnema · · 2026-02-25 02:25 UTC
parent
0ef5b19
signature
unsigned
4 files · +28 -12
M internal/adapters/in/cli/ui/components/selector.go +2 -1
@@ -82,7 +82,8 @@ func (m SelectorModel) View() string {
8282 if i == m.cursor {
8383 b.WriteString(styles.Theme.Highlight.Render(cursor + line))
8484 } else {
85- fmt.Fprintf(&b, "%s%s", cursor, line)
85+ b.WriteString(cursor)
86+ b.WriteString(line)
8687 }
8788 b.WriteString("\n")
8889 }
@@ -82,7 +82,8 @@ func (m SelectorModel) View() string {
82 if i == m.cursor {82 if i == m.cursor {
83 b.WriteString(styles.Theme.Highlight.Render(cursor + line))83 b.WriteString(styles.Theme.Highlight.Render(cursor + line))
84 } else {84 } else {
85- fmt.Fprintf(&b, "%s%s", cursor, line)85+ b.WriteString(cursor)
86+ b.WriteString(line)
86 }87 }
87 b.WriteString("\n")88 b.WriteString("\n")
88 }89 }
M internal/adapters/out/domainsecrets/pass_store.go +11 -4
@@ -33,7 +33,10 @@ type PassStore struct {
3333
3434 // NewPassStore creates a new pass-based domain secret store.
3535 func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36- if err := exec.Command("pass", "version").Run(); err != nil {
36+ // Use a timeout so a stalled GPG agent or keyring does not hang startup.
37+ probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
38+ defer probeCancel()
39+ if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input
3740 return nil, fmt.Errorf("pass is not available: %w", err)
3841 }
3942
@@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) {
809812 }
810813
811814 for _, path := range paths {
812- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
813- _ = s.passRemove(ctx, path)
814- cancel()
815+ // Wrap in a closure so defer cancel() runs at the end of each iteration,
816+ // not at the end of cleanupInsertedPaths (which would delay all cancels).
817+ func(p string) {
818+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
819+ defer cancel()
820+ _ = s.passRemove(ctx, p)
821+ }(path)
815822 }
816823 }
@@ -33,7 +33,10 @@ type PassStore struct {
33 33
34 // NewPassStore creates a new pass-based domain secret store.34 // NewPassStore creates a new pass-based domain secret store.
35 func NewPassStore(log zerowrap.Logger) (*PassStore, error) {35 func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36- if err := exec.Command("pass", "version").Run(); err != nil {36+ // Use a timeout so a stalled GPG agent or keyring does not hang startup.
37+ probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
38+ defer probeCancel()
39+ if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input
37 return nil, fmt.Errorf("pass is not available: %w", err)40 return nil, fmt.Errorf("pass is not available: %w", err)
38 }41 }
39 42
@@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) {
809 }812 }
810 813
811 for _, path := range paths {814 for _, path := range paths {
812- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)815+ // Wrap in a closure so defer cancel() runs at the end of each iteration,
813- _ = s.passRemove(ctx, path)816+ // not at the end of cleanupInsertedPaths (which would delay all cancels).
814- cancel()817+ func(p string) {
818+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
819+ defer cancel()
820+ _ = s.passRemove(ctx, p)
821+ }(path)
815 }822 }
816 }823 }
M internal/adapters/out/secrets/pass.go +5 -3
@@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
105105 }
106106
107107 // IsAvailable checks if pass is available in the system.
108+// Uses a short timeout so a stalled GPG agent does not hang the caller.
108109 func (p *PassProvider) IsAvailable() bool {
109- cmd := exec.Command("pass", "version")
110- err := cmd.Run()
111- return err == nil
110+ ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
111+ defer cancel()
112+ cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input
113+ return cmd.Run() == nil
112114 }
@@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
105 }105 }
106 106
107 // IsAvailable checks if pass is available in the system.107 // IsAvailable checks if pass is available in the system.
108+// Uses a short timeout so a stalled GPG agent does not hang the caller.
108 func (p *PassProvider) IsAvailable() bool {109 func (p *PassProvider) IsAvailable() bool {
109- cmd := exec.Command("pass", "version")110+ ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
110- err := cmd.Run()111+ defer cancel()
111- return err == nil112+ cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input
113+ return cmd.Run() == nil
112 }114 }
M internal/adapters/out/tokenstore/pass.go +10 -4
@@ -236,7 +236,8 @@ func parsePassLsEntries(output string) []treeEntry {
236236 // The branch character (├ or └, 3 bytes each) immediately precedes them.
237237 branchByteIdx := idx - 3
238238 if branchByteIdx < 0 {
239- branchByteIdx = 0
239+ // Marker is too close to the start — malformed line, skip.
240+ continue
240241 }
241242 // Count runes in the indentation prefix before the branch char.
242243 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
@@ -246,7 +247,8 @@ func parsePassLsEntries(output string) []treeEntry {
246247 // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.
247248 branchByteIdx := idx - 1
248249 if branchByteIdx < 0 {
249- branchByteIdx = 0
250+ // Marker is too close to the start — malformed line, skip.
251+ continue
250252 }
251253 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
252254 // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.
@@ -351,8 +353,12 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
351353 continue
352354 }
353355
354- // Try to get the token metadata
355- _, token, err := s.GetToken(ctx, subject)
356+ // Try to get the token metadata.
357+ // Use a fresh per-call context so each GetToken gets the full timeout,
358+ // not the shared (potentially exhausted) deadline from the outer ctx.
359+ callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout)
360+ _, token, err := s.GetToken(callCtx, subject)
361+ callCancel()
356362 if err != nil {
357363 s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
358364 continue
@@ -236,7 +236,8 @@ func parsePassLsEntries(output string) []treeEntry {
236 // The branch character (├ or └, 3 bytes each) immediately precedes them.236 // The branch character (├ or └, 3 bytes each) immediately precedes them.
237 branchByteIdx := idx - 3237 branchByteIdx := idx - 3
238 if branchByteIdx < 0 {238 if branchByteIdx < 0 {
239- branchByteIdx = 0239+ // Marker is too close to the start — malformed line, skip.
240+ continue
240 }241 }
241 // Count runes in the indentation prefix before the branch char.242 // Count runes in the indentation prefix before the branch char.
242 branchRune = utf8.RuneCountInString(line[:branchByteIdx])243 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
@@ -246,7 +247,8 @@ func parsePassLsEntries(output string) []treeEntry {
246 // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.247 // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.
247 branchByteIdx := idx - 1248 branchByteIdx := idx - 1
248 if branchByteIdx < 0 {249 if branchByteIdx < 0 {
249- branchByteIdx = 0250+ // Marker is too close to the start — malformed line, skip.
251+ continue
250 }252 }
251 branchRune = utf8.RuneCountInString(line[:branchByteIdx])253 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
252 // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.254 // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.
@@ -351,8 +353,12 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
351 continue353 continue
352 }354 }
353 355
354- // Try to get the token metadata356+ // Try to get the token metadata.
355- _, token, err := s.GetToken(ctx, subject)357+ // Use a fresh per-call context so each GetToken gets the full timeout,
358+ // not the shared (potentially exhausted) deadline from the outer ctx.
359+ callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout)
360+ _, token, err := s.GetToken(callCtx, subject)
361+ callCancel()
356 if err != nil {362 if err != nil {
357 s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")363 s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
358 continue364 continue

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 7

Tags 106

↑↓ moveenter openesc close