Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ version: "2"
linters:
default: none
enable:
- bodyclose
- copyloopvar
- durationcheck
- errcheck
- errorlint
- govet
- ineffassign
- makezero
- misspell
- nilerr
- nolintlint
- staticcheck
- unconvert
- unused
- wastedassign
settings:
errcheck:
check-type-assertions: true
Expand All @@ -15,13 +25,22 @@ linters:
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- vendor
rules:
- linters:
- nilerr
path: internal/integration/bounded_run_test\.go
text: "error is not nil"
- linters:
- nilerr
path: internal/pause/pause\.go
text: "error is not nil"
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ make verify # run build, vet, lint, deadcode, and un

# Lint
go vet ./... # built-in static analysis
make lint # golangci-lint (errcheck, gosimple, govet, ineffassign, staticcheck, unused, gofmt)
make lint # golangci-lint (bodyclose, copyloopvar, durationcheck, errcheck, errorlint, govet, ineffassign, makezero, misspell, nilerr, nolintlint, staticcheck, unconvert, unused, wastedassign, gofmt, goimports)
make deadcode # detect unreachable exported code and unused symbols

# Format
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Coverage measurement and regression-test evidence are documented in [Testing](do

## Code Style

- Format Go code with `gofmt`; `make verify` runs `golangci-lint`, which includes `gofmt` checks.
- Format Go code with `gofmt`; `make verify` runs `golangci-lint`, which includes `gofmt` and `goimports` checks.
- Follow idiomatic Go naming and structure, including the conventions in [Effective Go](https://go.dev/doc/effective_go) and [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments).
- Keep imports grouped and sorted by Go tooling conventions.
- Prefer small, minimal changes over broad refactors unless the refactor is the point of the work.
Expand Down
2 changes: 1 addition & 1 deletion cmd/mato/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,7 @@ func TestDoctorCmd_HardFailurePropagated(t *testing.T) {
if err == nil {
t.Fatal("expected hard failure error, got nil")
}
if err != hardErr {
if !errors.Is(err, hardErr) {
t.Errorf("error = %v, want %v", err, hardErr)
}

Expand Down
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config

import (
"bytes"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -94,15 +95,15 @@ func LoadFile(path string) (Config, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
if err == io.EOF {
if errors.Is(err, io.EOF) {
return Config{}, nil
}
return Config{}, fmt.Errorf("parse config file %s: %w", path, err)
}

// Reject multi-document YAML: a second Decode must return io.EOF.
var extra interface{}
if err := dec.Decode(&extra); err != io.EOF {
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
return Config{}, fmt.Errorf("parse config file %s: contains multiple YAML documents", path)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/configresolve/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func validateDurationResolved(resolved Resolved[string], settingName, configPath
if err == nil && d > 0 {
return nil
}
message := ""
var message string
if err != nil {
if resolved.Source == SourceEnv {
message = fmt.Sprintf("invalid %s %q: %v", resolved.EnvVar, resolved.Value, err)
Expand Down
3 changes: 2 additions & 1 deletion internal/doctor/check_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package doctor

import (
"fmt"
"github.com/ryansimmen/mato/internal/dirs"
"path/filepath"
"strings"

"github.com/ryansimmen/mato/internal/dirs"
)

// ---------- E. Task Parsing ----------
Expand Down
3 changes: 2 additions & 1 deletion internal/frontmatter/frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package frontmatter

import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -129,7 +130,7 @@ func decodeTaskMeta(block string, meta *TaskMeta) error {
dec := yaml.NewDecoder(strings.NewReader(block))
dec.KnownFields(true)
if err := dec.Decode(meta); err != nil {
if err == io.EOF {
if errors.Is(err, io.EOF) {
return nil
}
return err
Expand Down
2 changes: 1 addition & 1 deletion internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ func ShowTo(w io.Writer, repoRoot, format string, showAll bool) error {
// Fail on directory-level read errors only; skip glob warnings.
for _, bw := range idx.BuildWarnings() {
if !isGlobWarning(bw) {
return fmt.Errorf("incomplete index: %s: %v", bw.State, bw.Err)
return fmt.Errorf("incomplete index: %s: %w", bw.State, bw.Err)
}
}

Expand Down
12 changes: 3 additions & 9 deletions internal/merge/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func loadMergeTaskBranch(path string) (string, error) {
return "", errTaskBranchMarkerMissing
}
if err := git.ValidateBranch(taskBranch); err != nil {
return "", fmt.Errorf("%w: %v", errTaskBranchMarkerInvalid, err)
return "", fmt.Errorf("%w: %w", errTaskBranchMarkerInvalid, err)
}
return taskBranch, nil
}
Expand Down Expand Up @@ -228,25 +228,19 @@ func executeMergeRound(ctx context.Context, repoRoot, tasksDir, branch string, t
if !detailWritten {
continue
}
bookkeepingComplete := false
if err := moveTaskWithRetry(ctx, task.path, completedPath); err != nil {
if _, statErr := os.Stat(completedPath); statErr == nil {
if removeErr := removeTaskFileFn(task.path); removeErr != nil {
ui.Warnf("warning: could not remove duplicate ready-to-merge task %s: %v\n", task.name, removeErr)
continue
}
bookkeepingComplete = true
} else {
ui.Warnf("warning: merged task %s but could not move to completed: %v\n", task.name, err)
continue
}
} else {
bookkeepingComplete = true
}
if bookkeepingComplete {
runtimedata.DeleteRuntimeArtifacts(tasksDir, task.name)
cleanupTaskBranch(repoRoot, taskBranchName(task))
}
runtimedata.DeleteRuntimeArtifacts(tasksDir, task.name)
cleanupTaskBranch(repoRoot, taskBranchName(task))
merged++
}

Expand Down
4 changes: 2 additions & 2 deletions internal/merge/squash.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func mergeReadyTask(repoRoot, branch string, task mergeQueueTask) (*mergeResult,
}

if _, err := gitOutput(cloneDir, "merge", "--squash", "origin/"+taskBranch); err != nil {
return nil, fmt.Errorf("%w: %s: %v", errSquashMergeConflict, taskBranch, err)
return nil, fmt.Errorf("%w: %s: %w", errSquashMergeConflict, taskBranch, err)
}

// If the squash produced no staged changes, the task branch is already
Expand All @@ -63,7 +63,7 @@ func mergeReadyTask(repoRoot, branch string, task mergeQueueTask) (*mergeResult,
return nil, fmt.Errorf("commit squash merge: %w", err)
}
if _, err := gitOutput(cloneDir, "push", "origin", branch); err != nil {
return nil, fmt.Errorf("%w: push %s: %v", errPushAfterSquashFailed, branch, err)
return nil, fmt.Errorf("%w: push %s: %w", errPushAfterSquashFailed, branch, err)
}

// Capture merge result for completion detail.
Expand Down
1 change: 0 additions & 1 deletion internal/merge/taskops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ func TestAppendTaskRecord(t *testing.T) {
errCh := make(chan error, len(records))
var wg sync.WaitGroup
for _, record := range records {
record := record
wg.Add(1)
go func() {
defer wg.Done()
Expand Down
3 changes: 1 addition & 2 deletions internal/queue/cancel.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ func ListCancellableTasks(tasksDir string) []TaskMatch {
if _, ok := stateSet[pf.State]; !ok {
continue
}
pf := pf
matches = append(matches, TaskMatch{
Filename: pf.Filename,
State: pf.State,
Expand Down Expand Up @@ -126,7 +125,7 @@ func cancelResolvedTask(tasksDir string, idx *PollIndex, match TaskMatch) (Cance
if err := appendCancelledRecordFn(failedPath); err != nil {
if rollbackErr := AtomicMove(failedPath, match.Path); rollbackErr != nil {
fmt.Fprintf(os.Stderr, "error: cancelled marker write failed and rollback to %s/ also failed: %v\n", match.State, rollbackErr)
return CancelResult{}, fmt.Errorf("write cancelled marker: %w (rollback failed: %v)", err, rollbackErr)
return CancelResult{}, fmt.Errorf("write cancelled marker: %w (rollback failed: %w)", err, rollbackErr)
}
return CancelResult{}, fmt.Errorf("write cancelled marker to %s: %w (rolled back to %s/)", failedPath, err, match.State)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/queue/claim.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ func handleRetryExhaustedTask(name, dst, src, failedDir string) error {
// Move back to backlog so the task is not left orphaned
// in in-progress/ without a claimed-by marker.
if rbErr := retryExhaustedRollback(dst, src); rbErr != nil {
return fmt.Errorf("retry-exhausted rollback failed for %s (task stranded in in-progress): move-to-failed: %v, rollback: %w", name, err, rbErr)
return fmt.Errorf("retry-exhausted rollback failed for %s (task stranded in in-progress): move-to-failed: %w, rollback: %w", name, err, rbErr)
}
// Rollback succeeded, but the task is now back in backlog/
// while still retry-exhausted. Return a hard error so the
Expand All @@ -300,7 +300,7 @@ func handleRetryExhaustedTask(name, dst, src, failedDir string) error {
// meaning the task is stranded in in-progress/ without ownership metadata.
func rollbackClaimToBacklog(name, dst, src string, claimErr error) error {
if rbErr := claimRollbackFn(dst, src); rbErr != nil {
return fmt.Errorf("claim rollback failed for %s (task stranded in in-progress): prepend: %v, rollback: %w", name, claimErr, rbErr)
return fmt.Errorf("claim rollback failed for %s (task stranded in in-progress): prepend: %w, rollback: %w", name, claimErr, rbErr)
}
return nil
}
Expand Down Expand Up @@ -399,7 +399,7 @@ func SelectAndClaimTask(tasksDir, agentID string, candidates []string, cooldown
if err != nil {
ui.Warnf("warning: could not assign branch for %s: %v\n", name, err)
if restoreErr := restoreClaimedTaskContents(dst, originalData); restoreErr != nil {
return nil, fmt.Errorf("assign branch for %s: %w (also failed to restore task contents: %v)", name, err, restoreErr)
return nil, fmt.Errorf("assign branch for %s: %w (also failed to restore task contents: %w)", name, err, restoreErr)
}
if rbErr := rollbackClaimToBacklog(name, dst, src, err); rbErr != nil {
return nil, rbErr
Expand Down
3 changes: 2 additions & 1 deletion internal/queue/manifest_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package queue

import (
"github.com/ryansimmen/mato/internal/dirs"
"os"
"path/filepath"
"strings"
"testing"

"github.com/ryansimmen/mato/internal/dirs"
)

func TestComputeQueueManifest_PriorityOrder(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ func finalizeAtomicMove(src, dst, mode string) error {
}
cleanupErr := removeFn(dst)
if cleanupErr != nil && !os.IsNotExist(cleanupErr) {
return fmt.Errorf("atomic move %s → %s: remove source after %s: %w (also failed to remove destination during rollback: %v)", src, dst, mode, err, cleanupErr)
return fmt.Errorf("atomic move %s → %s: remove source after %s: %w (also failed to remove destination during rollback: %w)", src, dst, mode, err, cleanupErr)
}
return fmt.Errorf("atomic move %s → %s: remove source after %s: %w", src, dst, mode, err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/queue/resolve_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package queue

import (
"github.com/ryansimmen/mato/internal/dirs"
"strings"
"testing"

"github.com/ryansimmen/mato/internal/dirs"
)

func TestResolveTask(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion internal/queue/runnable_backlog_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package queue

import (
"github.com/ryansimmen/mato/internal/dirs"
"strings"
"testing"

"github.com/ryansimmen/mato/internal/dirs"
)

func TestFormatDependencyBlocks(t *testing.T) {
Expand Down
1 change: 0 additions & 1 deletion internal/queueview/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ func CollectTaskMatches(idx *PollIndex, taskRef string, states []string) (string
if _, ok := allowed[pf.State]; !ok {
continue
}
pf := pf
match := TaskMatch{
Filename: pf.Filename,
State: pf.State,
Expand Down
8 changes: 6 additions & 2 deletions internal/runner/runner_poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,10 @@ func pollLoop(ctx context.Context, env envConfig, run runContext, repoRoot, task
boundedErrorCount := 0
priorPaused := false
for {
if ctx.Err() != nil {
select {
case <-ctx.Done():
return nil
default:
}

result := pollIterate(ctx, env, run, repoRoot, tasksDir, branch, agentID, cooldown, &heartbeat, failedDirExcluded, priorPaused)
Expand All @@ -478,8 +480,10 @@ func pollLoop(ctx context.Context, env envConfig, run runContext, repoRoot, task
// immediately. This is unconditional — regardless of whether a
// task was claimed — to avoid starting new work with a cancelled
// context.
if ctx.Err() != nil {
select {
case <-ctx.Done():
return nil
default:
}

cycleHadError := result.pollHadError
Expand Down
7 changes: 5 additions & 2 deletions internal/runner/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ func runOnce(ctx context.Context, env envConfig, run runContext, claimed *queue.
func postAgentPush(env envConfig, agentID string, claimed *queue.ClaimedTask, cloneDir, startingTip string) error {
// Task must still be in in-progress/ (agent no longer moves files).
if _, err := os.Stat(claimed.TaskPath); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat claimed task %s: %w", claimed.TaskPath, err)
}
return nil
}

Expand Down Expand Up @@ -351,9 +354,9 @@ func moveTaskToReviewWithMarker(tasksDir string, claimed *queue.ClaimedTask, bra
detail := fmt.Sprintf("write branch marker to %s: %v (rollback failed: %v)", readyPath, err, rollbackErr)
if quarantineErr := queue.QuarantinePushedTaskHandoff(tasksDir, claimed.Filename, readyPath, detail); quarantineErr != nil {
fmt.Fprintf(os.Stderr, "error: branch marker write failed, rollback to in-progress/ also failed, and quarantine to failed/ also failed: %v\n", quarantineErr)
return fmt.Errorf("write branch marker to %s: %w (rollback failed: %v; quarantine to failed/ also failed: %v)", readyPath, err, rollbackErr, quarantineErr)
return fmt.Errorf("write branch marker to %s: %w (rollback failed: %w; quarantine to failed/ also failed: %w)", readyPath, err, rollbackErr, quarantineErr)
}
return fmt.Errorf("write branch marker to %s: %w (rollback failed: %v; moved task to failed/)", readyPath, err, rollbackErr)
return fmt.Errorf("write branch marker to %s: %w (rollback failed: %w; moved task to failed/)", readyPath, err, rollbackErr)
}
return fmt.Errorf("write branch marker to %s: %w (rolled back to in-progress/)", readyPath, err)
}
Expand Down
Loading