├── .gitignore
├── __test__
└── workspace
│ ├── go.work
│ ├── hello
│ ├── go.mod
│ └── lib
│ │ └── main.go
│ └── world
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── lib
├── resolver
│ ├── graph.go
│ └── graph_test.go
├── runner
│ ├── __playground__
│ │ ├── a
│ │ │ └── hello.txt
│ │ └── b
│ │ │ └── world.txt
│ ├── task.go
│ ├── runner_test.go
│ └── runner.go
├── analyser
│ ├── __playground__
│ │ └── workspace
│ │ │ ├── world
│ │ │ ├── go.sum
│ │ │ ├── go.mod
│ │ │ ├── lib
│ │ │ │ └── ok.go
│ │ │ └── cmd
│ │ │ │ └── main.go
│ │ │ ├── hello
│ │ │ ├── go.mod
│ │ │ ├── main.go
│ │ │ └── go.sum
│ │ │ └── go.work
│ ├── primitive.go
│ ├── parse_test.go
│ └── parse.go
├── language
│ └── main.go
├── utils
│ └── log.go
└── git
│ └── main.go
├── logo.png
├── e2e
├── testdata
│ └── workspace
│ │ ├── api
│ │ ├── go.mod
│ │ ├── api.go
│ │ └── api_test.go
│ │ ├── app
│ │ ├── go.mod
│ │ ├── main_test.go
│ │ └── main.go
│ │ ├── core
│ │ ├── go.mod
│ │ ├── core.go
│ │ └── core_test.go
│ │ ├── utils
│ │ ├── go.mod
│ │ ├── utils.go
│ │ └── utils_test.go
│ │ └── go.work
└── e2e_test.go
├── .idea
├── vcs.xml
├── .gitignore
├── modules.xml
└── monogo.iml
├── go.mod
├── go.sum
├── readme.md
└── main.go
/.gitignore:
--------------------------------------------------------------------------------
1 | knit
2 |
--------------------------------------------------------------------------------
/__test__/workspace/go.work:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/__test__/workspace/hello/go.mod:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/__test__/workspace/world/go.mod:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/__test__/workspace/world/go.sum:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/__test__/workspace/world/main.go:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/__test__/workspace/hello/lib/main.go:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/lib/resolver/graph.go:
--------------------------------------------------------------------------------
1 | package resolver
2 |
--------------------------------------------------------------------------------
/lib/runner/__playground__/a/hello.txt:
--------------------------------------------------------------------------------
1 | hello
2 |
--------------------------------------------------------------------------------
/lib/runner/__playground__/b/world.txt:
--------------------------------------------------------------------------------
1 | world
2 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/world/go.sum:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicolasgere/knit/HEAD/logo.png
--------------------------------------------------------------------------------
/lib/language/main.go:
--------------------------------------------------------------------------------
1 | package lg
2 |
3 | type Language interface {
4 | }
5 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/api/go.mod:
--------------------------------------------------------------------------------
1 | module example.com/api
2 |
3 | go 1.22.4
4 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/app/go.mod:
--------------------------------------------------------------------------------
1 | module example.com/app
2 |
3 | go 1.22.4
4 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/core/go.mod:
--------------------------------------------------------------------------------
1 | module example.com/core
2 |
3 | go 1.22.4
4 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/utils/go.mod:
--------------------------------------------------------------------------------
1 | module example.com/utils
2 |
3 | go 1.22.4
4 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/world/go.mod:
--------------------------------------------------------------------------------
1 | module world
2 |
3 | go 1.22.4
4 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/hello/go.mod:
--------------------------------------------------------------------------------
1 | module hello
2 |
3 | go 1.22.4
4 |
5 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/go.work:
--------------------------------------------------------------------------------
1 | go 1.22.4
2 |
3 | use ./hello
4 |
5 | use ./world
6 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/go.work:
--------------------------------------------------------------------------------
1 | go 1.22.4
2 |
3 | use (
4 | ./core
5 | ./utils
6 | ./api
7 | ./app
8 | )
9 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/hello/main.go:
--------------------------------------------------------------------------------
1 | package hello
2 |
3 | func Hello() string {
4 | return "Hello"
5 | }
6 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/world/lib/ok.go:
--------------------------------------------------------------------------------
1 | package lib
2 |
3 | import "hello"
4 |
5 | func Toto() string {
6 | return hello.Hello()
7 | }
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/world/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | t "world/lib"
7 | )
8 |
9 | func main() {
10 | fmt.Println(t.Toto())
11 | }
12 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/lib/analyser/__playground__/workspace/hello/go.sum:
--------------------------------------------------------------------------------
1 | golang.org/x/example/hello v0.0.0-20240205180059-32022caedd6a h1:lnHwduM5X5Fb0YnIPyjyGzRY88cc0Fo6xTgaWDX2c7Q=
2 | golang.org/x/example/hello v0.0.0-20240205180059-32022caedd6a/go.mod h1:UhUKOXx5fMcLZxwL20DUrWWBBoRYG9Jvc8FiwZhRHCI=
3 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/app/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "testing"
4 |
5 | func TestGetAppInfo(t *testing.T) {
6 | info := GetAppInfo()
7 | if info == "" {
8 | t.Error("app info should not be empty")
9 | }
10 | if len(info) < 5 {
11 | t.Error("app info seems too short")
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.idea/monogo.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/lib/runner/task.go:
--------------------------------------------------------------------------------
1 | package runner
2 |
3 | type Task struct {
4 | Id string
5 | Name string
6 | Root string
7 | Cmd string
8 | Args []string
9 | }
10 |
11 | type TaskFuture struct {
12 | Stdout chan []byte
13 | Stderr chan []byte
14 | Done chan TaskResult
15 | Id string
16 | }
17 |
18 | type TaskResult struct {
19 | Err error
20 | Status int
21 | }
22 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nicolasgere/knit
2 |
3 | go 1.22.4
4 |
5 | require (
6 | github.com/dominikbraun/graph v0.23.0
7 | github.com/urfave/cli/v2 v2.27.2
8 | )
9 |
10 | require (
11 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
12 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
13 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
14 | )
15 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/core/core.go:
--------------------------------------------------------------------------------
1 | // Package core provides fundamental types and functions.
2 | package core
3 |
4 | // Version returns the current version of the core module.
5 | func Version() string {
6 | return "1.0.0"
7 | }
8 |
9 | // Config holds application configuration.
10 | type Config struct {
11 | Name string
12 | Debug bool
13 | MaxSize int
14 | }
15 |
16 | // DefaultConfig returns a default configuration.
17 | func DefaultConfig() Config {
18 | return Config{
19 | Name: "default",
20 | Debug: false,
21 | MaxSize: 100,
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/core/core_test.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import "testing"
4 |
5 | func TestVersion(t *testing.T) {
6 | v := Version()
7 | if v == "" {
8 | t.Error("version should not be empty")
9 | }
10 | }
11 |
12 | func TestDefaultConfig(t *testing.T) {
13 | cfg := DefaultConfig()
14 | if cfg.Name != "default" {
15 | t.Errorf("expected name 'default', got %q", cfg.Name)
16 | }
17 | if cfg.Debug {
18 | t.Error("debug should be false by default")
19 | }
20 | if cfg.MaxSize != 100 {
21 | t.Errorf("expected max size 100, got %d", cfg.MaxSize)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/lib/resolver/graph_test.go:
--------------------------------------------------------------------------------
1 | package resolver
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/dominikbraun/graph"
8 | )
9 |
10 | func TestG(t *testing.T) {
11 | g := graph.New(graph.StringHash, graph.Directed(), graph.Acyclic())
12 | _ = g.AddVertex("A")
13 | _ = g.AddVertex("B")
14 | _ = g.AddVertex("C")
15 | _ = g.AddVertex("D")
16 | _ = g.AddEdge("A", "B")
17 | _ = g.AddEdge("B", "C")
18 | err := g.AddEdge("C", "A")
19 | fmt.Println(err)
20 | _ = graph.DFS(g, "A", func(value string) bool {
21 | fmt.Println(value)
22 | return false
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/lib/analyser/primitive.go:
--------------------------------------------------------------------------------
1 | package analyzer
2 |
3 | // Module represents a Go module from `go list -m -json`
4 | type Module struct {
5 | Path string `json:"Path"`
6 | Main bool `json:"Main"`
7 | Dir string `json:"Dir"`
8 | GoMod string `json:"GoMod"`
9 | GoVersion string `json:"GoVersion"`
10 | }
11 |
12 | // Package represents a Go package from `go list -json ./...`
13 | type Package struct {
14 | Dir string `json:"Dir"`
15 | ImportPath string `json:"ImportPath"`
16 | Name string `json:"Name"`
17 | Module *Module `json:"Module"`
18 | Imports []string `json:"Imports"`
19 | }
20 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/app/main.go:
--------------------------------------------------------------------------------
1 | // Package main is the application entry point.
2 | package main
3 |
4 | import (
5 | "fmt"
6 | "os"
7 |
8 | "example.com/api"
9 | "example.com/core"
10 | )
11 |
12 | func main() {
13 | fmt.Println("App starting...")
14 | fmt.Println("Core version:", core.Version())
15 |
16 | resp := api.HealthCheck()
17 | if resp.Success {
18 | fmt.Println("Health check passed!")
19 | } else {
20 | fmt.Println("Health check failed!")
21 | os.Exit(1)
22 | }
23 | }
24 |
25 | // GetAppInfo returns application information.
26 | func GetAppInfo() string {
27 | return fmt.Sprintf("App v%s", core.Version())
28 | }
29 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/utils/utils.go:
--------------------------------------------------------------------------------
1 | // Package utils provides utility functions that build on core.
2 | package utils
3 |
4 | import "example.com/core"
5 |
6 | // GetVersionInfo returns formatted version information.
7 | func GetVersionInfo() string {
8 | return "Version: " + core.Version()
9 | }
10 |
11 | // ConfigWithDefaults returns a config with some customizations.
12 | func ConfigWithDefaults(name string) core.Config {
13 | cfg := core.DefaultConfig()
14 | cfg.Name = name
15 | return cfg
16 | }
17 |
18 | // StringSliceContains checks if a slice contains a string.
19 | func StringSliceContains(slice []string, item string) bool {
20 | for _, s := range slice {
21 | if s == item {
22 | return true
23 | }
24 | }
25 | return false
26 | }
27 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "testing"
4 |
5 | func TestGetVersionInfo(t *testing.T) {
6 | info := GetVersionInfo()
7 | if info == "" {
8 | t.Error("version info should not be empty")
9 | }
10 | if len(info) < 10 {
11 | t.Error("version info seems too short")
12 | }
13 | }
14 |
15 | func TestConfigWithDefaults(t *testing.T) {
16 | cfg := ConfigWithDefaults("myapp")
17 | if cfg.Name != "myapp" {
18 | t.Errorf("expected name 'myapp', got %q", cfg.Name)
19 | }
20 | }
21 |
22 | func TestStringSliceContains(t *testing.T) {
23 | slice := []string{"a", "b", "c"}
24 |
25 | if !StringSliceContains(slice, "b") {
26 | t.Error("should contain 'b'")
27 | }
28 | if StringSliceContains(slice, "x") {
29 | t.Error("should not contain 'x'")
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
2 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
3 | github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
4 | github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
5 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
6 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
7 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
8 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
9 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
10 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
11 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/api/api.go:
--------------------------------------------------------------------------------
1 | // Package api provides HTTP API handlers.
2 | package api
3 |
4 | import (
5 | "encoding/json"
6 |
7 | "example.com/core"
8 | "example.com/utils"
9 | )
10 |
11 | // Response represents an API response.
12 | type Response struct {
13 | Success bool `json:"success"`
14 | Data interface{} `json:"data,omitempty"`
15 | Error string `json:"error,omitempty"`
16 | }
17 |
18 | // VersionResponse returns version information as JSON.
19 | func VersionResponse() ([]byte, error) {
20 | resp := Response{
21 | Success: true,
22 | Data: map[string]string{
23 | "version": utils.GetVersionInfo(),
24 | },
25 | }
26 | return json.Marshal(resp)
27 | }
28 |
29 | // ConfigResponse returns config information as JSON.
30 | func ConfigResponse(name string) ([]byte, error) {
31 | cfg := utils.ConfigWithDefaults(name)
32 | resp := Response{
33 | Success: true,
34 | Data: cfg,
35 | }
36 | return json.Marshal(resp)
37 | }
38 |
39 | // HealthCheck returns a health check response.
40 | func HealthCheck() Response {
41 | return Response{
42 | Success: true,
43 | Data: map[string]string{
44 | "status": "healthy",
45 | "version": core.Version(),
46 | },
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/e2e/testdata/workspace/api/api_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 | )
7 |
8 | func TestVersionResponse(t *testing.T) {
9 | data, err := VersionResponse()
10 | if err != nil {
11 | t.Fatalf("unexpected error: %v", err)
12 | }
13 |
14 | var resp Response
15 | if err := json.Unmarshal(data, &resp); err != nil {
16 | t.Fatalf("failed to unmarshal: %v", err)
17 | }
18 |
19 | if !resp.Success {
20 | t.Error("expected success to be true")
21 | }
22 | }
23 |
24 | func TestConfigResponse(t *testing.T) {
25 | data, err := ConfigResponse("testapp")
26 | if err != nil {
27 | t.Fatalf("unexpected error: %v", err)
28 | }
29 |
30 | var resp Response
31 | if err := json.Unmarshal(data, &resp); err != nil {
32 | t.Fatalf("failed to unmarshal: %v", err)
33 | }
34 |
35 | if !resp.Success {
36 | t.Error("expected success to be true")
37 | }
38 | }
39 |
40 | func TestHealthCheck(t *testing.T) {
41 | resp := HealthCheck()
42 | if !resp.Success {
43 | t.Error("expected success to be true")
44 | }
45 |
46 | data, ok := resp.Data.(map[string]string)
47 | if !ok {
48 | t.Fatal("expected Data to be map[string]string")
49 | }
50 |
51 | if data["status"] != "healthy" {
52 | t.Errorf("expected status 'healthy', got %q", data["status"])
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/lib/analyser/parse_test.go:
--------------------------------------------------------------------------------
1 | package analyzer
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | func TestListModules(t *testing.T) {
9 | modules, err := ListModule("./__playground__/workspace/")
10 | if err != nil {
11 | t.Error(err)
12 | }
13 | if len(modules) != 2 {
14 | t.Errorf("Expected 2 modules, got %d", len(modules))
15 | }
16 | for _, m := range modules {
17 | fmt.Printf("Module: %s at %s\n", m.Path, m.Dir)
18 | }
19 | }
20 |
21 | func TestListPackages(t *testing.T) {
22 | workspaceRoot := "./__playground__/workspace/"
23 | modules, err := ListModule(workspaceRoot)
24 | if err != nil {
25 | t.Fatal(err)
26 | }
27 |
28 | packages, err := ListPackages(workspaceRoot, modules)
29 | if err != nil {
30 | t.Error(err)
31 | }
32 | if len(packages) == 0 {
33 | t.Error("Expected at least one package")
34 | }
35 | for _, p := range packages {
36 | modPath := ""
37 | if p.Module != nil {
38 | modPath = p.Module.Path
39 | }
40 | fmt.Printf("Package: %s (module: %s) imports: %v\n", p.ImportPath, modPath, p.Imports)
41 | }
42 | }
43 |
44 | func TestBuildDependencyGraph(t *testing.T) {
45 | modules, err := ListModule("./__playground__/workspace/")
46 | if err != nil {
47 | t.Fatal(err)
48 | }
49 |
50 | graph, err := BuildDependencyGraph(modules)
51 | if err != nil {
52 | t.Fatal(err)
53 | }
54 |
55 | // Print the graph edges
56 | adjacencyMap, err := (*graph).AdjacencyMap()
57 | if err != nil {
58 | t.Fatal(err)
59 | }
60 |
61 | fmt.Println("Dependency Graph:")
62 | for src, edges := range adjacencyMap {
63 | for dst := range edges {
64 | fmt.Printf(" %s -> %s\n", src, dst)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/lib/runner/runner_test.go:
--------------------------------------------------------------------------------
1 | package runner
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 | "testing"
7 | )
8 |
9 | // func TestRunner(t *testing.T) {
10 | // r := Runner{}
11 |
12 | // task := Task{
13 | // Cmd: "cat hello.txt",
14 | // Root: "./__playground__/a/",
15 | // }
16 | // fmt.Println("START")
17 | // fmt.Println("running task")
18 | // tf := r.RunTask(task)
19 |
20 | // for {
21 | // select {
22 | // case stdout, ok := <-tf.Stdout:
23 | // if !ok {
24 | // tf.Stdout = nil
25 | // }
26 | // fmt.Println("stdout", string(stdout))
27 | // break
28 | // case stderr, ok := <-tf.Stderr:
29 | // if !ok {
30 | // tf.Stderr = nil
31 | // }
32 | // fmt.Println("stderr", string(stderr))
33 | // break
34 | // case result := <-tf.Done:
35 | // fmt.Println("done")
36 | // fmt.Println(result.Status)
37 | // return
38 | // }
39 | // }
40 | // }
41 |
42 | func TestRunnerMultiple(t *testing.T) {
43 | r := Runner{}
44 | tasks := []Task{
45 | {
46 | Id: "a",
47 | Cmd: "cat hello.txt",
48 | Root: "./__playground__/a/",
49 | },
50 | {
51 | Id: "b",
52 | Cmd: "cat world.txt",
53 | Root: "./__playground__/b/",
54 | },
55 | }
56 | tfs := r.RunTasks(tasks)
57 | var wg sync.WaitGroup
58 | for _, tf := range tfs {
59 | wg.Add(1)
60 | go func(tf *TaskFuture) {
61 | for {
62 | select {
63 | case stdout, ok := <-tf.Stdout:
64 | if !ok {
65 | tf.Stdout = nil
66 | }
67 | if len(stdout) == 0 {
68 | break
69 | }
70 | fmt.Println("stdout", tf.Id, string(stdout))
71 | break
72 | case stderr, ok := <-tf.Stderr:
73 | if !ok {
74 | tf.Stderr = nil
75 | }
76 | if len(stderr) == 0 {
77 | break
78 | }
79 | fmt.Println("stderr", tf.Id, string(stderr))
80 | break
81 | case result := <-tf.Done:
82 | fmt.Println("done", tf.Id)
83 | fmt.Println(result.Status)
84 | wg.Done()
85 | break
86 | }
87 | }
88 | }(tf)
89 | }
90 | wg.Wait()
91 | }
92 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
Knit
4 |
Zero-config tool for Go workspace monorepos
5 |
6 |
7 | ## Why Knit?
8 |
9 | Built for Go monorepos using `go.work`. Run commands only on affected modules.
10 |
11 | **Use cases:**
12 | - **CI**: Test only what changed in a PR
13 | - **Pre-commit**: Format only modified modules
14 | - **Local dev**: Run commands on specific modules
15 |
16 | ## Install
17 |
18 | ```sh
19 | go install github.com/nicolasgere/knit@latest
20 | ```
21 |
22 | ## Commands
23 |
24 | ```sh
25 | knit test # Run tests on all modules
26 | knit fmt # Format all modules
27 | knit affected # List changed modules
28 | knit graph # Show dependency graph
29 | ```
30 |
31 | ### Options
32 |
33 | ```sh
34 | -p, --path Workspace root
35 | -t, --target Specific module
36 | -a, --affected Run on affected modules only
37 | -b, --base Git ref to compare (with --affected)
38 | -c, --color Colored output
39 | ```
40 |
41 | ## Examples
42 |
43 | ```sh
44 | # Run all tests with color
45 | knit test --color
46 |
47 | # Run tests on affected modules (CI)
48 | knit test --affected
49 |
50 | # Run tests on affected modules (compare against develop)
51 | knit test --affected --base develop
52 |
53 | # Format affected modules
54 | knit fmt --affected
55 |
56 | # Test specific module
57 | knit test -t example.com/api
58 |
59 | # Get list of affected modules
60 | knit affected --merge-base
61 |
62 | # Visualize dependencies
63 | knit graph -f dot | dot -Tpng -o deps.png
64 | ```
65 |
66 | ## CI
67 |
68 | ```yaml
69 | # Simple: test affected modules
70 | - run: knit test --affected --color
71 |
72 | # Or parallelize with GitHub matrix
73 | - id: affected
74 | run: echo "matrix=$(knit affected --merge-base -f github-matrix)" >> $GITHUB_OUTPUT
75 | - strategy:
76 | matrix: ${{ fromJson(steps.affected.outputs.matrix) }}
77 | run: knit test -t ${{ matrix.module }}
78 | ```
79 |
80 | ## Pre-commit
81 |
82 | ```yaml
83 | # .pre-commit-config.yaml
84 | repos:
85 | - repo: local
86 | hooks:
87 | - id: knit-fmt
88 | name: Format affected modules
89 | entry: knit fmt --affected --base HEAD
90 | language: system
91 | pass_filenames: false
92 | ```
93 |
--------------------------------------------------------------------------------
/lib/runner/runner.go:
--------------------------------------------------------------------------------
1 | package runner
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "io"
8 | "os/exec"
9 |
10 | "github.com/nicolasgere/knit/lib/utils"
11 | )
12 |
13 | func NewRunner(ctx context.Context, concurency int) Runner {
14 | return Runner{
15 | semaphore: make(chan struct{}, concurency),
16 | ctx: ctx,
17 | }
18 | }
19 |
20 | type Runner struct {
21 | semaphore chan struct{}
22 | ctx context.Context
23 | }
24 |
25 | func (r *Runner) ExecCommand(cmd *exec.Cmd, tf *TaskFuture, task *Task) {
26 | r.semaphore <- struct{}{}
27 | defer func() { <-r.semaphore }()
28 | utils.LogTaskStart(task.Id, task.Cmd)
29 | pipeout, err := cmd.StdoutPipe()
30 | if err != nil {
31 | tf.Done <- TaskResult{Err: err, Status: 1}
32 | }
33 | ReaderToChan(&pipeout, tf.Stdout)
34 | pipeerr, err := cmd.StderrPipe()
35 | if err != nil {
36 | tf.Done <- TaskResult{Err: err, Status: 1}
37 | }
38 | ReaderToChan(&pipeerr, tf.Stderr)
39 | if err := cmd.Start(); err != nil {
40 | tf.Done <- TaskResult{Err: err, Status: 1}
41 | }
42 | if err := cmd.Wait(); err != nil {
43 | fmt.Println("ERROR")
44 | if exiterr, ok := err.(*exec.ExitError); ok {
45 | tf.Done <- TaskResult{Err: err, Status: exiterr.ExitCode()}
46 | } else {
47 | tf.Done <- TaskResult{Err: err, Status: exiterr.ExitCode()}
48 | }
49 | }
50 | tf.Done <- TaskResult{Err: err, Status: 0}
51 | }
52 |
53 | func (r *Runner) RunTask(task Task) (tf *TaskFuture) {
54 | cmd := exec.CommandContext(r.ctx, "sh", "-c", task.Cmd)
55 | cmd.Dir = task.Root
56 | tf = &TaskFuture{
57 | Id: task.Id,
58 | Stdout: make(chan []byte),
59 | Stderr: make(chan []byte),
60 | Done: make(chan TaskResult, 1),
61 | }
62 |
63 | go r.ExecCommand(cmd, tf, &task)
64 | return
65 | }
66 |
67 | func (r *Runner) RunTasks(tasks []Task) (tf []*TaskFuture) {
68 | tf = make([]*TaskFuture, 0)
69 | for _, task := range tasks {
70 | tf = append(tf, r.RunTask(task))
71 | }
72 | return
73 | }
74 |
75 | func ReaderToChan(r *io.ReadCloser, out chan []byte) {
76 | go func() {
77 | rc := *r
78 | defer close(out)
79 | defer rc.Close()
80 | scanner := bufio.NewScanner(rc)
81 | for scanner.Scan() {
82 | t := scanner.Bytes()
83 | out <- t
84 | }
85 | if err := scanner.Err(); err != nil {
86 | // Handle error (if needed)
87 | fmt.Println("Error reading:", err)
88 | }
89 | }()
90 | }
91 |
--------------------------------------------------------------------------------
/lib/utils/log.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "hash/fnv"
6 | "sync"
7 | "time"
8 | )
9 |
10 | type LogLevel int
11 |
12 | var STARTED = time.Now()
13 |
14 | const (
15 | DEBUG LogLevel = 0
16 | INFO LogLevel = 1
17 | WARN LogLevel = 2
18 | ERROR LogLevel = 3
19 | )
20 |
21 | const LOG_LEVEL = INFO
22 |
23 | // ANSI color codes
24 | const (
25 | Reset = "\033[0m"
26 | Bold = "\033[1m"
27 | Dim = "\033[2m"
28 | Red = "\033[31m"
29 | Green = "\033[32m"
30 | Yellow = "\033[33m"
31 | Blue = "\033[34m"
32 | Magenta = "\033[35m"
33 | Cyan = "\033[36m"
34 | White = "\033[37m"
35 | )
36 |
37 | // Bright colors for task differentiation
38 | var taskColors = []string{
39 | "\033[36m", // Cyan
40 | "\033[33m", // Yellow
41 | "\033[35m", // Magenta
42 | "\033[32m", // Green
43 | "\033[34m", // Blue
44 | "\033[91m", // Bright Red
45 | "\033[92m", // Bright Green
46 | "\033[93m", // Bright Yellow
47 | "\033[94m", // Bright Blue
48 | "\033[95m", // Bright Magenta
49 | "\033[96m", // Bright Cyan
50 | }
51 |
52 | var (
53 | colorEnabled = false
54 | colorMu sync.RWMutex
55 | taskColorMap = make(map[string]string)
56 | colorIndex = 0
57 | )
58 |
59 | // SetColorEnabled enables or disables color output
60 | func SetColorEnabled(enabled bool) {
61 | colorMu.Lock()
62 | defer colorMu.Unlock()
63 | colorEnabled = enabled
64 | }
65 |
66 | // IsColorEnabled returns whether color output is enabled
67 | func IsColorEnabled() bool {
68 | colorMu.RLock()
69 | defer colorMu.RUnlock()
70 | return colorEnabled
71 | }
72 |
73 | // getColorForTask returns a consistent color for a task ID
74 | func getColorForTask(id string) string {
75 | colorMu.Lock()
76 | defer colorMu.Unlock()
77 |
78 | if color, exists := taskColorMap[id]; exists {
79 | return color
80 | }
81 |
82 | // Use hash to get consistent color for same task ID
83 | h := fnv.New32a()
84 | h.Write([]byte(id))
85 | idx := int(h.Sum32()) % len(taskColors)
86 | color := taskColors[idx]
87 | taskColorMap[id] = color
88 | return color
89 | }
90 |
91 | func LogWithTaskId(id string, msg string, level LogLevel) {
92 | Since := time.Since(STARTED)
93 | if level >= LOG_LEVEL {
94 | if IsColorEnabled() {
95 | color := getColorForTask(id)
96 | timeColor := Dim
97 | fmt.Printf("%s%.1f%s %s[%s]%s %s\n", timeColor, Since.Seconds(), Reset, color, id, Reset, msg)
98 | } else {
99 | fmt.Printf("%.1f [%s] %s\n", Since.Seconds(), id, msg)
100 | }
101 | }
102 | }
103 |
104 | // LogStatus logs a status message with appropriate color
105 | func LogStatus(id string, status string, isSuccess bool) {
106 | Since := time.Since(STARTED)
107 | if IsColorEnabled() {
108 | color := getColorForTask(id)
109 | statusColor := Green
110 | if !isSuccess {
111 | statusColor = Red
112 | }
113 | timeColor := Dim
114 | fmt.Printf("%s%.1f%s %s[%s]%s %s%s%s\n", timeColor, Since.Seconds(), Reset, color, id, Reset, statusColor, status, Reset)
115 | } else {
116 | fmt.Printf("%.1f [%s] %s\n", Since.Seconds(), id, status)
117 | }
118 | }
119 |
120 | // LogTaskStart logs when a task starts with highlighted command
121 | func LogTaskStart(id string, cmd string) {
122 | Since := time.Since(STARTED)
123 | if IsColorEnabled() {
124 | color := getColorForTask(id)
125 | timeColor := Dim
126 | fmt.Printf("%s%.1f%s %s[%s]%s %s▶ Run%s %s%s%s\n",
127 | timeColor, Since.Seconds(), Reset,
128 | color, id, Reset,
129 | Bold, Reset,
130 | Cyan, cmd, Reset)
131 | } else {
132 | fmt.Printf("%.1f [%s] Run task -> %s\n", Since.Seconds(), id, cmd)
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/lib/git/main.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 | "path/filepath"
7 | "strings"
8 | )
9 |
10 | // GetChangedFiles returns a list of files changed compared to a reference.
11 | // If useMergeBase is true, it compares against the merge-base (common ancestor),
12 | // which is useful in CI to detect changes in a PR/branch.
13 | func GetChangedFiles(compareRef string, useMergeBase bool, dir string) ([]string, error) {
14 | var cmd *exec.Cmd
15 |
16 | if useMergeBase {
17 | // Find the merge-base (common ancestor) and compare against it
18 | // This is what you want in CI for PRs
19 | mergeBase, err := getMergeBase(compareRef, dir)
20 | if err != nil {
21 | return nil, fmt.Errorf("failed to get merge-base: %w", err)
22 | }
23 | cmd = exec.Command("git", "diff", "--name-only", mergeBase)
24 | } else {
25 | // Direct comparison against the reference
26 | cmd = exec.Command("git", "diff", "--name-only", compareRef)
27 | }
28 |
29 | cmd.Dir = dir
30 | output, err := cmd.Output()
31 | if err != nil {
32 | return nil, fmt.Errorf("error executing git diff: %w", err)
33 | }
34 |
35 | // Split the output into individual file paths
36 | trimmed := strings.TrimSpace(string(output))
37 | if trimmed == "" {
38 | return []string{}, nil
39 | }
40 |
41 | return strings.Split(trimmed, "\n"), nil
42 | }
43 |
44 | // getMergeBase finds the common ancestor between HEAD and the given ref
45 | func getMergeBase(ref string, dir string) (string, error) {
46 | cmd := exec.Command("git", "merge-base", ref, "HEAD")
47 | cmd.Dir = dir
48 | output, err := cmd.Output()
49 | if err != nil {
50 | return "", fmt.Errorf("git merge-base failed: %w", err)
51 | }
52 | return strings.TrimSpace(string(output)), nil
53 | }
54 |
55 | // GetAffectedRootDirectories returns root directories that have changed files.
56 | // Deprecated: Use GetChangedFiles + FindAffectedModules instead.
57 | func GetAffectedRootDirectories(compareBranch string, dir string) ([]string, error) {
58 | changedFiles, err := GetChangedFiles(compareBranch, false, dir)
59 | if err != nil {
60 | return nil, err
61 | }
62 |
63 | // Create a map to store unique root directories
64 | affectedRootDirs := make(map[string]bool)
65 |
66 | // Extract root directories from file paths
67 | for _, file := range changedFiles {
68 | rootDir := extractRootDirectory(file)
69 | if rootDir != "" {
70 | affectedRootDirs[rootDir] = true
71 | }
72 | }
73 |
74 | // Convert map keys to slice
75 | result := make([]string, 0, len(affectedRootDirs))
76 | for dir := range affectedRootDirs {
77 | result = append(result, dir)
78 | }
79 |
80 | return result, nil
81 | }
82 |
83 | // FindAffectedModuleDirs determines which module directories contain changed files.
84 | // It takes the list of changed files and the list of module directories (absolute paths),
85 | // and returns the directories of modules that have changes.
86 | func FindAffectedModuleDirs(changedFiles []string, moduleDirs []string, workspaceRoot string) []string {
87 | // Sort module directories by length (longest first) so more specific paths match first
88 | // This prevents the root module from matching files in submodules
89 | sortedDirs := make([]string, len(moduleDirs))
90 | copy(sortedDirs, moduleDirs)
91 | sortByLengthDesc(sortedDirs)
92 |
93 | affectedDirs := make(map[string]bool)
94 |
95 | for _, file := range changedFiles {
96 | // Convert to absolute path
97 | absFile := filepath.Join(workspaceRoot, file)
98 |
99 | // Check which module this file belongs to (most specific first)
100 | for _, modDir := range sortedDirs {
101 | if strings.HasPrefix(absFile, modDir+string(filepath.Separator)) || absFile == modDir {
102 | affectedDirs[modDir] = true
103 | break
104 | }
105 | }
106 | }
107 |
108 | result := make([]string, 0, len(affectedDirs))
109 | for dir := range affectedDirs {
110 | result = append(result, dir)
111 | }
112 | return result
113 | }
114 |
115 | // sortByLengthDesc sorts strings by length in descending order
116 | func sortByLengthDesc(strs []string) {
117 | for i := 0; i < len(strs)-1; i++ {
118 | for j := i + 1; j < len(strs); j++ {
119 | if len(strs[j]) > len(strs[i]) {
120 | strs[i], strs[j] = strs[j], strs[i]
121 | }
122 | }
123 | }
124 | }
125 |
126 | func extractRootDirectory(filePath string) string {
127 | parts := strings.SplitN(filePath, string(filepath.Separator), 2)
128 | if len(parts) > 0 {
129 | return parts[0]
130 | }
131 | return ""
132 | }
133 |
--------------------------------------------------------------------------------
/lib/analyser/parse.go:
--------------------------------------------------------------------------------
1 | package analyzer
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "strings"
10 |
11 | "github.com/dominikbraun/graph"
12 | )
13 |
14 | // ListModule discovers all modules in a Go workspace using `go list -m -json`
15 | func ListModule(dir string) (modules []Module, err error) {
16 | output, err := runCommand(dir, "go list -m -json")
17 | if err != nil {
18 | return
19 | }
20 |
21 | d := json.NewDecoder(strings.NewReader(output))
22 | for d.More() {
23 | var m Module
24 | if err = d.Decode(&m); err != nil {
25 | return
26 | }
27 | modules = append(modules, m)
28 | }
29 | return
30 | }
31 |
32 | // ListPackages lists all packages in the workspace using `go list -json`
33 | // For workspaces, it queries each module directory explicitly
34 | func ListPackages(workspaceRoot string, modules []Module) (packages []Package, err error) {
35 | if len(modules) == 0 {
36 | return nil, nil
37 | }
38 |
39 | // Ensure workspaceRoot is absolute for consistent path handling
40 | absWorkspaceRoot, err := filepath.Abs(workspaceRoot)
41 | if err != nil {
42 | absWorkspaceRoot = workspaceRoot
43 | }
44 |
45 | // Build the list of module paths to query (relative to workspace root)
46 | var patterns []string
47 | for _, m := range modules {
48 | // Get relative path from workspace root to module
49 | relPath, err := filepath.Rel(absWorkspaceRoot, m.Dir)
50 | if err != nil {
51 | // Fallback: use last component of the path
52 | relPath = filepath.Base(m.Dir)
53 | }
54 | patterns = append(patterns, "./"+relPath+"/...")
55 | }
56 |
57 | // Query all modules in a single go list command
58 | cmd := "go list -json " + strings.Join(patterns, " ")
59 | output, err := runCommand(absWorkspaceRoot, cmd)
60 | if err != nil {
61 | return nil, err
62 | }
63 |
64 | d := json.NewDecoder(strings.NewReader(output))
65 | for d.More() {
66 | var p Package
67 | if err = d.Decode(&p); err != nil {
68 | return nil, err
69 | }
70 | packages = append(packages, p)
71 | }
72 | return packages, nil
73 | }
74 |
75 | // BuildDependencyGraph builds a directed acyclic graph of module dependencies
76 | // by analyzing package imports across the workspace
77 | func BuildDependencyGraph(modules []Module) (*graph.Graph[string, string], error) {
78 | g := graph.New(graph.StringHash, graph.Directed(), graph.Acyclic())
79 |
80 | // Build a set of workspace module paths for quick lookup
81 | workspaceModules := make(map[string]bool)
82 | for _, m := range modules {
83 | workspaceModules[m.Path] = true
84 | if err := g.AddVertex(m.Path); err != nil {
85 | // Vertex may already exist, ignore
86 | }
87 | }
88 |
89 | // We need a workspace root - use the first module's parent or find go.work
90 | // For now, use the directory of the first module (assumes it's the main module)
91 | if len(modules) == 0 {
92 | return &g, nil
93 | }
94 |
95 | // Find workspace root by looking for go.work or use the main module's dir
96 | workspaceRoot := findWorkspaceRoot(modules)
97 |
98 | // Get all packages in the workspace
99 | packages, err := ListPackages(workspaceRoot, modules)
100 | if err != nil {
101 | return nil, fmt.Errorf("failed to list packages: %w", err)
102 | }
103 |
104 | // Build a map: import path prefix -> module path
105 | // This helps us determine which module an import belongs to
106 | importToModule := make(map[string]string)
107 | for _, pkg := range packages {
108 | if pkg.Module != nil {
109 | importToModule[pkg.ImportPath] = pkg.Module.Path
110 | }
111 | }
112 |
113 | // Track dependencies: module -> set of dependent modules
114 | moduleDeps := make(map[string]map[string]bool)
115 | for _, m := range modules {
116 | moduleDeps[m.Path] = make(map[string]bool)
117 | }
118 |
119 | // Analyze each package's imports
120 | for _, pkg := range packages {
121 | if pkg.Module == nil {
122 | continue
123 | }
124 | srcModule := pkg.Module.Path
125 |
126 | // Skip if source module is not in our workspace
127 | if !workspaceModules[srcModule] {
128 | continue
129 | }
130 |
131 | for _, imp := range pkg.Imports {
132 | // Find which module this import belongs to
133 | depModule := findModuleForImport(imp, importToModule, workspaceModules)
134 | if depModule != "" && depModule != srcModule {
135 | moduleDeps[srcModule][depModule] = true
136 | }
137 | }
138 | }
139 |
140 | // Add edges to the graph
141 | for srcModule, deps := range moduleDeps {
142 | for depModule := range deps {
143 | if err := g.AddEdge(srcModule, depModule); err != nil {
144 | // Edge may already exist or would create cycle, ignore
145 | }
146 | }
147 | }
148 |
149 | return &g, nil
150 | }
151 |
152 | // findWorkspaceRoot finds the workspace root directory by looking for go.work
153 | // It searches upward from the first module's directory
154 | func findWorkspaceRoot(modules []Module) string {
155 | if len(modules) == 0 {
156 | return "."
157 | }
158 |
159 | // Start from the first module's directory and search upward for go.work
160 | dir := modules[0].Dir
161 | for {
162 | goWorkPath := filepath.Join(dir, "go.work")
163 | if _, err := os.Stat(goWorkPath); err == nil {
164 | return dir
165 | }
166 |
167 | // Move up one directory
168 | parent := filepath.Dir(dir)
169 | if parent == dir {
170 | // Reached root, no go.work found
171 | break
172 | }
173 | dir = parent
174 | }
175 |
176 | // Fallback: use the first module's directory
177 | return modules[0].Dir
178 | }
179 |
180 | // findModuleForImport determines which workspace module an import path belongs to
181 | func findModuleForImport(importPath string, importToModule map[string]string, workspaceModules map[string]bool) string {
182 | // Direct match from our package scan
183 | if mod, ok := importToModule[importPath]; ok && workspaceModules[mod] {
184 | return mod
185 | }
186 |
187 | // Check if import path starts with any workspace module path
188 | for modPath := range workspaceModules {
189 | if strings.HasPrefix(importPath, modPath+"/") || importPath == modPath {
190 | return modPath
191 | }
192 | }
193 |
194 | return ""
195 | }
196 |
197 | func GetDependencyPaths(g *graph.Graph[string, string], vertex string) ([]string, error) {
198 | var dependencyPaths []string
199 |
200 | // Define a visitor function for DFS
201 | visitor := func(v string) bool {
202 | if v != vertex { // Don't include the starting vertex itself
203 | dependencyPaths = append(dependencyPaths, v)
204 | }
205 | return false // Continue traversal
206 | }
207 |
208 | // Perform DFS
209 | err := graph.DFS(*g, vertex, visitor)
210 | if err != nil {
211 | return nil, fmt.Errorf("failed to perform DFS for vertex %s: %w", vertex, err)
212 | }
213 |
214 | return dependencyPaths, nil
215 | }
216 |
217 | func runCommand(dir, command string) (output string, err error) {
218 | cmd := exec.Command("sh", "-c", command)
219 | cmd.Dir = dir
220 | var outputBytes []byte
221 | outputBytes, err = cmd.CombinedOutput()
222 | if err != nil {
223 | return "", fmt.Errorf("command execution failed: %w\nOutput: %s", err, outputBytes)
224 | }
225 | return string(outputBytes), nil
226 | }
227 |
--------------------------------------------------------------------------------
/e2e/e2e_test.go:
--------------------------------------------------------------------------------
1 | package e2e
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 | "path/filepath"
7 | "strings"
8 | "testing"
9 | )
10 |
11 | var (
12 | binaryPath string
13 | workspaceDir string
14 | )
15 |
16 | func TestMain(m *testing.M) {
17 | // Get absolute path to workspace
18 | wd, err := os.Getwd()
19 | if err != nil {
20 | panic("failed to get working directory: " + err.Error())
21 | }
22 | workspaceDir = filepath.Join(wd, "testdata", "workspace")
23 |
24 | // Build the binary once before all tests
25 | tmpDir, err := os.MkdirTemp("", "knit-e2e")
26 | if err != nil {
27 | panic("failed to create temp dir: " + err.Error())
28 | }
29 | binaryPath = filepath.Join(tmpDir, "knit")
30 |
31 | // Build from the parent directory (where main.go is)
32 | parentDir := filepath.Dir(wd)
33 | cmd := exec.Command("go", "build", "-o", binaryPath, ".")
34 | cmd.Dir = parentDir
35 | output, err := cmd.CombinedOutput()
36 | if err != nil {
37 | panic("failed to build binary: " + err.Error() + "\noutput: " + string(output))
38 | }
39 |
40 | // Run tests
41 | code := m.Run()
42 |
43 | // Cleanup
44 | os.RemoveAll(tmpDir)
45 | os.Exit(code)
46 | }
47 |
48 | // runKnit executes the knit binary with the given arguments
49 | func runKnit(t *testing.T, args ...string) (string, error) {
50 | t.Helper()
51 | cmd := exec.Command(binaryPath, args...)
52 | output, err := cmd.CombinedOutput()
53 | return string(output), err
54 | }
55 |
56 | func TestE2E_TestAllModules(t *testing.T) {
57 | output, err := runKnit(t, "test", "-p", workspaceDir)
58 | if err != nil {
59 | t.Fatalf("command failed: %v\noutput: %s", err, output)
60 | }
61 |
62 | // All 4 modules should be tested
63 | expectedModules := []string{
64 | "[example.com/core]",
65 | "[example.com/utils]",
66 | "[example.com/api]",
67 | "[example.com/app]",
68 | }
69 |
70 | for _, mod := range expectedModules {
71 | if !strings.Contains(output, mod) {
72 | t.Errorf("expected module %s in output, got:\n%s", mod, output)
73 | }
74 | }
75 | }
76 |
77 | func TestE2E_TestSingleTarget(t *testing.T) {
78 | output, err := runKnit(t, "test", "-p", workspaceDir, "-t", "example.com/core")
79 | if err != nil {
80 | t.Fatalf("command failed: %v\noutput: %s", err, output)
81 | }
82 |
83 | // Only core should be tested
84 | if !strings.Contains(output, "[example.com/core]") {
85 | t.Errorf("expected core module in output, got:\n%s", output)
86 | }
87 |
88 | // Other modules should NOT be tested
89 | unexpectedModules := []string{
90 | "[example.com/utils]",
91 | "[example.com/api]",
92 | "[example.com/app]",
93 | }
94 |
95 | for _, mod := range unexpectedModules {
96 | if strings.Contains(output, mod) {
97 | t.Errorf("unexpected module %s in output when targeting only core:\n%s", mod, output)
98 | }
99 | }
100 | }
101 |
102 | func TestE2E_TestTargetWithDependencies(t *testing.T) {
103 | t.Skip("Dependency flag removed - use 'knit affected --include-deps' instead")
104 | }
105 |
106 | func TestE2E_TestApiWithDependencies(t *testing.T) {
107 | t.Skip("Dependency flag removed - use 'knit affected --include-deps' instead")
108 | }
109 |
110 | func TestE2E_FmtAllModules(t *testing.T) {
111 | output, err := runKnit(t, "fmt", "-p", workspaceDir)
112 | if err != nil {
113 | t.Fatalf("command failed: %v\noutput: %s", err, output)
114 | }
115 |
116 | // All 4 modules should be formatted
117 | expectedModules := []string{
118 | "[example.com/core]",
119 | "[example.com/utils]",
120 | "[example.com/api]",
121 | "[example.com/app]",
122 | }
123 |
124 | for _, mod := range expectedModules {
125 | if !strings.Contains(output, mod) {
126 | t.Errorf("expected module %s in output, got:\n%s", mod, output)
127 | }
128 | }
129 | }
130 |
131 | func TestE2E_InstallAllModules(t *testing.T) {
132 | t.Skip("Install command removed - not useful for Go modules")
133 | }
134 |
135 | // setupGitRepo initializes a git repo, commits everything, then modifies specific files
136 | func setupGitRepo(t *testing.T, dir string, filesToModify []string) func() {
137 | t.Helper()
138 |
139 | // Initialize git repo
140 | runGit(t, dir, "init")
141 | runGit(t, dir, "config", "user.email", "test@test.com")
142 | runGit(t, dir, "config", "user.name", "Test")
143 | runGit(t, dir, "add", "-A")
144 | runGit(t, dir, "commit", "-m", "initial commit")
145 |
146 | // Modify the specified files
147 | for _, file := range filesToModify {
148 | filePath := filepath.Join(dir, file)
149 | f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
150 | if err != nil {
151 | t.Fatalf("failed to open file %s: %v", file, err)
152 | }
153 | _, err = f.WriteString("\n// modified for test\n")
154 | f.Close()
155 | if err != nil {
156 | t.Fatalf("failed to modify file %s: %v", file, err)
157 | }
158 | }
159 |
160 | // Return cleanup function
161 | return func() {
162 | os.RemoveAll(filepath.Join(dir, ".git"))
163 | // Restore modified files by removing the added comment
164 | for _, file := range filesToModify {
165 | filePath := filepath.Join(dir, file)
166 | data, _ := os.ReadFile(filePath)
167 | restored := strings.ReplaceAll(string(data), "\n// modified for test\n", "")
168 | os.WriteFile(filePath, []byte(restored), 0644)
169 | }
170 | }
171 | }
172 |
173 | func runGit(t *testing.T, dir string, args ...string) {
174 | t.Helper()
175 | cmd := exec.Command("git", args...)
176 | cmd.Dir = dir
177 | output, err := cmd.CombinedOutput()
178 | if err != nil {
179 | t.Fatalf("git %v failed: %v\noutput: %s", args, err, output)
180 | }
181 | }
182 |
183 | func TestE2E_AffectedList(t *testing.T) {
184 | // Setup git repo with changes only in core and utils
185 | cleanup := setupGitRepo(t, workspaceDir, []string{
186 | "core/core.go",
187 | "utils/utils.go",
188 | })
189 | defer cleanup()
190 |
191 | output, err := runKnit(t, "affected", "-p", workspaceDir, "--base", "HEAD")
192 | if err != nil {
193 | t.Fatalf("command failed: %v\noutput: %s", err, output)
194 | }
195 |
196 | // Should list core and utils as affected
197 | if !strings.Contains(output, "example.com/core") {
198 | t.Errorf("expected example.com/core in output, got:\n%s", output)
199 | }
200 | if !strings.Contains(output, "example.com/utils") {
201 | t.Errorf("expected example.com/utils in output, got:\n%s", output)
202 | }
203 |
204 | // Should NOT list api or app (they weren't modified)
205 | if strings.Contains(output, "example.com/api") {
206 | t.Errorf("unexpected example.com/api in output:\n%s", output)
207 | }
208 | if strings.Contains(output, "example.com/app") {
209 | t.Errorf("unexpected example.com/app in output:\n%s", output)
210 | }
211 | }
212 |
213 | func TestE2E_AffectedGoArgsFormat(t *testing.T) {
214 | cleanup := setupGitRepo(t, workspaceDir, []string{
215 | "core/core.go",
216 | })
217 | defer cleanup()
218 |
219 | output, err := runKnit(t, "affected", "-p", workspaceDir, "--base", "HEAD", "-f", "go-args")
220 | if err != nil {
221 | t.Fatalf("command failed: %v\noutput: %s", err, output)
222 | }
223 |
224 | // Should be in go-args format: -p module1 -p module2
225 | if !strings.Contains(output, "-p example.com/core") {
226 | t.Errorf("expected '-p example.com/core' in output, got:\n%s", output)
227 | }
228 | }
229 |
230 | func TestE2E_AffectedGitHubMatrixFormat(t *testing.T) {
231 | cleanup := setupGitRepo(t, workspaceDir, []string{
232 | "api/api.go",
233 | })
234 | defer cleanup()
235 |
236 | output, err := runKnit(t, "affected", "-p", workspaceDir, "--base", "HEAD", "-f", "github-matrix")
237 | if err != nil {
238 | t.Fatalf("command failed: %v\noutput: %s", err, output)
239 | }
240 |
241 | // Should be JSON format
242 | if !strings.Contains(output, `"module"`) {
243 | t.Errorf("expected JSON with 'module' key in output, got:\n%s", output)
244 | }
245 | if !strings.Contains(output, "example.com/api") {
246 | t.Errorf("expected example.com/api in JSON output, got:\n%s", output)
247 | }
248 | }
249 |
250 | func TestE2E_AffectedWithDeps(t *testing.T) {
251 | // Modify only core - with --include-deps, should still only show core
252 | // since --include-deps shows dependencies OF affected modules, not dependents
253 | cleanup := setupGitRepo(t, workspaceDir, []string{
254 | "api/api.go",
255 | })
256 | defer cleanup()
257 |
258 | output, err := runKnit(t, "affected", "-p", workspaceDir, "--base", "HEAD", "--include-deps")
259 | if err != nil {
260 | t.Fatalf("command failed: %v\noutput: %s", err, output)
261 | }
262 |
263 | // api is affected, and it depends on utils and core
264 | // So with --include-deps we should see api, utils, core
265 | if !strings.Contains(output, "example.com/api") {
266 | t.Errorf("expected example.com/api in output, got:\n%s", output)
267 | }
268 | if !strings.Contains(output, "example.com/utils") {
269 | t.Errorf("expected example.com/utils (dependency of api) in output, got:\n%s", output)
270 | }
271 | if !strings.Contains(output, "example.com/core") {
272 | t.Errorf("expected example.com/core (dependency of api via utils) in output, got:\n%s", output)
273 | }
274 |
275 | // app should NOT be included (it's a dependent, not a dependency)
276 | if strings.Contains(output, "example.com/app") {
277 | t.Errorf("unexpected example.com/app in output:\n%s", output)
278 | }
279 | }
280 |
281 | func TestE2E_AffectedNoChanges(t *testing.T) {
282 | // Setup git repo with NO changes after commit
283 | cleanup := setupGitRepo(t, workspaceDir, []string{})
284 | defer cleanup()
285 |
286 | output, err := runKnit(t, "affected", "-p", workspaceDir, "--base", "HEAD", "-f", "github-matrix")
287 | if err != nil {
288 | t.Fatalf("command failed: %v\noutput: %s", err, output)
289 | }
290 |
291 | // Should output empty module array
292 | if !strings.Contains(output, `"module":[]`) {
293 | t.Errorf("expected empty module array in output, got:\n%s", output)
294 | }
295 | }
296 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "os"
9 | "os/signal"
10 | "path/filepath"
11 | "strings"
12 | "sync"
13 | "syscall"
14 |
15 | analyzer "github.com/nicolasgere/knit/lib/analyser"
16 |
17 | "github.com/nicolasgere/knit/lib/git"
18 | "github.com/nicolasgere/knit/lib/runner"
19 | "github.com/nicolasgere/knit/lib/utils"
20 | "github.com/urfave/cli/v2"
21 | )
22 |
23 | var defaultDir = "."
24 |
25 | func main() {
26 | ctx, cancel := context.WithCancel(context.Background())
27 | defer cancel()
28 |
29 | setupSignalHandling(cancel)
30 |
31 | r := runner.NewRunner(ctx, 3)
32 | app := createCliApp(&r)
33 |
34 | if err := app.Run(os.Args); err != nil {
35 | log.Fatal(err)
36 | }
37 | }
38 |
39 | func setupSignalHandling(cancel context.CancelFunc) {
40 | sigChan := make(chan os.Signal, 1)
41 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
42 | go func() {
43 | <-sigChan
44 | cancel()
45 | }()
46 | }
47 |
48 | func createCliApp(r *runner.Runner) *cli.App {
49 | return &cli.App{
50 | Commands: []*cli.Command{
51 | createCommand("fmt", "Format every modules", "go fmt ./...", r),
52 | createCommand("test", "Test every modules", "go test ./...", r),
53 | createAffectedCommand(),
54 | createGraphCommand(),
55 | },
56 | }
57 | }
58 |
59 | // OutputFormat defines the format for the affected command output
60 | type OutputFormat string
61 |
62 | const (
63 | FormatList OutputFormat = "list"
64 | FormatGoArgs OutputFormat = "go-args"
65 | FormatGitHubMatrix OutputFormat = "github-matrix"
66 | )
67 |
68 | // createAffectedCommand creates the 'affected' command
69 | func createAffectedCommand() *cli.Command {
70 | var (
71 | path string
72 | base string
73 | useMergeBase bool
74 | format string
75 | includeDeps bool
76 | )
77 |
78 | return &cli.Command{
79 | Name: "affected",
80 | Usage: "List modules affected by changes since a git reference",
81 | Description: `Detect which modules have changed compared to a git reference.
82 |
83 | Examples:
84 | knit affected # Compare against 'main' branch
85 | knit affected --base origin/main # Compare against origin/main
86 | knit affected --merge-base # Use merge-base (recommended for CI)
87 | knit affected -f go-args # Output: -p module1 -p module2
88 | knit affected -f github-matrix # Output: JSON matrix for GitHub Actions
89 | knit affected --include-deps # Include dependencies of affected modules`,
90 | Flags: []cli.Flag{
91 | &cli.StringFlag{
92 | Name: "path",
93 | Usage: "Path to the workspace root",
94 | Aliases: []string{"p"},
95 | Value: ".",
96 | Destination: &path,
97 | },
98 | &cli.StringFlag{
99 | Name: "base",
100 | Usage: "Git reference to compare against (branch, tag, or commit)",
101 | Aliases: []string{"b"},
102 | Value: "main",
103 | Destination: &base,
104 | },
105 | &cli.BoolFlag{
106 | Name: "merge-base",
107 | Usage: "Compare against merge-base (common ancestor) - recommended for CI/PRs",
108 | Aliases: []string{"m"},
109 | Destination: &useMergeBase,
110 | },
111 | &cli.StringFlag{
112 | Name: "format",
113 | Usage: "Output format: list (default), go-args, github-matrix",
114 | Aliases: []string{"f"},
115 | Value: "list",
116 | Destination: &format,
117 | },
118 | &cli.BoolFlag{
119 | Name: "include-deps",
120 | Usage: "Include dependencies of affected modules",
121 | Aliases: []string{"d"},
122 | Destination: &includeDeps,
123 | },
124 | },
125 | Action: func(c *cli.Context) error {
126 | return runAffected(path, base, useMergeBase, OutputFormat(format), includeDeps)
127 | },
128 | }
129 | }
130 |
131 | func runAffected(path, base string, useMergeBase bool, format OutputFormat, includeDeps bool) error {
132 | // Get absolute path to workspace
133 | absPath, err := filepath.Abs(path)
134 | if err != nil {
135 | return fmt.Errorf("failed to get absolute path: %w", err)
136 | }
137 |
138 | // List all modules in the workspace
139 | modules, err := analyzer.ListModule(absPath)
140 | if err != nil {
141 | return fmt.Errorf("failed to list modules: %w", err)
142 | }
143 |
144 | if len(modules) == 0 {
145 | return fmt.Errorf("no modules found in workspace")
146 | }
147 |
148 | // Get changed files
149 | changedFiles, err := git.GetChangedFiles(base, useMergeBase, absPath)
150 | if err != nil {
151 | return fmt.Errorf("failed to get changed files: %w", err)
152 | }
153 |
154 | // Get module directories
155 | moduleDirs := make([]string, len(modules))
156 | moduleDirToPath := make(map[string]string)
157 | for i, m := range modules {
158 | moduleDirs[i] = m.Dir
159 | moduleDirToPath[m.Dir] = m.Path
160 | }
161 |
162 | // Find affected module directories
163 | affectedDirs := git.FindAffectedModuleDirs(changedFiles, moduleDirs, absPath)
164 |
165 | // Convert to module paths
166 | affectedPaths := make([]string, 0, len(affectedDirs))
167 | for _, dir := range affectedDirs {
168 | if path, ok := moduleDirToPath[dir]; ok {
169 | affectedPaths = append(affectedPaths, path)
170 | }
171 | }
172 |
173 | // Include dependencies if requested
174 | if includeDeps && len(affectedPaths) > 0 {
175 | graph, err := analyzer.BuildDependencyGraph(modules)
176 | if err != nil {
177 | return fmt.Errorf("failed to build dependency graph: %w", err)
178 | }
179 |
180 | allAffected := make(map[string]bool)
181 | for _, p := range affectedPaths {
182 | allAffected[p] = true
183 | }
184 |
185 | // For each affected module, find its dependencies
186 | for _, p := range affectedPaths {
187 | deps, err := analyzer.GetDependencyPaths(graph, p)
188 | if err != nil {
189 | // Module might not have dependencies, continue
190 | continue
191 | }
192 | for _, dep := range deps {
193 | allAffected[dep] = true
194 | }
195 | }
196 |
197 | // Convert back to slice
198 | affectedPaths = make([]string, 0, len(allAffected))
199 | for p := range allAffected {
200 | affectedPaths = append(affectedPaths, p)
201 | }
202 | }
203 |
204 | // Output in the requested format
205 | return outputAffected(affectedPaths, format)
206 | }
207 |
208 | func outputAffected(modules []string, format OutputFormat) error {
209 | switch format {
210 | case FormatList:
211 | for _, m := range modules {
212 | fmt.Println(m)
213 | }
214 |
215 | case FormatGoArgs:
216 | // Output: -p module1 -p module2 ...
217 | var args []string
218 | for _, m := range modules {
219 | args = append(args, "-p", m)
220 | }
221 | fmt.Println(strings.Join(args, " "))
222 |
223 | case FormatGitHubMatrix:
224 | // Output: JSON for GitHub Actions matrix
225 | type MatrixOutput struct {
226 | Module []string `json:"module"`
227 | }
228 | matrix := MatrixOutput{Module: modules}
229 | if len(modules) == 0 {
230 | matrix.Module = []string{} // Ensure empty array, not null
231 | }
232 | data, err := json.Marshal(matrix)
233 | if err != nil {
234 | return fmt.Errorf("failed to marshal JSON: %w", err)
235 | }
236 | fmt.Println(string(data))
237 |
238 | default:
239 | return fmt.Errorf("unknown format: %s (use list, go-args, or github-matrix)", format)
240 | }
241 |
242 | return nil
243 | }
244 |
245 | // createGraphCommand creates the 'graph' command to visualize module dependencies
246 | func createGraphCommand() *cli.Command {
247 | var (
248 | path string
249 | format string
250 | )
251 |
252 | return &cli.Command{
253 | Name: "graph",
254 | Usage: "Display the dependency graph of all modules in the workspace",
255 | Description: `Show all modules and their dependencies within the monorepo.
256 |
257 | Examples:
258 | knit graph # Show dependency graph
259 | knit graph -f dot # Output in DOT format (for Graphviz)
260 | knit graph -f json # Output in JSON format`,
261 | Flags: []cli.Flag{
262 | &cli.StringFlag{
263 | Name: "path",
264 | Usage: "Path to the workspace root",
265 | Aliases: []string{"p"},
266 | Value: ".",
267 | Destination: &path,
268 | },
269 | &cli.StringFlag{
270 | Name: "format",
271 | Usage: "Output format: tree (default), dot, json",
272 | Aliases: []string{"f"},
273 | Value: "tree",
274 | Destination: &format,
275 | },
276 | },
277 | Action: func(c *cli.Context) error {
278 | return runGraph(path, format)
279 | },
280 | }
281 | }
282 |
283 | func runGraph(path, format string) error {
284 | // Get absolute path to workspace
285 | absPath, err := filepath.Abs(path)
286 | if err != nil {
287 | return fmt.Errorf("failed to get absolute path: %w", err)
288 | }
289 |
290 | // List all modules in the workspace
291 | modules, err := analyzer.ListModule(absPath)
292 | if err != nil {
293 | return fmt.Errorf("failed to list modules: %w", err)
294 | }
295 |
296 | if len(modules) == 0 {
297 | return fmt.Errorf("no modules found in workspace")
298 | }
299 |
300 | // Build dependency graph
301 | g, err := analyzer.BuildDependencyGraph(modules)
302 | if err != nil {
303 | return fmt.Errorf("failed to build dependency graph: %w", err)
304 | }
305 |
306 | // Get adjacency map
307 | adjMap, err := (*g).AdjacencyMap()
308 | if err != nil {
309 | return fmt.Errorf("failed to get adjacency map: %w", err)
310 | }
311 |
312 | // Output in requested format
313 | switch format {
314 | case "tree":
315 | return outputGraphTree(modules, adjMap)
316 | case "dot":
317 | return outputGraphDot(modules, adjMap)
318 | case "json":
319 | return outputGraphJSON(modules, adjMap)
320 | default:
321 | return fmt.Errorf("unknown format: %s (use tree, dot, or json)", format)
322 | }
323 | }
324 |
325 | func outputGraphTree[T any](modules []analyzer.Module, adjMap map[string]map[string]T) error {
326 | fmt.Println("Module Dependency Graph")
327 | fmt.Println("=======================")
328 | fmt.Println()
329 |
330 | for _, m := range modules {
331 | deps := adjMap[m.Path]
332 | if len(deps) == 0 {
333 | fmt.Printf("📦 %s\n", m.Path)
334 | fmt.Println(" (no workspace dependencies)")
335 | } else {
336 | fmt.Printf("📦 %s\n", m.Path)
337 | depList := make([]string, 0, len(deps))
338 | for dep := range deps {
339 | depList = append(depList, dep)
340 | }
341 | for i, dep := range depList {
342 | if i == len(depList)-1 {
343 | fmt.Printf(" └── %s\n", dep)
344 | } else {
345 | fmt.Printf(" ├── %s\n", dep)
346 | }
347 | }
348 | }
349 | fmt.Println()
350 | }
351 |
352 | return nil
353 | }
354 |
355 | func outputGraphDot[T any](modules []analyzer.Module, adjMap map[string]map[string]T) error {
356 | fmt.Println("digraph dependencies {")
357 | fmt.Println(" rankdir=TB;")
358 | fmt.Println(" node [shape=box, style=rounded];")
359 | fmt.Println()
360 |
361 | // Add all nodes
362 | for _, m := range modules {
363 | // Use short name for display
364 | shortName := m.Path
365 | if idx := strings.LastIndex(m.Path, "/"); idx != -1 {
366 | shortName = m.Path[idx+1:]
367 | }
368 | fmt.Printf(" \"%s\" [label=\"%s\"];\n", m.Path, shortName)
369 | }
370 | fmt.Println()
371 |
372 | // Add edges
373 | for _, m := range modules {
374 | deps := adjMap[m.Path]
375 | for dep := range deps {
376 | fmt.Printf(" \"%s\" -> \"%s\";\n", m.Path, dep)
377 | }
378 | }
379 |
380 | fmt.Println("}")
381 | return nil
382 | }
383 |
384 | func outputGraphJSON[T any](modules []analyzer.Module, adjMap map[string]map[string]T) error {
385 | type ModuleNode struct {
386 | Path string `json:"path"`
387 | Dir string `json:"dir"`
388 | Dependencies []string `json:"dependencies"`
389 | }
390 |
391 | type GraphOutput struct {
392 | Modules []ModuleNode `json:"modules"`
393 | }
394 |
395 | output := GraphOutput{
396 | Modules: make([]ModuleNode, 0, len(modules)),
397 | }
398 |
399 | for _, m := range modules {
400 | deps := adjMap[m.Path]
401 | depList := make([]string, 0, len(deps))
402 | for dep := range deps {
403 | depList = append(depList, dep)
404 | }
405 |
406 | output.Modules = append(output.Modules, ModuleNode{
407 | Path: m.Path,
408 | Dir: m.Dir,
409 | Dependencies: depList,
410 | })
411 | }
412 |
413 | data, err := json.MarshalIndent(output, "", " ")
414 | if err != nil {
415 | return fmt.Errorf("failed to marshal JSON: %w", err)
416 | }
417 | fmt.Println(string(data))
418 | return nil
419 | }
420 |
421 | func createCommand(name, usage, cmd string, r *runner.Runner) *cli.Command {
422 | var target string
423 | var useColor bool
424 | var affected bool
425 | var base string
426 |
427 | return &cli.Command{
428 | Name: name,
429 | Usage: usage,
430 | Flags: []cli.Flag{
431 | &cli.StringFlag{
432 | Name: "Path",
433 | Usage: "Path to the root directory of the project",
434 | Aliases: []string{"p"},
435 | Destination: &defaultDir,
436 | },
437 | &cli.StringFlag{
438 | Name: "target",
439 | Usage: "Targeted module",
440 | Aliases: []string{"t"},
441 | Destination: &target,
442 | },
443 | &cli.BoolFlag{
444 | Name: "affected",
445 | Usage: "Run only on affected modules (since merge-base)",
446 | Aliases: []string{"a"},
447 | Destination: &affected,
448 | Value: false,
449 | },
450 | &cli.StringFlag{
451 | Name: "base",
452 | Usage: "Git reference to compare against when using --affected (default: main)",
453 | Aliases: []string{"b"},
454 | Value: "main",
455 | Destination: &base,
456 | },
457 | &cli.BoolFlag{
458 | Name: "color",
459 | Usage: "Enable colored output for better readability",
460 | Aliases: []string{"c"},
461 | Destination: &useColor,
462 | Value: false,
463 | },
464 | },
465 | Action: func(*cli.Context) error {
466 | // Enable color output if requested
467 | utils.SetColorEnabled(useColor)
468 |
469 | // Get absolute path to workspace
470 | absPath, err := filepath.Abs(defaultDir)
471 | if err != nil {
472 | return fmt.Errorf("failed to get absolute path: %w", err)
473 | }
474 |
475 | modules, err := analyzer.ListModule(absPath)
476 | if err != nil {
477 | return err
478 | }
479 | modulesToRun := modules
480 |
481 | // Filter by affected modules if requested
482 | if affected {
483 | changedFiles, err := git.GetChangedFiles(base, true, absPath)
484 | if err != nil {
485 | return fmt.Errorf("failed to get changed files: %w", err)
486 | }
487 |
488 | // Get module directories
489 | moduleDirs := make([]string, len(modules))
490 | moduleDirToPath := make(map[string]string)
491 | for i, m := range modules {
492 | moduleDirs[i] = m.Dir
493 | moduleDirToPath[m.Dir] = m.Path
494 | }
495 |
496 | // Find affected module directories
497 | affectedDirs := git.FindAffectedModuleDirs(changedFiles, moduleDirs, absPath)
498 |
499 | // Convert to module list
500 | affectedModules := make([]analyzer.Module, 0)
501 | affectedPaths := make(map[string]bool)
502 | for _, dir := range affectedDirs {
503 | if path, ok := moduleDirToPath[dir]; ok {
504 | affectedPaths[path] = true
505 | }
506 | }
507 |
508 | for _, m := range modules {
509 | if affectedPaths[m.Path] {
510 | affectedModules = append(affectedModules, m)
511 | }
512 | }
513 | modulesToRun = affectedModules
514 |
515 | if len(modulesToRun) == 0 {
516 | fmt.Println("No affected modules found")
517 | return nil
518 | }
519 | }
520 |
521 | // Filter by target if specified
522 | if target != "" {
523 | filteredModule := make([]analyzer.Module, 0)
524 | for _, m := range modulesToRun {
525 | if m.Path == target {
526 | filteredModule = append(filteredModule, m)
527 | }
528 | }
529 | modulesToRun = filteredModule
530 | }
531 |
532 | runOnModules(defaultDir, cmd, r, modulesToRun)
533 | return nil
534 | },
535 | }
536 | }
537 |
538 | func runOnModules(dir, cmd string, r *runner.Runner, modules []analyzer.Module) {
539 |
540 | tasks := createTasks(modules, cmd)
541 | tfs := r.RunTasks(tasks)
542 |
543 | var wg sync.WaitGroup
544 | wg.Add(len(tfs))
545 |
546 | for _, tf := range tfs {
547 | go handleTaskFuture(tf, &wg)
548 | }
549 |
550 | wg.Wait()
551 | return
552 | }
553 |
554 | func createTasks(modules []analyzer.Module, cmd string) []runner.Task {
555 | tasks := make([]runner.Task, len(modules))
556 | for i, module := range modules {
557 | tasks[i] = runner.Task{
558 | Id: module.Path,
559 | Cmd: cmd,
560 | Root: module.Dir,
561 | }
562 | }
563 | return tasks
564 | }
565 |
566 | func handleTaskFuture(tf *runner.TaskFuture, wg *sync.WaitGroup) {
567 | defer wg.Done()
568 | for {
569 | select {
570 | case stdout, ok := <-tf.Stdout:
571 | handleOutput(tf.Id, stdout, ok, &tf.Stdout)
572 | case stderr, ok := <-tf.Stderr:
573 | handleOutput(tf.Id, stderr, ok, &tf.Stderr)
574 | case result := <-tf.Done:
575 | isSuccess := result.Status == 0
576 | statusMsg := fmt.Sprintf("Done with status %d", result.Status)
577 | if isSuccess {
578 | statusMsg = "✓ Done"
579 | } else {
580 | statusMsg = fmt.Sprintf("✗ Failed (exit %d)", result.Status)
581 | }
582 | utils.LogStatus(tf.Id, statusMsg, isSuccess)
583 | return
584 | }
585 | }
586 | }
587 |
588 | func handleOutput(id string, output []byte, ok bool, channel *chan []byte) {
589 | if !ok {
590 | *channel = nil
591 | return
592 | }
593 | if len(output) > 0 {
594 | utils.LogWithTaskId(id, string(output), utils.INFO)
595 | }
596 | }
597 |
--------------------------------------------------------------------------------