diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3deb69c..612bc6b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,15 +9,17 @@ if [ -z "$STAGED_GO" ]; then exit 0 fi -echo "pre-commit: goimports..." -if command -v goimports &>/dev/null; then +echo "pre-commit: formatting..." +if command -v gofumpt &>/dev/null && command -v goimports &>/dev/null; then + echo "$STAGED_GO" | xargs gofumpt -w echo "$STAGED_GO" | xargs goimports -w echo "$STAGED_GO" | xargs git diff --exit-code -- >/dev/null 2>&1 || { - echo "pre-commit: goimports produced changes — re-staged." + echo "pre-commit: formatter produced changes — re-staged." echo "$STAGED_GO" | xargs git add } else - echo "pre-commit: goimports not found, skipping format check" + echo "pre-commit: gofumpt/goimports not found, skipping format check" + echo " install: go install mvdan.cc/gofumpt@latest" echo " install: go install golang.org/x/tools/cmd/goimports@latest" fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..7f3fbb8 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Forge pre-push hook: runs full test suite so CI failures are caught locally. +# Install: git config core.hooksPath .githooks +set -euo pipefail + +echo "pre-push: running tests..." +make test + +echo "pre-push: OK" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3920503..9359140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - name: Format check run: | + go install mvdan.cc/gofumpt@latest go install golang.org/x/tools/cmd/goimports@latest make fmt git diff --exit-code || (echo "::error::goimports produced changes; run 'make fmt' locally" && exit 1) diff --git a/cmd/forge/cmd_init.go b/cmd/forge/cmd_init.go index dada570..51496b4 100644 --- a/cmd/forge/cmd_init.go +++ b/cmd/forge/cmd_init.go @@ -130,7 +130,7 @@ func cmdInit() error { return fmt.Errorf("rendering template: %w", err) } - if err := os.WriteFile(configPath, buf.Bytes(), 0644); err != nil { + if err := os.WriteFile(configPath, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("writing %s: %w", configPath, err) } diff --git a/internal/graph/topsort.go b/internal/graph/topsort.go index 4d6f148..f1f5444 100644 --- a/internal/graph/topsort.go +++ b/internal/graph/topsort.go @@ -8,8 +8,10 @@ import ( "strings" ) -var depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`) -var issueNumPattern = regexp.MustCompile(`#(\d+)`) +var ( + depPattern = regexp.MustCompile(`(?i)(?:depends on|blocked by)\s+(#\d+(?:,\s*#\d+)*)`) + issueNumPattern = regexp.MustCompile(`#(\d+)`) +) // ParseDeps extracts issue dependencies from a GitHub issue body. // It looks for "Depends on #N" and "Blocked by #N" patterns (case-insensitive), diff --git a/internal/intent/classify.go b/internal/intent/classify.go index 9b3620b..d192f1f 100644 --- a/internal/intent/classify.go +++ b/internal/intent/classify.go @@ -13,12 +13,15 @@ import ( // CommandContext is the function used to create exec.Cmd. Override in tests. var CommandContext = exec.CommandContext +// LookPath is the function used to find executables. Override in tests. +var LookPath = exec.LookPath + // MinConfidence is the minimum confidence score required to accept a classification. const MinConfidence = 0.5 // Classify interprets a natural language query as a forge command. func Classify(ctx context.Context, query string) (*Result, error) { - if _, err := exec.LookPath("claude"); err != nil { + if _, err := LookPath("claude"); err != nil { return nil, ErrNoClaude } diff --git a/internal/intent/classify_test.go b/internal/intent/classify_test.go index 723a431..9b84de9 100644 --- a/internal/intent/classify_test.go +++ b/internal/intent/classify_test.go @@ -39,13 +39,17 @@ func fakeCommandContext(output string, exitErr bool) func(ctx context.Context, n } } +func fakeLookPath(string) (string, error) { return "/fake/claude", nil } + func TestClassify_Success(t *testing.T) { // Envelope wrapping actual JSON result. envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.95,\"reasoning\":\"user wants to run auth plan\"}"}` orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(envelope, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() r, err := Classify(context.Background(), "run the auth plan") if err != nil { @@ -71,8 +75,10 @@ func TestClassify_NoClaude(t *testing.T) { func TestClassify_ExitError(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext("something went wrong", true) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -82,8 +88,10 @@ func TestClassify_ExitError(t *testing.T) { func TestClassify_MalformedJSON(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(`{"result":"not json at all"}`, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -93,8 +101,10 @@ func TestClassify_MalformedJSON(t *testing.T) { func TestClassify_EmptyArgv(t *testing.T) { orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(`{"result":"{\"argv\":[],\"confidence\":0.1,\"reasoning\":\"unclear\"}"}`, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "do something") if !errors.Is(err, ErrClassificationFailed) { @@ -107,8 +117,10 @@ func TestClassify_CodeFencedJSON(t *testing.T) { fenced := "{\"result\":\"```json\\n{\\\"argv\\\":[\\\"runs\\\"],\\\"confidence\\\":0.9,\\\"reasoning\\\":\\\"list runs\\\"}\\n```\"}" orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(fenced, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() r, err := Classify(context.Background(), "show my runs") if err != nil { @@ -134,8 +146,10 @@ func TestClassify_LowConfidence(t *testing.T) { envelope := `{"result":"{\"argv\":[\"run\",\"plans/auth.md\"],\"confidence\":0.2,\"reasoning\":\"not sure\"}"}` orig := CommandContext + origLP := LookPath CommandContext = fakeCommandContext(envelope, false) - defer func() { CommandContext = orig }() + LookPath = fakeLookPath + defer func() { CommandContext = orig; LookPath = origLP }() _, err := Classify(context.Background(), "maybe run something") if !errors.Is(err, ErrClassificationFailed) { diff --git a/internal/intent/prompt_test.go b/internal/intent/prompt_test.go index 13b7524..75e5d91 100644 --- a/internal/intent/prompt_test.go +++ b/internal/intent/prompt_test.go @@ -18,9 +18,11 @@ func TestBuildPrompt_ContainsSubcommands(t *testing.T) { dc := DynamicContext{} prompt := BuildPrompt("anything", dc) - subcommands := []string{"forge run", "forge push", "forge resume", "forge runs", + subcommands := []string{ + "forge run", "forge push", "forge resume", "forge runs", "forge status", "forge logs", "forge steps", "forge edit", - "forge cleanup", "forge init", "forge completion", "forge serve", "forge version"} + "forge cleanup", "forge init", "forge completion", "forge serve", "forge version", + } for _, sub := range subcommands { if !strings.Contains(prompt, sub) { diff --git a/internal/pipeline/.forge/runs/20260217-120000-test.yaml b/internal/pipeline/.forge/runs/20260217-120000-test.yaml index 8978142..cae8426 100644 --- a/internal/pipeline/.forge/runs/20260217-120000-test.yaml +++ b/internal/pipeline/.forge/runs/20260217-120000-test.yaml @@ -1,8 +1,8 @@ id: 20260217-120000-test -plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent3223185930/001/auth.md +plan_path: /var/folders/tx/bm28bkzx7vj48wg0z_ls65gw0000gn/T/TestRun_LocalCR_SeparateReviewAgent1795440872/001/auth.md status: completed -created_at: 2026-02-28T17:24:37.746616+02:00 -updated_at: 2026-02-28T17:24:37.749882+02:00 +created_at: 2026-02-28T20:04:38.362657+02:00 +updated_at: 2026-02-28T20:04:38.366259+02:00 branch: forge/auth worktree_path: /tmp/wt pr_url: https://github.com/owner/repo/pull/1 diff --git a/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml b/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml index 0eaf999..caa51b6 100644 --- a/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml +++ b/internal/pipeline/.forge/runs/20260219-120000-push-test.yaml @@ -2,8 +2,8 @@ id: 20260219-120000-push-test plan_path: "" mode: push status: completed -created_at: 2026-02-28T17:24:37.49641+02:00 -updated_at: 2026-02-28T17:24:37.500963+02:00 +created_at: 2026-02-28T20:04:38.105526+02:00 +updated_at: 2026-02-28T20:04:38.110421+02:00 branch: forge/my-feature worktree_path: /tmp/repo pr_url: url diff --git a/internal/pipeline/batch.go b/internal/pipeline/batch.go index 55b51dc..9888690 100644 --- a/internal/pipeline/batch.go +++ b/internal/pipeline/batch.go @@ -19,8 +19,8 @@ import ( // sorts them by dependency, and executes each in order. Sequential within each // level for V1. func RunBatch(ctx context.Context, cfg *config.Config, providers Providers, - label string, dryRun bool, logger *slog.Logger) error { - + label string, dryRun bool, logger *slog.Logger, +) error { issues, err := providers.VCS.ListIssues(ctx, "open", label) if err != nil { return fmt.Errorf("listing issues: %w", err) @@ -154,8 +154,8 @@ func reportFailure(ctx context.Context, providers Providers, num int, err error, // runSingleIssue executes a single GitHub issue through the forge pipeline. func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers, - number int, title, body string, logger *slog.Logger) error { - + number int, title, body string, logger *slog.Logger, +) error { slug := SlugFromTitle(title) runID := time.Now().Format("20060102-150405") + "-" + slug @@ -184,7 +184,8 @@ func runSingleIssue(ctx context.Context, cfg *config.Config, providers Providers // Handles transitive deps. Fetch errors are logged and treated as external deps (skipped). func expandDeps(ctx context.Context, vcs interface { GetIssue(ctx context.Context, number int) (*provider.GitHubIssue, error) -}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger) error { +}, issueSet map[int]bool, titleMap map[int]string, bodyMap map[int]string, logger *slog.Logger, +) error { external := make(map[int]bool) // deps we tried to fetch and failed — treat as external for { var missing []int diff --git a/internal/pipeline/pool_test.go b/internal/pipeline/pool_test.go index 7dffb17..9986877 100644 --- a/internal/pipeline/pool_test.go +++ b/internal/pipeline/pool_test.go @@ -3,11 +3,10 @@ package pipeline import ( "context" "errors" + "log/slog" "os" "testing" - "log/slog" - "github.com/shahar-caura/forge/internal/provider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go index a4d03bf..475c73c 100644 --- a/internal/pipeline/run.go +++ b/internal/pipeline/run.go @@ -518,8 +518,10 @@ func agentResultText(output string) string { return parsed.Result } -var crReviewMarker = "---CRREVIEW---" -var crSummaryMarker = "---CRSUMMARY---" +var ( + crReviewMarker = "---CRREVIEW---" + crSummaryMarker = "---CRSUMMARY---" +) // extractCRSummary extracts the text between ---CRSUMMARY--- markers from agent output. // Returns empty string if markers are missing or content is empty. @@ -724,8 +726,10 @@ func openAgentLog(runID string, step int, a provider.Agent, logger *slog.Logger) } } -var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`) -var validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`) +var ( + nonAlphanumeric = regexp.MustCompile(`[^a-z0-9-]+`) + validBranch = regexp.MustCompile(`^[A-Z]+-[0-9]+(-[a-z0-9]+)+$`) +) // SlugFromTitle converts a title string to a kebab-case slug. func SlugFromTitle(title string) string { diff --git a/internal/provider/agent/claude.go b/internal/provider/agent/claude.go index 529b68b..9359c19 100644 --- a/internal/provider/agent/claude.go +++ b/internal/provider/agent/claude.go @@ -49,7 +49,8 @@ func (c *Claude) Run(ctx context.Context, dir, prompt string) (string, error) { c.Logger.Info("running agent", "dir", dir, "timeout", c.Timeout) - args := []string{"-p", prompt, + args := []string{ + "-p", prompt, "--allowedTools", "Edit,Read,Write,Bash", "--output-format", "json", } diff --git a/internal/provider/agent/codex.go b/internal/provider/agent/codex.go index 17f30ac..caa511b 100644 --- a/internal/provider/agent/codex.go +++ b/internal/provider/agent/codex.go @@ -46,7 +46,8 @@ func (c *Codex) Run(ctx context.Context, dir, prompt string) (string, error) { c.Logger.Info("running codex agent", "dir", dir, "timeout", c.Timeout) - args := []string{"exec", + args := []string{ + "exec", "--full-auto", "--json", "--cd", dir, diff --git a/internal/provider/vcs/github.go b/internal/provider/vcs/github.go index 341a7ab..88a0dcc 100644 --- a/internal/provider/vcs/github.go +++ b/internal/provider/vcs/github.go @@ -199,7 +199,8 @@ func (g *GitHub) GetIssue(ctx context.Context, number int) (*provider.GitHubIssu func (g *GitHub) ListIssues(ctx context.Context, state string, label string) ([]provider.GitHubIssue, error) { g.Logger.Info("listing issues", "state", state, "label", label) - args := []string{"issue", "list", + args := []string{ + "issue", "list", "--repo", g.Repo, "--state", state, "--json", "number,title,body,url", diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..5162034 --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,193 @@ +package scanner + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/shahar-caura/forge/internal/state" +) + +const defaultRunsDir = ".forge/runs" + +var stateLoadMu sync.Mutex + +// RepoRuns contains all run states discovered for a repository. +type RepoRuns struct { + RepoPath string + RepoName string + Runs []state.RunState +} + +// ScanRepos walks root directories and returns run states for each discovered repo. +func ScanRepos(roots []string) ([]RepoRuns, error) { + repos := make(map[string]RepoRuns) + + for _, root := range roots { + if strings.TrimSpace(root) == "" { + continue + } + + resolvedRoot, err := resolveRoot(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrPermission) { + continue + } + return nil, fmt.Errorf("resolve root %q: %w", root, err) + } + + err = filepath.WalkDir(resolvedRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if errors.Is(walkErr, fs.ErrPermission) { + return filepath.SkipDir + } + return nil + } + + if d.Type()&os.ModeSymlink != 0 { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if !d.IsDir() { + return nil + } + + if path != resolvedRoot && isHiddenDir(d.Name()) { + return filepath.SkipDir + } + + if d.Name() == "runs" && filepath.Base(filepath.Dir(path)) == ".forge" { + repoPath := filepath.Clean(filepath.Dir(filepath.Dir(path))) + if _, exists := repos[repoPath]; exists { + return filepath.SkipDir + } + + runs, err := loadRuns(path) + if err != nil { + if errors.Is(err, fs.ErrPermission) { + return filepath.SkipDir + } + return nil + } + + repos[repoPath] = RepoRuns{ + RepoPath: repoPath, + RepoName: filepath.Base(repoPath), + Runs: runs, + } + return filepath.SkipDir + } + + return nil + }) + if err != nil { + if errors.Is(err, fs.ErrPermission) { + continue + } + return nil, fmt.Errorf("walk root %q: %w", resolvedRoot, err) + } + } + + out := make([]RepoRuns, 0, len(repos)) + for _, repo := range repos { + out = append(out, repo) + } + sortRepoRuns(out) + return out, nil +} + +func sortRepoRuns(repos []RepoRuns) { + sort.Slice(repos, func(i, j int) bool { + return repos[i].RepoPath < repos[j].RepoPath + }) +} + +func loadRuns(runsDir string) ([]state.RunState, error) { + entries, err := os.ReadDir(runsDir) + if err != nil { + return nil, err + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + runs := make([]state.RunState, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if filepath.Ext(name) != ".yaml" { + continue + } + + id := strings.TrimSuffix(name, ".yaml") + rs, err := loadRun(runsDir, id) + if err != nil { + continue + } + runs = append(runs, *rs) + } + + return runs, nil +} + +func loadRun(runsDir, id string) (*state.RunState, error) { + stateLoadMu.Lock() + defer stateLoadMu.Unlock() + + state.SetRunsDir(runsDir) + defer state.SetRunsDir(defaultRunsDir) + + return state.Load(id) +} + +func resolveRoot(root string) (string, error) { + path := root + if strings.HasPrefix(path, "~/") || path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + path = home + } else { + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + + resolved, err := filepath.EvalSymlinks(abs) + if err == nil { + abs = resolved + } else if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, fs.ErrPermission) { + return "", err + } + + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("not a directory: %s", abs) + } + return abs, nil +} + +func isHiddenDir(name string) bool { + return strings.HasPrefix(name, ".") && name != ".forge" +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..9fac1c7 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,136 @@ +package scanner + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/shahar-caura/forge/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScanRepos_DiscoversNestedRepoRuns(t *testing.T) { + root := t.TempDir() + + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "nested", "repo-b") + + createRunFile(t, repoA, "run-a") + createRunFile(t, repoB, "run-b") + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 2) + + repoAResolved := mustResolvePath(t, repoA) + repoBResolved := mustResolvePath(t, repoB) + + byPath := make(map[string]RepoRuns) + for _, repo := range repos { + byPath[repo.RepoPath] = repo + } + + repoARuns, ok := byPath[repoAResolved] + require.True(t, ok) + assert.Equal(t, "repo-a", repoARuns.RepoName) + require.Len(t, repoARuns.Runs, 1) + assert.Equal(t, "run-a", repoARuns.Runs[0].ID) + + repoBRuns, ok := byPath[repoBResolved] + require.True(t, ok) + assert.Equal(t, "repo-b", repoBRuns.RepoName) + require.Len(t, repoBRuns.Runs, 1) + assert.Equal(t, "run-b", repoBRuns.Runs[0].ID) +} + +func TestScanRepos_EmptyRunsAndHiddenDirectories(t *testing.T) { + root := t.TempDir() + + emptyRepo := filepath.Join(root, "repo-empty") + require.NoError(t, os.MkdirAll(filepath.Join(emptyRepo, ".forge", "runs"), 0o755)) + + hiddenRepo := filepath.Join(root, ".hidden", "repo-hidden") + createRunFile(t, hiddenRepo, "hidden-run") + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + + assert.Equal(t, mustResolvePath(t, emptyRepo), repos[0].RepoPath) + assert.Equal(t, "repo-empty", repos[0].RepoName) + assert.Empty(t, repos[0].Runs) +} + +func TestScanRepos_SkipsSymlinkedDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test is platform-specific") + } + + root := t.TempDir() + actualRepo := filepath.Join(root, "actual-repo") + createRunFile(t, actualRepo, "run-actual") + + linkPath := filepath.Join(root, "repo-link") + require.NoError(t, os.Symlink(actualRepo, linkPath)) + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, mustResolvePath(t, actualRepo), repos[0].RepoPath) +} + +func TestScanRepos_SkipsUnreadableDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod permission semantics differ on windows") + } + + root := t.TempDir() + goodRepo := filepath.Join(root, "good-repo") + createRunFile(t, goodRepo, "good-run") + + blockedDir := filepath.Join(root, "blocked") + require.NoError(t, os.MkdirAll(filepath.Join(blockedDir, "child"), 0o755)) + require.NoError(t, os.Chmod(blockedDir, 0o000)) + defer func() { + _ = os.Chmod(blockedDir, 0o755) + }() + + repos, err := ScanRepos([]string{root}) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, mustResolvePath(t, goodRepo), repos[0].RepoPath) + require.Len(t, repos[0].Runs, 1) + assert.Equal(t, "good-run", repos[0].Runs[0].ID) +} + +func createRunFile(t *testing.T, repoPath, id string) { + t.Helper() + + runsDir := filepath.Join(repoPath, ".forge", "runs") + require.NoError(t, os.MkdirAll(runsDir, 0o755)) + + rs := state.New(id, "plans/test.md") + + cleanup := setRunsDirForTest(t, runsDir) + defer cleanup() + require.NoError(t, rs.Save()) +} + +func setRunsDirForTest(t *testing.T, dir string) func() { + t.Helper() + state.SetRunsDir(dir) + return func() { state.SetRunsDir(".forge/runs") } +} + +func mustResolvePath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved + } + abs, absErr := filepath.Abs(path) + require.NoError(t, absErr) + return abs +} diff --git a/lefthook.yml b/lefthook.yml index 1bb1404..e7ef4b8 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,6 +10,9 @@ pre-commit: lint: glob: "*.go" run: golangci-lint run --new-from-rev=HEAD {staged_files} + test: + glob: "*.go" + run: go test -race ./... pre-push: commands: