A very simple terminal spinner
0

Configure Feed

Select the types of activity you want to include in your feed.

Add tangled workflows and switch to mise

Tom Fleet (Jun 15, 2026, 7:40 PM +0100) 96f75fc9 abd774c5

+165 -206
+36 -94
.golangci.yml
··· 11 11 extra-rules: true 12 12 13 13 golines: 14 - max-len: 140 14 + max-len: 120 15 15 16 16 linters: 17 17 default: all 18 18 disable: 19 + - cyclop # gocognit is the single complexity linter, cognitive beats cyclomatic 19 20 - decorder # Don't care about this 20 - - dupl # Basically every table driven test ever triggers this 21 - - dupword # Messes with test cases more often than not 22 - - err113 # Out of date 23 - - exhaustruct # No 21 + - err113 # Not everything needs this 22 + - exhaustruct # This is just a bad idea 24 23 - forbidigo # Nothing to forbid 25 24 - funlen # Bad metric for complexity 26 - - ginkgolinter # I don't use whatever this is 27 - - gochecknoglobals # Globals are fine sometimes, use common sense 28 - - gocyclo # cyclop does this instead 25 + - goconst # Got significantly more annoying recently 26 + - gocyclo # Prefer cognitive complexity over naive metrics 29 27 - godox # "todo" and "fixme" comments are allowed 30 - - goheader # No need 31 28 - gomodguard # Deprecated 32 - - gosmopolitan # No need 33 - - grouper # Imports take care of themselves, rest is common sense 34 - - ireturn # This is just not necessary or practical in a real codebase 29 + - ireturn # AST is full of interfaces we need to return (e.g. Expression) 35 30 - lll # Auto formatters do this and what they can't do I don't care about 36 - - maintidx # This is just the inverse of complexity... which is cyclop 37 - - nestif # cyclop does this 38 - - nlreturn # Similar to wsl, I think best left to judgement 31 + - maintidx # Prefer cognitive 32 + - mnd # Annoying and too sensitive 33 + - nestif # Nesting depth is the dominant factor in gocognit's score already 39 34 - noinlineerr # Inline errors are fine 40 - - nonamedreturns # Named returns are often helpful, it's naked returns that are the issue 35 + - nonamedreturns # Named returns are often helpful documentation, it's naked returns that are the issue 41 36 - paralleltest # I've never had Go tests take longer than a few seconds, it's fine 42 37 - unparam # gopls is better and more subtle 43 38 - varnamelen # Lots of false positives of things that are fine 44 39 - wrapcheck # Not every error must be wrapped 45 - - wsl # Very aggressive, some of this I like but tend to do anyway 46 - - wsl_v5 # As above, just newer version 40 + - wsl # Deprecated 47 41 48 42 exclusions: 49 43 presets: 50 44 # See https://golangci-lint.run/usage/false-positives/#exclusion-presets 51 - - comments # Revive in particular has lots of false positives 52 - - std-error-handling 53 45 - common-false-positives 46 + - std-error-handling 54 47 rules: 55 48 - path: _test\.go 56 49 linters: 57 - - prealloc # These kinds of optimisations will make no difference to test code 50 + - dupl # Common false positives with table driven tests 51 + - dupword # Messes with test cases more often than not 52 + - gochecknoglobals # Flags and env vars 53 + - goconst # Sometimes repetition is okay in tests 58 54 - gosec # Tests don't need security stuff 55 + - prealloc # These kinds of optimisations will make no difference to test code 59 56 60 57 settings: 61 - cyclop: 62 - max-complexity: 20 63 - 64 58 depguard: 65 59 rules: 66 60 main: ··· 72 66 desc: use math/rand/v2 instead 73 67 74 68 errcheck: 75 - check-type-assertions: true 76 69 check-blank: true 70 + check-type-assertions: true 77 71 78 72 exhaustive: 79 73 check: 80 - - switch 81 74 - map 75 + - switch 82 76 default-signifies-exhaustive: true 83 - 84 - staticcheck: 85 - checks: 86 - - all 87 77 88 78 gosec: 89 79 excludes: ··· 100 90 require-explanation: true 101 91 require-specific: true 102 92 93 + revive: 94 + # every other revive rule overlaps one or more of the other 95 + # linters. These are the ones nothing else covers. 96 + enable-all-rules: false 97 + rules: 98 + - name: context-as-argument # ctx must be the first parameter 99 + - name: deep-exit # os.Exit / log.Fatal outside main and init 100 + - name: modifies-parameter # don't mutate value parameters 101 + - name: modifies-value-receiver # value receiver mutations are lost 102 + - name: unexported-return # exported func returning an unexported type 103 + 104 + staticcheck: 105 + checks: 106 + - all 107 + 103 108 usetesting: 104 109 context-background: true 105 110 context-todo: true 106 111 os-chdir: true 112 + os-create-temp: true 107 113 os-mkdir-temp: true 108 114 os-setenv: true 109 - os-create-temp: true 110 115 os-temp-dir: true 111 - 112 - revive: 113 - max-open-files: 256 114 - enable-all-rules: true 115 - rules: 116 - - name: add-constant 117 - disabled: true # goconst does this 118 - 119 - - name: argument-limit 120 - arguments: 121 - - 5 122 - 123 - - name: cognitive-complexity 124 - disabled: true # gocognit does this 125 - 126 - - name: comment-spacings 127 - arguments: 128 - - "nolint:" 129 - 130 - - name: cyclomatic 131 - disabled: true # cyclop does this 132 - 133 - - name: exported 134 - arguments: 135 - - checkPrivateReceivers 136 - - checkPublicInterface 137 - 138 - - name: function-length 139 - disabled: true # Bad proxy for complexity 140 - 141 - - name: function-result-limit 142 - arguments: 143 - - 3 144 - 145 - - name: import-shadowing 146 - disabled: true # predeclared does this 147 - 148 - - name: line-length-limit 149 - disabled: true # gofmt/golines handles this well enough 150 - 151 - - name: max-public-structs 152 - disabled: true # This is a dumb rule 153 - 154 - - name: redefines-builtin-id 155 - disabled: true # predeclared does this 156 - 157 - - name: unhandled-error 158 - arguments: 159 - - fmt\.(Fp|P)rint(ln|f)? 160 - - strings.Builder.Write(String|Byte)? 161 - - bytes.Buffer.Write(String|Byte)? 162 - 163 - - name: flag-parameter 164 - disabled: true # As far as I can work out this just doesn't like bools 165 - 166 - - name: unused-parameter 167 - disabled: true # The gopls unused analyzer covers this better 168 - 169 - - name: unused-receiver 170 - disabled: true # As above 171 - 172 - - name: var-naming 173 - disabled: true
-112
Taskfile.yml
··· 1 - # https://taskfile.dev 2 - 3 - version: "3" 4 - 5 - vars: 6 - COV_DATA: coverage.out 7 - 8 - tasks: 9 - default: 10 - desc: List all available tasks 11 - silent: true 12 - cmds: 13 - - task --list 14 - 15 - tidy: 16 - desc: Tidy dependencies in go.mod and go.sum 17 - sources: 18 - - "**/*.go" 19 - - go.mod 20 - - go.sum 21 - cmds: 22 - - go mod tidy 23 - 24 - fmt: 25 - desc: Run go fmt on all source files 26 - sources: 27 - - "**/*.go" 28 - - .golangci.yml 29 - cmds: 30 - - golangci-lint fmt 31 - 32 - test: 33 - desc: Run the test suite 34 - sources: 35 - - "**/*.go" 36 - - go.mod 37 - - go.sum 38 - - "**/testdata/**/*" 39 - cmds: 40 - - go test -race ./... {{ .CLI_ARGS }} 41 - 42 - bench: 43 - desc: Run all project benchmarks 44 - sources: 45 - - "**/*.go" 46 - cmds: 47 - - go test ./... -run None -benchmem -bench . {{ .CLI_ARGS }} 48 - 49 - lint: 50 - desc: Run the linters and auto-fix if possible 51 - sources: 52 - - "**/*.go" 53 - - .golangci.yml 54 - preconditions: 55 - - sh: command -v golangci-lint 56 - msg: golangci-lint not installed, see https://golangci-lint.run/usage/install/#local-installation 57 - 58 - - sh: command -v typos 59 - msg: requires typos-cli, run `brew install typos-cli` 60 - cmds: 61 - - golangci-lint run --fix 62 - - typos 63 - 64 - doc: 65 - desc: Render the pkg docs locally 66 - preconditions: 67 - - sh: command -v pkgsite 68 - msg: pkgsite not installed, run `go install golang.org/x/pkgsite/cmd/pkgsite@latest` 69 - cmds: 70 - - pkgsite -open 71 - 72 - demo: 73 - desc: Render the demo gifs 74 - sources: 75 - - ./docs/src/*.tape 76 - - "**/*.go" 77 - preconditions: 78 - - sh: command -v vhs 79 - msg: vhs not installed, see https://github.com/charmbracelet/vhs 80 - cmds: 81 - - for file in ./docs/src/*.tape; do vhs "$file"; done 82 - 83 - cov: 84 - desc: Calculate test coverage and render the html 85 - generates: 86 - - "{{ .COV_DATA }}" 87 - cmds: 88 - - go test -race -cover -covermode atomic -coverprofile {{ .COV_DATA }} ./... 89 - - go tool cover -html {{ .COV_DATA }} 90 - 91 - check: 92 - desc: Run tests and linting in one 93 - cmds: 94 - - task: test 95 - - task: lint 96 - 97 - sloc: 98 - desc: Print lines of code 99 - cmds: 100 - - fd . -e go | xargs wc -l | sort -nr | head 101 - 102 - clean: 103 - desc: Remove build artifacts and other clutter 104 - cmds: 105 - - go clean ./... 106 - - rm -rf {{ .COV_DATA }} 107 - 108 - update: 109 - desc: Updates dependencies in go.mod and go.sum 110 - cmds: 111 - - go get -u ./... 112 - - go mod tidy
+78
mise.toml
··· 1 + # Dev tasks. Run `mise tasks` to list, `mise run <task>` to execute. 2 + # Extra args after `--` are appended to the task's command: 3 + # mise run test -- -run TestFoo 4 + # mise run bench -- -benchtime 5s 5 + 6 + [tools] 7 + typos = "latest" 8 + "go:golang.org/x/pkgsite/cmd/pkgsite" = "latest" 9 + golangci-lint = "latest" 10 + 11 + [vars] 12 + COV_DATA = "coverage.out" 13 + 14 + [tasks.tidy] 15 + description = "Tidy dependencies in go.mod and go.sum" 16 + sources = ["**/*.go", "go.mod", "go.sum"] 17 + run = "go mod tidy" 18 + 19 + [tasks.gen] 20 + description = "Run go generate" 21 + sources = ["**/*.go"] 22 + run = "go generate ./..." 23 + 24 + [tasks.fmt] 25 + description = "Run go fmt on all source files" 26 + sources = ["**/*.go", ".golangci.yml"] 27 + run = "golangci-lint fmt ./..." 28 + 29 + [tasks.test] 30 + description = "Run the test suite" 31 + sources = [ 32 + "**/*.go", 33 + "go.mod", 34 + "go.sum", 35 + "**/testdata/**/*", 36 + ] 37 + # -race needs CGO (https://go.dev/doc/articles/race_detector#Requirements) 38 + env = { CGO_ENABLED = "1" } 39 + run = "go test -race ./..." 40 + 41 + [tasks.bench] 42 + description = "Run all project benchmarks" 43 + sources = ["**/*.go"] 44 + run = "go test ./... -run None -benchmem -bench ." 45 + 46 + [tasks.lint] 47 + description = "Run linting" 48 + depends = ["fmt"] 49 + sources = ["**/*.go", ".golangci.yml"] 50 + run = ["golangci-lint run --fix", "typos"] 51 + 52 + [tasks.doc] 53 + description = "Render the pkg docs locally" 54 + run = "pkgsite -open" 55 + 56 + [tasks.cov] 57 + description = "Calculate test coverage and render the html" 58 + outputs = ["{{ vars.COV_DATA }}"] 59 + run = [ 60 + "go test -race -cover -covermode atomic -coverprofile {{ vars.COV_DATA }} ./...", 61 + "go tool cover -html {{ vars.COV_DATA }}", 62 + ] 63 + 64 + [tasks.check] 65 + description = "Run tests and linting in one" 66 + depends = ["test", "lint"] 67 + 68 + [tasks.sloc] 69 + description = "Print lines of code" 70 + run = "fd . -e go | xargs wc -l | sort -nr | head" 71 + 72 + [tasks.clean] 73 + description = "Remove build artifacts and other clutter" 74 + run = ["go clean ./...", "rm -rf {{ vars.COV_DATA }} ./bin ./dist"] 75 + 76 + [tasks.update] 77 + description = "Updates dependencies in go.mod and go.sum" 78 + run = ["go get -u ./...", "go mod tidy"]
+3
spin.go
··· 77 77 // Store the frames and the index locally so no need for synchronisation. 78 78 frames := [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} 79 79 current := 0 80 + 80 81 ticker := time.NewTicker(frameRate) 81 82 defer ticker.Stop() 83 + 82 84 for { 83 85 select { 84 86 case <-s.stop: ··· 109 111 func (s *Spinner) Do(fn func()) { 110 112 s.Start() 111 113 defer s.Stop() 114 + 112 115 fn() 113 116 } 114 117
+10
spin_test.go
··· 24 24 t.Run(tt.name, func(t *testing.T) { 25 25 synctest.Test(t, func(t *testing.T) { 26 26 var buf bytes.Buffer 27 + 27 28 s := spin.New(&buf, tt.msg, spin.WithForceEnabled()) 28 29 s.Start() 29 30 time.Sleep(250 * time.Millisecond) ··· 34 35 if !strings.Contains(output, tt.msg) { 35 36 t.Errorf("output %q does not contain message %q", output, tt.msg) 36 37 } 38 + 37 39 if !strings.HasSuffix(output, eraseSeq) { 38 40 t.Errorf("output %q does not end with ANSI erase sequence", output) 39 41 } ··· 44 46 45 47 func TestSpinnerStopWhenNotRunningIsNoOp(t *testing.T) { 46 48 var buf bytes.Buffer 49 + 47 50 s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 48 51 s.Stop() // must not panic or write anything 49 52 ··· 55 58 func TestSpinnerStopCalledTwiceIsNoOp(t *testing.T) { 56 59 synctest.Test(t, func(t *testing.T) { 57 60 var buf bytes.Buffer 61 + 58 62 s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 59 63 s.Start() 60 64 time.Sleep(250 * time.Millisecond) ··· 74 78 func TestSpinnerStartWhenAlreadyRunningIsNoOp(t *testing.T) { 75 79 synctest.Test(t, func(t *testing.T) { 76 80 var buf bytes.Buffer 81 + 77 82 s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 78 83 s.Start() 79 84 s.Start() // second call must not start a second goroutine ··· 90 95 91 96 func TestSpinnerDoExecutesFunction(t *testing.T) { 92 97 var buf bytes.Buffer 98 + 93 99 s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 94 100 95 101 ran := false 102 + 96 103 s.Do(func() { ran = true }) 97 104 98 105 if !ran { ··· 103 110 func TestSpinnerDoStartsAndStopsAroundFunction(t *testing.T) { 104 111 synctest.Test(t, func(t *testing.T) { 105 112 var buf bytes.Buffer 113 + 106 114 s := spin.New(&buf, "Loading", spin.WithForceEnabled()) 107 115 108 116 s.Do(func() { time.Sleep(250 * time.Millisecond) }) ··· 111 119 if !strings.Contains(output, "Loading") { 112 120 t.Errorf("output %q does not contain message", output) 113 121 } 122 + 114 123 if !strings.HasSuffix(output, eraseSeq) { 115 124 t.Errorf("output %q does not end with erase sequence", output) 116 125 } ··· 120 129 func TestSpinnerNonTerminalWriterProducesNoOutput(t *testing.T) { 121 130 synctest.Test(t, func(t *testing.T) { 122 131 var buf bytes.Buffer 132 + 123 133 s := spin.New(&buf, "Loading") // no WithForceEnabled — Start is a no-op 124 134 s.Start() 125 135 time.Sleep(250 * time.Millisecond)
+19
.tangled/workflows/Lint.yml
··· 1 + when: 2 + - event: [push] 3 + branch: [main] 4 + - event: [pull_request] 5 + branch: [main] 6 + 7 + engine: nixery 8 + 9 + dependencies: 10 + nixpkgs/nixpkgs-unstable: 11 + - go 12 + - golangci-lint 13 + 14 + steps: 15 + - name: Format 16 + command: golangci-lint fmt ./... --diff-colored 17 + 18 + - name: Lint 19 + command: golangci-lint run ./...
+19
.tangled/workflows/Test.yml
··· 1 + when: 2 + - event: [push] 3 + branch: [main] 4 + - event: [pull_request] 5 + branch: [main] 6 + 7 + engine: nixery 8 + 9 + dependencies: 10 + nixpkgs/nixpkgs-unstable: 11 + - go 12 + nixpkgs: 13 + - gcc # Needed for CGO_ENABLED=1 (go test -race) 14 + 15 + steps: 16 + - name: Test 17 + environment: 18 + CGO_ENABLED: "1" 19 + command: go test -race ./...