├── .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 | Knit 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 | --------------------------------------------------------------------------------