Skip to content

Commit e951587

Browse files
authored
Merge pull request #65 from liggitt/prune
Make depstat work properly with go1.17+ go mod graphs
2 parents da538ee + ce305ab commit e951587

File tree

5 files changed

+188
-50
lines changed

5 files changed

+188
-50
lines changed

.github/workflows/go.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,7 @@ jobs:
3030
- uses: actions/checkout@v2
3131

3232
- name: Run golint
33-
run: make lint
33+
run: |
34+
export PATH=$PATH:$(go env GOPATH)/bin
35+
make lint
3436

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ build:
77
go build
88

99
.PHONY: lint
10-
lint:
11-
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s v1.39.0
12-
./bin/golangci-lint run --verbose --enable gofmt
10+
lint:
11+
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.39.0
12+
golangci-lint run --verbose --enable gofmt

cmd/stats.go

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/spf13/cobra"
2424
)
2525

26+
var dir string
2627
var jsonOutput bool
2728
var verbose bool
2829
var mainModules []string
@@ -46,10 +47,8 @@ var statsCmd = &cobra.Command{
4647
}
4748

4849
// get the longest chain
49-
var longestChain Chain
5050
var temp Chain
51-
getLongestChain(depGraph.MainModules[0], depGraph.Graph, temp, &longestChain)
52-
51+
longestChain := getLongestChain(depGraph.MainModules[0], depGraph.Graph, temp, map[string]Chain{})
5352
// get values
5453
maxDepth := len(longestChain)
5554
directDeps := len(depGraph.DirectDepList)
@@ -98,30 +97,44 @@ var statsCmd = &cobra.Command{
9897
}
9998

10099
// get the longest chain starting from currentDep
101-
func getLongestChain(currentDep string, graph map[string][]string, currentChain Chain, longestChain *Chain) {
100+
func getLongestChain(currentDep string, graph map[string][]string, currentChain Chain, longestChains map[string]Chain) Chain {
101+
// fmt.Println(strings.Repeat(" ", len(currentChain)), currentDep)
102+
103+
// already computed
104+
if longestChain, ok := longestChains[currentDep]; ok {
105+
return longestChain
106+
}
107+
108+
deps := graph[currentDep]
109+
110+
if len(deps) == 0 {
111+
// we have no dependencies, our longest chain is just us
112+
longestChains[currentDep] = Chain{currentDep}
113+
return longestChains[currentDep]
114+
}
115+
116+
if contains(currentChain, currentDep) {
117+
// we've already been visited in the current chain, avoid cycles but also don't record a longest chain for currentDep
118+
return nil
119+
}
120+
102121
currentChain = append(currentChain, currentDep)
103-
_, ok := graph[currentDep]
104-
if ok {
105-
for _, dep := range graph[currentDep] {
106-
if !contains(currentChain, dep) {
107-
cpy := make(Chain, len(currentChain))
108-
copy(cpy, currentChain)
109-
getLongestChain(dep, graph, cpy, longestChain)
110-
} else {
111-
if len(currentChain) > len(*longestChain) {
112-
*longestChain = currentChain
113-
}
114-
}
115-
}
116-
} else {
117-
if len(currentChain) > len(*longestChain) {
118-
*longestChain = currentChain
122+
// find the longest dependency chain
123+
var longestDepChain Chain
124+
for _, dep := range deps {
125+
depChain := getLongestChain(dep, graph, currentChain, longestChains)
126+
if len(depChain) > len(longestDepChain) {
127+
longestDepChain = depChain
119128
}
120129
}
130+
// prepend ourselves to the longest of our dependencies' chains and persist
131+
longestChains[currentDep] = append(Chain{currentDep}, longestDepChain...)
132+
return longestChains[currentDep]
121133
}
122134

123135
func init() {
124136
rootCmd.AddCommand(statsCmd)
137+
statsCmd.Flags().StringVarP(&dir, "dir", "d", "", "Directory containing the module to evaluate. Defaults to the current directory.")
125138
statsCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Get additional details")
126139
statsCmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "Get the output in JSON format")
127140
statsCmd.Flags().StringSliceVarP(&mainModules, "mainModules", "m", []string{}, "Enter modules whose dependencies should be considered direct dependencies; defaults to the first module encountered in `go mod graph` output")

cmd/utils.go

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ type DependencyOverview struct {
4545
func getDepInfo(mainModules []string) *DependencyOverview {
4646
// get output of "go mod graph" in a string
4747
goModGraph := exec.Command("go", "mod", "graph")
48+
if dir != "" {
49+
goModGraph.Dir = dir
50+
}
4851
goModGraphOutput, err := goModGraph.Output()
4952
if err != nil {
5053
log.Fatal(err)
@@ -112,38 +115,131 @@ func sliceContains(val []Chain, key Chain) bool {
112115
return false
113116
}
114117

118+
type module struct {
119+
name string
120+
version string
121+
}
122+
123+
func parseModule(s string) module {
124+
if strings.Contains(s, "@") {
125+
parts := strings.SplitN(s, "@", 2)
126+
return module{name: parts[0], version: parts[1]}
127+
}
128+
return module{name: s}
129+
}
130+
115131
func generateGraph(goModGraphOutputString string, mainModules []string) DependencyOverview {
116132
depGraph := DependencyOverview{MainModules: mainModules}
133+
versionedGraph := make(map[module][]module)
134+
var lhss []module
117135
graph := make(map[string][]string)
118136
scanner := bufio.NewScanner(strings.NewReader(goModGraphOutputString))
119137

138+
var versionedMainModules []module
139+
var seenVersionedMainModules = map[module]bool{}
120140
for scanner.Scan() {
121141
line := scanner.Text()
122142
words := strings.Fields(line)
123-
// remove versions
124-
words[0] = (strings.Split(words[0], "@"))[0]
125-
words[1] = (strings.Split(words[1], "@"))[0]
126143

127-
// we don't want to add the same dep again
128-
if !contains(graph[words[0]], words[1]) {
129-
graph[words[0]] = append(graph[words[0]], words[1])
144+
lhs := parseModule(words[0])
145+
if len(versionedMainModules) == 0 || contains(mainModules, lhs.name) {
146+
if !seenVersionedMainModules[lhs] {
147+
// remember our root module and listed main modules
148+
versionedMainModules = append(versionedMainModules, lhs)
149+
seenVersionedMainModules[lhs] = true
150+
}
130151
}
131-
132152
if len(depGraph.MainModules) == 0 {
133-
depGraph.MainModules = append(depGraph.MainModules, words[0])
153+
// record the first module we see as the main module by default
154+
depGraph.MainModules = append(depGraph.MainModules, lhs.name)
134155
}
156+
rhs := parseModule(words[1])
157+
158+
// remember the order we observed lhs modules in
159+
if len(versionedGraph[lhs]) == 0 {
160+
lhss = append(lhss, lhs)
161+
}
162+
// record this lhs -> rhs relationship
163+
versionedGraph[lhs] = append(versionedGraph[lhs], rhs)
164+
}
165+
166+
// record effective versions of modules required by our main modules
167+
// in go1.17+, the main module records effective versions of all dependencies, even indirect ones
168+
effectiveVersions := map[string]string{}
169+
for _, mm := range versionedMainModules {
170+
for _, m := range versionedGraph[mm] {
171+
if effectiveVersions[m.name] < m.version {
172+
effectiveVersions[m.name] = m.version
173+
}
174+
}
175+
}
135176

136-
// if the LHS is a mainModule
137-
// then RHS is a direct dep else transitive dep
138-
if contains(depGraph.MainModules, words[0]) && contains(depGraph.MainModules, words[1]) {
177+
type edge struct {
178+
from module
179+
to module
180+
}
181+
182+
// figure out which modules in the graph are reachable from the effective versions required by our main modules
183+
reachableModules := map[string]module{}
184+
// start with our main modules
185+
var toVisit []edge
186+
for _, m := range versionedMainModules {
187+
toVisit = append(toVisit, edge{to: m})
188+
}
189+
for len(toVisit) > 0 {
190+
from := toVisit[0].from
191+
v := toVisit[0].to
192+
toVisit = toVisit[1:]
193+
if _, reachable := reachableModules[v.name]; reachable {
194+
// already flagged as reachable
195+
continue
196+
}
197+
// mark as reachable
198+
reachableModules[v.name] = from
199+
if effectiveVersion, ok := effectiveVersions[v.name]; ok && effectiveVersion > v.version {
200+
// replace with the effective version if applicable
201+
v.version = effectiveVersion
202+
} else {
203+
// set the effective version
204+
effectiveVersions[v.name] = v.version
205+
}
206+
// queue dependants of this to check for reachability
207+
for _, m := range versionedGraph[v] {
208+
toVisit = append(toVisit, edge{from: v, to: m})
209+
}
210+
}
211+
212+
for _, lhs := range lhss {
213+
if _, reachable := reachableModules[lhs.name]; !reachable {
214+
// this is not reachable via required versions, skip it
215+
continue
216+
}
217+
if effectiveVersion, ok := effectiveVersions[lhs.name]; ok && effectiveVersion != lhs.version {
218+
// this is not the effective version in our graph, skip it
139219
continue
140-
} else if contains(depGraph.MainModules, words[0]) {
141-
if !contains(depGraph.DirectDepList, words[1]) {
142-
depGraph.DirectDepList = append(depGraph.DirectDepList, words[1])
220+
}
221+
// fmt.Println(lhs.name, "via", reachableModules[lhs.name])
222+
223+
for _, rhs := range versionedGraph[lhs] {
224+
// we don't want to add the same dep again
225+
if !contains(graph[lhs.name], rhs.name) {
226+
graph[lhs.name] = append(graph[lhs.name], rhs.name)
143227
}
144-
} else if !contains(depGraph.MainModules, words[0]) {
145-
if !contains(depGraph.TransDepList, words[1]) {
146-
depGraph.TransDepList = append(depGraph.TransDepList, words[1])
228+
229+
// if the LHS is a mainModule
230+
// then RHS is a direct dep else transitive dep
231+
if contains(depGraph.MainModules, lhs.name) && contains(depGraph.MainModules, rhs.name) {
232+
continue
233+
} else if contains(depGraph.MainModules, lhs.name) {
234+
if !contains(depGraph.DirectDepList, rhs.name) {
235+
// fmt.Println(rhs.name, "via", lhs)
236+
depGraph.DirectDepList = append(depGraph.DirectDepList, rhs.name)
237+
}
238+
} else if !contains(depGraph.MainModules, lhs.name) {
239+
if !contains(depGraph.TransDepList, rhs.name) {
240+
// fmt.Println(rhs.name, "via", lhs)
241+
depGraph.TransDepList = append(depGraph.TransDepList, rhs.name)
242+
}
147243
}
148244
}
149245
}

cmd/utils_test.go

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ limitations under the License.
1717
package cmd
1818

1919
import (
20-
"fmt"
2120
"testing"
2221
)
2322

@@ -55,10 +54,9 @@ func Test_getChains_simple(t *testing.T) {
5554
}
5655

5756
var cycleChains []Chain
58-
var longestChain Chain
5957
var chains []Chain
6058
var temp Chain
61-
getLongestChain("A", graph, temp, &longestChain)
59+
longestChain := getLongestChain("A", graph, temp, map[string]Chain{})
6260
maxDepth := len(longestChain)
6361
getCycleChains("A", graph, temp, &cycleChains)
6462
getAllChains("A", graph, temp, &chains)
@@ -80,7 +78,6 @@ func Test_getChains_simple(t *testing.T) {
8078
"E" -> "F"
8179
"F" -> "H"
8280
`
83-
fmt.Println(getFileContentsForAllDeps(overview))
8481
if correctFileContentsForAllDeps != getFileContentsForAllDeps(overview) {
8582
t.Errorf("File contents for graph of all dependencies are wrong")
8683
}
@@ -150,10 +147,9 @@ func Test_getChains_cycle(t *testing.T) {
150147
}
151148

152149
var cycleChains []Chain
153-
var longestChain Chain
154150
var chains []Chain
155151
var temp Chain
156-
getLongestChain("A", graph, temp, &longestChain)
152+
longestChain := getLongestChain("A", graph, temp, map[string]Chain{})
157153
maxDepth := len(longestChain)
158154
getCycleChains("A", graph, temp, &cycleChains)
159155
getAllChains("A", graph, temp, &chains)
@@ -244,10 +240,9 @@ func Test_getChains_cycle_2(t *testing.T) {
244240
}
245241

246242
var cycleChains []Chain
247-
var longestChain Chain
248243
var chains []Chain
249244
var temp Chain
250-
getLongestChain("A", graph, temp, &longestChain)
245+
longestChain := getLongestChain("A", graph, temp, map[string]Chain{})
251246
maxDepth := len(longestChain)
252247
getCycleChains("A", graph, temp, &cycleChains)
253248
getAllChains("A", graph, temp, &chains)
@@ -364,7 +359,7 @@ func getGoModGraphTestData() string {
364359
| \ | / \
365360
F C E
366361
*/
367-
goModGraphOutputString := `A@1.1 G@1.2
362+
goModGraphOutputString := `A@1.1 G@1.5
368363
A@1.1 B@1.3
369364
A@1.1 D@1.2
370365
G@1.5 F@1.3
@@ -413,3 +408,35 @@ func Test_generateGraph_custom_mainModule(t *testing.T) {
413408
t.Errorf("Expected transitive dependencies are %s but got %s", transitiveDependencyList, depGraph.TransDepList)
414409
}
415410
}
411+
412+
func Test_generateGraph_overridden_versions(t *testing.T) {
413+
mainModules := []string{"A", "D"}
414+
// obsolete C@v1 has a cycle with D@v1 and a transitive ref to unwanted dependency E@v1
415+
// effective version C@v2 updates to D@v2, which still has a cycle back to C@v2, but no dependency on E
416+
depGraph := generateGraph(`A B@v2
417+
A C@v2
418+
A D@v2
419+
B@v2 C@v1
420+
C@v1 D@v1
421+
D@v1 C@v1
422+
D@v1 E@v1
423+
C@v2 D@v2
424+
C@v2 F@v2
425+
D@v2 C@v2
426+
D@v2 G@v2`, mainModules)
427+
428+
transitiveDependencyList := []string{"C", "D", "F"}
429+
directDependencyList := []string{"B", "C", "G"}
430+
431+
if !isSliceSame(depGraph.MainModules, mainModules) {
432+
t.Errorf("Expected mainModules are %s but got %s", mainModules, depGraph.MainModules)
433+
}
434+
435+
if !isSliceSame(depGraph.DirectDepList, directDependencyList) {
436+
t.Errorf("Expected direct dependecies are %s but got %s", directDependencyList, depGraph.DirectDepList)
437+
}
438+
439+
if !isSliceSame(depGraph.TransDepList, transitiveDependencyList) {
440+
t.Errorf("Expected transitive dependencies are %s but got %s", transitiveDependencyList, depGraph.TransDepList)
441+
}
442+
}

0 commit comments

Comments
 (0)