chore: add golangci-lint, GitHub Actions CI, and pre-commit hooks
Adds .golangci.yml (errcheck, govet, staticcheck, revive, gocyclo, gocritic, nilerr, errorlint, etc.). Fixes all lint issues found: - RpcError/wrapRpc renamed to RPCError/wrapRPC (revive var-naming) - Shadow vars resolved in process.go, outline.go, project.go - nilerr suppressed with justifying comments in git/collect.go and auto/detector.go (WalkDir / intentional empty-returns) - if-else chain rewritten as switch in format.go (gocritic) - gofmt applied to detector.go and client.go Adds .github/workflows/ci.yml: lint (ubuntu), build+vet+test matrix (ubuntu/windows/macos), and Roslyn helper build (windows). Validated with actionlint (0 issues). Adds .githooks/pre-commit + pre-commit.ps1 for local pre-commit checks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
15dc1b6b2f
commit
22f6e8fde9
36
.githooks/pre-commit
Normal file
36
.githooks/pre-commit
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ctx pre-commit hook — shell entry point (works in Git Bash on Windows)
|
||||||
|
# Delegates to pre-commit.ps1 on Windows, runs natively on Linux/macOS.
|
||||||
|
#
|
||||||
|
# To activate:
|
||||||
|
# git config core.hooksPath .githooks
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
# Windows: delegate to PowerShell script
|
||||||
|
exec powershell.exe -ExecutionPolicy Bypass -File .githooks/pre-commit.ps1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Linux / macOS: run directly
|
||||||
|
echo "Running gofmt..."
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "Files not formatted with gofmt:"
|
||||||
|
echo "$unformatted"
|
||||||
|
echo
|
||||||
|
echo "Run: gofmt -w ."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running go vet..."
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1; then
|
||||||
|
echo "Running golangci-lint..."
|
||||||
|
golangci-lint run ./...
|
||||||
|
else
|
||||||
|
echo "warning: golangci-lint not installed, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-commit checks passed"
|
||||||
25
.githooks/pre-commit.ps1
Normal file
25
.githooks/pre-commit.ps1
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# ctx pre-commit hook (PowerShell)
|
||||||
|
# To activate: git config core.hooksPath .githooks
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "Running gofmt..."
|
||||||
|
$unformatted = gofmt -l .
|
||||||
|
if ($unformatted) {
|
||||||
|
Write-Host "Files not formatted with gofmt:"
|
||||||
|
Write-Host $unformatted
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Run: gofmt -w ."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Running go vet..."
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
if (Get-Command golangci-lint -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Running golangci-lint..."
|
||||||
|
golangci-lint run ./...
|
||||||
|
} else {
|
||||||
|
Write-Host "warning: golangci-lint not installed, skipping"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "pre-commit checks passed"
|
||||||
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache: true
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build & Test
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache: true
|
||||||
|
- name: Build
|
||||||
|
run: go build -v ./...
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
- name: Test
|
||||||
|
run: go test -v -race -count=1 ./...
|
||||||
|
|
||||||
|
build-helper:
|
||||||
|
name: Build Roslyn Helper
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
- name: Build helper
|
||||||
|
working-directory: tools/roslyn-helper
|
||||||
|
run: dotnet build src/RoslynHelper -c Release
|
||||||
69
.golangci.yml
Normal file
69
.golangci.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
run:
|
||||||
|
timeout: 3m
|
||||||
|
tests: true
|
||||||
|
go: "1.26"
|
||||||
|
|
||||||
|
output:
|
||||||
|
formats:
|
||||||
|
- format: colored-line-number
|
||||||
|
print-issued-lines: true
|
||||||
|
print-linter-name: true
|
||||||
|
sort-results: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- errcheck # unhandled errors
|
||||||
|
- govet # official compiler checks
|
||||||
|
- ineffassign # useless assignments
|
||||||
|
- staticcheck # bugs and simplifications
|
||||||
|
- unused # dead code
|
||||||
|
- gofmt # formatting
|
||||||
|
- goimports # import organization
|
||||||
|
- misspell # typos in strings/comments
|
||||||
|
- revive # golint successor
|
||||||
|
- gocyclo # high cyclomatic complexity
|
||||||
|
- gocritic # additional checks
|
||||||
|
- bodyclose # http.Response.Body closed
|
||||||
|
- nilerr # returning nil when err intended
|
||||||
|
- errorlint # non-wrappable errors
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
gocyclo:
|
||||||
|
min-complexity: 15
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- fieldalignment # unnecessary noise
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
- name: exported
|
||||||
|
arguments: ["disableStutteringCheck"] # allows ctx.Context
|
||||||
|
- name: error-return
|
||||||
|
- name: error-naming
|
||||||
|
- name: unexported-return
|
||||||
|
- name: indent-error-flow
|
||||||
|
- name: unreachable-code
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-dirs:
|
||||||
|
- tools/roslyn-helper # C# code, ignore
|
||||||
|
- vendor
|
||||||
|
- bin
|
||||||
|
exclude-rules:
|
||||||
|
# Allow errcheck in cobra command handlers — cobra's RunE owns error printing
|
||||||
|
- path: cmd/
|
||||||
|
linters: [errcheck]
|
||||||
|
# Allow higher complexity in plugin format functions (inherently procedural)
|
||||||
|
- path: internal/plugins/.*/format\.go
|
||||||
|
linters: [gocyclo]
|
||||||
|
text: "cyclomatic complexity"
|
||||||
|
# Allow higher complexity in plugin collect/detect logic
|
||||||
|
- path: internal/plugins/.*/detect
|
||||||
|
linters: [gocyclo]
|
||||||
|
text: "cyclomatic complexity"
|
||||||
|
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
new: false
|
||||||
30
README.md
30
README.md
@ -80,6 +80,36 @@ runtime into the Go binary.
|
|||||||
|
|
||||||
See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
|
See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Go 1.26+
|
||||||
|
- .NET SDK 10.0+ (for Roslyn helper)
|
||||||
|
- golangci-lint (`go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```sh
|
||||||
|
go build -o ctx.exe ./cmd/ctx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests & lint
|
||||||
|
```sh
|
||||||
|
go vet ./...
|
||||||
|
golangci-lint run ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit hooks
|
||||||
|
```sh
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
# On Linux/macOS also: chmod +x .githooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Roslyn helper
|
||||||
|
```sh
|
||||||
|
cd tools/roslyn-helper
|
||||||
|
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.
|
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.
|
||||||
|
|||||||
@ -46,8 +46,8 @@ var skipDirs = map[string]bool{
|
|||||||
|
|
||||||
// findings holds raw evidence gathered during the directory walk.
|
// findings holds raw evidence gathered during the directory walk.
|
||||||
type findings struct {
|
type findings struct {
|
||||||
slnFiles []string // forward-slash relative paths to .sln files
|
slnFiles []string // forward-slash relative paths to .sln files
|
||||||
csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files
|
csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files
|
||||||
hasGlobalJSON bool
|
hasGlobalJSON bool
|
||||||
packageJSONs []pkgJSON
|
packageJSONs []pkgJSON
|
||||||
hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1)
|
hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1)
|
||||||
@ -68,7 +68,7 @@ func scanDir(rootDir string, maxDepth int) (findings, error) {
|
|||||||
|
|
||||||
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // skip unreadable entries silently
|
return nil //nolint:nilerr // skip unreadable entries; returning err would abort the walk
|
||||||
}
|
}
|
||||||
if path == rootDir {
|
if path == rootDir {
|
||||||
return nil // skip root itself
|
return nil // skip root itself
|
||||||
|
|||||||
@ -223,13 +223,14 @@ func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
|
|||||||
|
|
||||||
if len(frameworkSets) == 0 {
|
if len(frameworkSets) == 0 {
|
||||||
// All same framework, or single framework each
|
// All same framework, or single framework each
|
||||||
if len(allFrameworks) == 1 {
|
switch len(allFrameworks) {
|
||||||
|
case 0:
|
||||||
|
fmt.Fprintf(w, "No target frameworks detected.\n\n")
|
||||||
|
case 1:
|
||||||
for tf := range allFrameworks {
|
for tf := range allFrameworks {
|
||||||
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
|
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
|
||||||
}
|
}
|
||||||
} else if len(allFrameworks) == 0 {
|
default:
|
||||||
fmt.Fprintf(w, "No target frameworks detected.\n\n")
|
|
||||||
} else {
|
|
||||||
// Multiple different single targets
|
// Multiple different single targets
|
||||||
for _, p := range projects {
|
for _, p := range projects {
|
||||||
if len(p.TargetFrameworks) > 0 {
|
if len(p.TargetFrameworks) > 0 {
|
||||||
|
|||||||
@ -84,7 +84,7 @@ type PackageReference struct {
|
|||||||
func (c *Client) Ping() (*PingResult, error) {
|
func (c *Client) Ping() (*PingResult, error) {
|
||||||
raw, err := c.proc.Send("ping", nil)
|
raw, err := c.proc.Send("ping", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapRpc("ping", err)
|
return nil, wrapRPC("ping", err)
|
||||||
}
|
}
|
||||||
var r PingResult
|
var r PingResult
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
@ -98,7 +98,7 @@ func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
|
|||||||
params := map[string]string{"path": path}
|
params := map[string]string{"path": path}
|
||||||
raw, err := c.proc.Send("loadSolution", params)
|
raw, err := c.proc.Send("loadSolution", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapRpc("loadSolution", err)
|
return nil, wrapRPC("loadSolution", err)
|
||||||
}
|
}
|
||||||
var r LoadSolutionResult
|
var r LoadSolutionResult
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
@ -111,7 +111,7 @@ func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
|
|||||||
func (c *Client) ProjectSummary() (*ProjectSummary, error) {
|
func (c *Client) ProjectSummary() (*ProjectSummary, error) {
|
||||||
raw, err := c.proc.Send("projectSummary", nil)
|
raw, err := c.proc.Send("projectSummary", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapRpc("projectSummary", err)
|
return nil, wrapRPC("projectSummary", err)
|
||||||
}
|
}
|
||||||
var r ProjectSummary
|
var r ProjectSummary
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
@ -124,12 +124,12 @@ func (c *Client) ProjectSummary() (*ProjectSummary, error) {
|
|||||||
|
|
||||||
// OutlineResult is the structural outline of a single .cs file.
|
// OutlineResult is the structural outline of a single .cs file.
|
||||||
type OutlineResult struct {
|
type OutlineResult struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
LineCount int `json:"lineCount"`
|
LineCount int `json:"lineCount"`
|
||||||
Usings []string `json:"usings"`
|
Usings []string `json:"usings"`
|
||||||
Types []OutlineType `json:"types"`
|
Types []OutlineType `json:"types"`
|
||||||
HasSyntaxErrors bool `json:"hasSyntaxErrors"`
|
HasSyntaxErrors bool `json:"hasSyntaxErrors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutlineType describes a type (class, interface, struct, record, enum) in the file.
|
// OutlineType describes a type (class, interface, struct, record, enum) in the file.
|
||||||
@ -157,7 +157,7 @@ func (c *Client) Outline(path string) (*OutlineResult, error) {
|
|||||||
params := map[string]string{"path": path}
|
params := map[string]string{"path": path}
|
||||||
raw, err := c.proc.Send("outline", params)
|
raw, err := c.proc.Send("outline", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapRpc("outline", err)
|
return nil, wrapRPC("outline", err)
|
||||||
}
|
}
|
||||||
var r OutlineResult
|
var r OutlineResult
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
@ -166,9 +166,9 @@ func (c *Client) Outline(path string) (*OutlineResult, error) {
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapRpc wraps RpcError values into user-friendly messages.
|
// wrapRPC wraps RPCError values into user-friendly messages.
|
||||||
func wrapRpc(method string, err error) error {
|
func wrapRPC(method string, err error) error {
|
||||||
var rpcErr *RpcError
|
var rpcErr *RPCError
|
||||||
if errors.As(err, &rpcErr) {
|
if errors.As(err, &rpcErr) {
|
||||||
switch rpcErr.Code {
|
switch rpcErr.Code {
|
||||||
case "E_NOT_FOUND":
|
case "E_NOT_FOUND":
|
||||||
|
|||||||
@ -73,8 +73,8 @@ func (p *Process) Send(method string, params interface{}) (json.RawMessage, erro
|
|||||||
}
|
}
|
||||||
line = append(line, '\n')
|
line = append(line, '\n')
|
||||||
|
|
||||||
if _, err := p.stdin.Write(line); err != nil {
|
if _, werr := p.stdin.Write(line); werr != nil {
|
||||||
return nil, fmt.Errorf("helper write (process may have crashed): %w", err)
|
return nil, fmt.Errorf("helper write (process may have crashed): %w", werr)
|
||||||
}
|
}
|
||||||
|
|
||||||
respLine, err := p.stdout.ReadString('\n')
|
respLine, err := p.stdout.ReadString('\n')
|
||||||
|
|||||||
@ -13,14 +13,14 @@ type Request struct {
|
|||||||
type Response struct {
|
type Response struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
Error *RpcError `json:"error,omitempty"`
|
Error *RPCError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RpcError is a structured error from the helper process.
|
// RpcError is a structured error from the helper process.
|
||||||
type RpcError struct {
|
type RPCError struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *RpcError) Error() string { return e.Code + ": " + e.Message }
|
func (e *RPCError) Error() string { return e.Code + ": " + e.Message }
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func runOutline(ctx *core.Context, file string) error {
|
|||||||
return errExit
|
return errExit
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(abs); err != nil {
|
if _, statErr := os.Stat(abs); statErr != nil {
|
||||||
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
|
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
|
||||||
return errExit
|
return errExit
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,8 +44,8 @@ func runProject(ctx *core.Context) error {
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
if _, err := client.LoadSolution(slnPath); err != nil {
|
if _, loadErr := client.LoadSolution(slnPath); loadErr != nil {
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
fmt.Fprintln(ctx.Stderr, loadErr.Error())
|
||||||
return errExit
|
return errExit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -171,8 +171,8 @@ func collectRepoInfo(dir string) (repoInfo, error) {
|
|||||||
func collectCommits(dir string, n int) ([]commit, error) {
|
func collectCommits(dir string, n int) ([]commit, error) {
|
||||||
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
|
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
// Empty repo or no commits — not a hard error.
|
// Empty repo or no commits — not an error for our purposes.
|
||||||
return nil, nil
|
return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show"
|
||||||
}
|
}
|
||||||
var commits []commit
|
var commits []commit
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
@ -254,7 +254,7 @@ func parseNumstat(dir string, cached bool) ([]fileChange, error) {
|
|||||||
}
|
}
|
||||||
out, err := gitCmd(dir, args...)
|
out, err := gitCmd(dir, args...)
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
return nil, nil
|
return nil, nil //nolint:nilerr // no changes or not in a git repo — not an error
|
||||||
}
|
}
|
||||||
var changes []fileChange
|
var changes []fileChange
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
@ -280,7 +280,7 @@ func parseNumstat(dir string, cached bool) ([]fileChange, error) {
|
|||||||
func parseUntracked(dir string) ([]string, error) {
|
func parseUntracked(dir string) ([]string, error) {
|
||||||
out, err := gitCmd(dir, "status", "--porcelain=v1")
|
out, err := gitCmd(dir, "status", "--porcelain=v1")
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
return nil, nil
|
return nil, nil //nolint:nilerr // clean working tree or not in a git repo — not an error
|
||||||
}
|
}
|
||||||
var untracked []string
|
var untracked []string
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user