blob: c6406c73a09e45ac66e82e206b627b1d3a395a02 [file] [log] [blame]
Vadim Bendeburybde09a92019-10-15 23:21:18 -07001// Copyright 2020 The Chromium OS Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//
6// A utility to report status of a repo tree, listing all git repositories wich
7// have branches or are not in sync with the upstream, works the same insde
8// and outside chroot.
9//
10// To install it run
11//
12// go build -o <directory in your PATH>/willis willis.go
13//
14// and to use it just run 'willis'
15//
16
17package main
18
19import (
20 "bytes"
21 "encoding/xml"
22 "errors"
23 "fmt"
24 "io/ioutil"
25 "os"
26 "os/exec"
27 "path"
28 "path/filepath"
29 "regexp"
30 "sort"
31 "strconv"
32 "strings"
33 "sync"
Junichi Uekawa773a86f2020-03-05 16:18:01 +090034 "time"
Vadim Bendeburybde09a92019-10-15 23:21:18 -070035)
36
37type project struct {
38 Remote string `xml:"remote,attr"`
39 Path string `xml:"path,attr"`
40 Revision string `xml:"revision,attr"`
41 Name string `xml:"name,attr"`
42 // Identifies the tracking branch
43 Tracking string
44}
45
46type defaultTracking struct {
47 Revision string `xml:"revision,attr"`
48 Remote string `xml:"remote,attr"`
49}
50
51type include struct {
52 Name string `xml:"name,attr"`
53}
54
55type remoteServer struct {
56 Name string `xml:"name,attr"`
57 Alias string `xml:"alias,attr"`
58}
59
60// manifest is a structure representing accumulated contents of all repo XML
61// manifest files.
62type manifest struct {
63 XMLName xml.Name `xml:"manifest"`
64 Dflt defaultTracking `xml:"default"`
65 Include []include `xml:"include"`
66 Projects []project `xml:"project"`
67 Remotes []remoteServer `xml:"remote"`
68}
69
70// gitTreeReport is used to represent information about a single git tree.
71type gitTreeReport struct {
72 branches string
73 status string
74 osErrors string
75 errorMsg string
76}
77
78// ProjectMap maps project paths into project structures.
79type ProjectMap map[string]project
80
81var reHex = regexp.MustCompile("^[0-9a-fA-F]+$")
82
83// reDetached and reNoBranch cover two possible default branch states.
84var reDetached = regexp.MustCompile(`^\* .*\(HEAD detached (?:at|from) (?:[^ ]+)\)[^ ]* ([^ ]+)`)
85var reNoBranch = regexp.MustCompile(`^\* .*\(no branch\)[^ ]* ([^ ]+) `)
86
87type color int
88
89const (
90 colorRed color = iota
91 colorBlue
92)
93
94func colorize(text string, newColor color) string {
95 var code string
96
97 switch newColor {
98 case colorRed:
99 code = "31"
100 break
101 case colorBlue:
102 code = "34"
103 break
104 default:
105 return text
106 }
107 return fmt.Sprintf("\x1b[%sm%s\x1b[m", code, text)
108}
109
110// getRepoManifest given the manifest directory return Chrome OS manifest.
111// This function starts with 'default.xml' in the manifest root directory,
112// goes through nested manifest files and returns a single manifest object
113// representing current expected repo state.
114func getRepoManifest(rootDir string) (*manifest, error) {
115 var manifest manifest
116
117 files := []string{path.Join(rootDir, "default.xml")}
118 for len(files) > 0 {
119 var file string
120
121 file, files = files[0], files[1:]
122
123 bytes, err := ioutil.ReadFile(file)
124 if err != nil {
125 return nil, err
126 }
127
128 // xml.Unmarshal keeps adding parsed data to the same manifest
129 // structure instance. When invoked with a non-empty manifest,
130 // xml.Unmarshal() does not zero out previously retrieved data
131 // fields even if they are not present in the currently
132 // supplied xml blob. Slices of objects (like project in the
133 // manifest case) keep being added to.
134 //
135 // Note that this behavior seems to contradict the spec which in
136 // https://golang.org/pkg/encoding/xml/#Unmarshal reads
137 //
138 // == quote ==
139 // A missing element or empty attribute value will be
140 // unmarshaled as a zero value.
141 // == quote end ==
142 //
143 // Should a golang update change the implementation, the failure
144 // of reading the manifests would be immediately obvious, the
145 // code will have to be changed then.
146 if err := xml.Unmarshal(bytes, &manifest); err != nil {
147 return nil, err
148 }
149
150 for _, inc := range manifest.Include {
151 files = append(files, path.Join(rootDir, inc.Name))
152 }
153
154 manifest.Include = nil
155 }
156 return &manifest, nil
157}
158
159func prepareProjectMap(repoRoot string) (*ProjectMap, error) {
160 manifest, err := getRepoManifest(path.Join(repoRoot, ".repo", "manifests"))
161 if err != nil {
162 return nil, err
163 }
164
165 // Set up mapping to remote server name aliases.
166 aliases := make(map[string]string)
167 for _, remote := range manifest.Remotes {
168 if remote.Alias != "" {
169 aliases[remote.Name] = remote.Alias
170 }
171 }
172
173 pm := make(ProjectMap)
174 for _, p := range manifest.Projects {
175 if p.Revision == "" {
176 p.Revision = manifest.Dflt.Revision
177 }
178 if p.Remote == "" {
179 p.Remote = manifest.Dflt.Remote
180 } else if alias, ok := aliases[p.Remote]; ok {
181 p.Remote = alias
182 }
183
184 if reHex.MatchString(p.Revision) {
185 p.Tracking = p.Revision
186 } else {
187 p.Tracking = p.Remote + "/" + strings.TrimPrefix(p.Revision, "refs/heads/")
188 }
189 pm[p.Path] = p
190 }
191 return &pm, nil
192}
193
194func findRepoRoot() (string, error) {
195 myPath, err := os.Getwd()
196 if err != nil {
197 return "", fmt.Errorf("failed to get current directory: %v", err)
198 }
199 for {
200 if myPath == "/" {
201 return "", errors.New("not running in a repo tree")
202 }
203 repo := path.Join(myPath, ".repo")
204 stat, err := os.Stat(repo)
205 if err != nil {
206 if !os.IsNotExist(err) {
207 return "", fmt.Errorf("cannot stat %s: %v", repo, err)
208 }
209 myPath = filepath.Dir(myPath)
210 continue
211 }
212
213 if !stat.IsDir() {
214 myPath = filepath.Dir(myPath)
215 continue
216 }
217 return myPath, err
218 }
219}
220
221// runCommand runs a shell command.
222// cmdArray is an array of strings starting with the command name and followed
223// by the command line paramters.
224// Returns two strinngs (stdout and stderr) and the error value.
225func runCommand(args ...string) (stdout, stderr string, err error) {
226 var outbuf bytes.Buffer
227 var errbuf bytes.Buffer
228
229 cmd := exec.Command(args[0], args[1:]...)
230 cmd.Stdout = &outbuf
231 cmd.Stderr = &errbuf
232 err = cmd.Run()
233
234 // To keep indentation intact, we don't want to change non-error git
235 // output formatting, but still want to strip the trainling newline in
236 // this output. Error output formatting does not need to be preserved,
237 // let's trim it on both sides.
238 stdout = strings.TrimRight(outbuf.String(), "\n")
239 stderr = strings.TrimSpace(errbuf.String())
240 return
241}
242
243// checkGitTree generates a text describing status of a git tree.
244// Status includes outputs of 'git branch' and 'git status' commands, thus
245// listing all branches in the current tree as well as its state (outstanding
246// files, git state, etc.).
247// Ignore 'git branch -vv' output in case there are no local branches and the
248// git tree is synced up with the tracking branch.
249func checkGitTree(gitPath string, tracking string) gitTreeReport {
250 stdout, stderr, err := runCommand("git", "-C", gitPath, "branch", "-vv", "--color")
251
252 if err != nil {
253 return gitTreeReport{
254 branches: stdout,
255 osErrors: stderr,
256 errorMsg: fmt.Sprintf("failed to retrieve branch information: %v", err)}
257 }
258
259 branches := strings.Split(stdout, "\n")
260
261 headOk := true
262 var sha string
263 for i, branch := range branches {
264 // Check for both possible default branch state outputs.
265 matches := reDetached.FindStringSubmatch(branch)
266 if len(matches) == 0 {
267 matches = reNoBranch.FindStringSubmatch(branch)
268 }
269 if len(matches) == 0 {
270 continue
271 }
272
273 // git sha of this tree.
274 sha = matches[1]
275
276 // Check if local git sha is the same as tracking branch.
277 stdout, stderr, err = runCommand("git", "-C", gitPath, "diff", sha, tracking)
278 if err != nil {
279 return gitTreeReport{
280 branches: stdout,
281 osErrors: stderr,
282 errorMsg: fmt.Sprintf("failed to compare branches: %v", err)}
283 }
284
285 if stdout != "" {
286 headOk = false
287 branches[i] = colorize("!!!! ", colorRed) + branch
288 }
289 break
290 }
291
292 stdout, stderr, err = runCommand("git", "-C", gitPath, "status", "-s")
293
294 if err != nil {
295 return gitTreeReport{
296 branches: stdout,
297 osErrors: stderr,
298 errorMsg: fmt.Sprintf("failed to retrieve status information: %v", err)}
299 }
300
301 var report gitTreeReport
302
303 if len(branches) != 1 || sha == "" || !headOk || stdout != "" {
304 report.branches = strings.Join(branches, "\n")
305 report.status = stdout
306 }
307
308 return report
309}
310
311func reportProgress(startedCounter, runningCounter int) {
312 fmt.Printf("Started %3d still going %3d\r", startedCounter, runningCounter)
313}
314
315func printResults(results map[string]gitTreeReport) {
316 var keys []string
317
318 for key, result := range results {
319 if result.branches+result.status+result.osErrors+result.errorMsg == "" {
320 continue
321 }
322 keys = append(keys, key)
323 }
324
325 sort.Strings(keys)
326
327 fmt.Println() // Go down from status stats line.
328 for _, key := range keys {
329 fmt.Printf("%s\n", colorize(key, colorBlue))
330 if results[key].errorMsg != "" {
331 fmt.Printf("%s\n", colorize(results[key].errorMsg, colorRed))
332 }
333 if results[key].osErrors != "" {
334 fmt.Printf("%s\n", colorize(results[key].osErrors, colorRed))
335 }
336 if results[key].branches != "" {
337 fmt.Printf("%s\n", results[key].branches)
338 }
339 if results[key].status != "" {
340 fmt.Printf("%s\n", results[key].status)
341 }
342 fmt.Println()
343 }
344
345}
346
347// getMaxGoCount - suggest maximum number of concurrent go routines.
348// Determine current limit of number of open files and return the suggested
349// maximum number of concurrent go routines. Conservatively keep it at 10% of
350// the number of open files limit.
351func getMaxGoCount() (int, error) {
352 stdout, _, err := runCommand("sh", "-c", "ulimit -Sn")
353 if err != nil {
354 return 0, err
355 }
356 limit, err := strconv.Atoi(stdout)
357 if err != nil {
358 return 0, err
359 }
360 // This asssumes that the max number of opened files limit exceeds 10,
361 // which is deemed a very reasonable assumption.
362 return (limit / 10), nil
363}
364
365func main() {
366 repoRoot, err := findRepoRoot()
367 if err != nil {
368 fmt.Fprintln(os.Stderr, err)
369 os.Exit(1)
370 }
371
372 pm, err := prepareProjectMap(repoRoot)
373 if err != nil {
374 fmt.Fprintln(os.Stderr, err)
375 os.Exit(1)
376 }
377
378 // project map (pm) includes all projects present in xml files in
379 // .repo/manifests, but not all of them might be included in the repo
380 // checkout, let's trust 'repo list' command to report the correct
381 // list of projects.
382 repos, stderr, err := runCommand("repo", "list")
383 if err != nil {
384 fmt.Fprintln(os.Stderr, stderr)
385 os.Exit(1)
386 }
387
388 var countMtx sync.Mutex
389
390 startedCounter := 0
391 runningCounter := 0
392 results := make(map[string]gitTreeReport)
393 cwd, err := os.Getwd()
394 if err != nil {
395 fmt.Fprintln(os.Stderr, err)
396 os.Exit(1)
397 }
398
399 maxGoCount, err := getMaxGoCount()
400 if err != nil {
401 fmt.Fprintf(os.Stderr, "Failed to get max go routine count: %v\n", err)
402 os.Exit(1)
403 }
404
405 repoList := strings.Split(repos, "\n")
406
407 throttlingNeeded := maxGoCount < len(repoList)
408 var ch chan bool
409 if throttlingNeeded {
410 // Create a channel to use it as a throttle to prevent from starting
411 // too many git queries concurrently.
412 ch = make(chan bool, maxGoCount)
413 fmt.Printf("Throttling at %d concurrent checks\n", maxGoCount)
414 }
415
416 var wg sync.WaitGroup
417 for _, line := range repoList {
418 gitPath := strings.TrimSpace(strings.Split(line, ":")[0])
419 wg.Add(1)
420 go func() {
421 defer func() {
422 runningCounter--
423 countMtx.Unlock()
424 if throttlingNeeded {
425 <-ch
426 }
427 wg.Done()
428 }()
429 if throttlingNeeded {
430 ch <- true
431 }
432 countMtx.Lock()
433 startedCounter++
434 runningCounter++
435 countMtx.Unlock()
436 gitTree := path.Join(repoRoot, gitPath)
437 report := checkGitTree(gitTree, (*pm)[gitPath].Tracking)
438
439 relpath, err := filepath.Rel(cwd, gitTree)
440
441 if err != nil {
442 fmt.Fprintln(os.Stderr, stderr)
443 // In the unlikely event of filepath.Rel()
444 // failing, use full git path as the key in
445 // the results map.
446 relpath = gitPath
447 }
448
449 countMtx.Lock()
450 results[relpath] = report
451 reportProgress(startedCounter, runningCounter)
452 }()
453 }
454
455 stillRunning := true
456 for stillRunning {
Junichi Uekawa773a86f2020-03-05 16:18:01 +0900457 time.Sleep(time.Second)
Vadim Bendeburybde09a92019-10-15 23:21:18 -0700458 countMtx.Lock()
459 reportProgress(startedCounter, runningCounter)
460 if runningCounter == 0 {
461 stillRunning = false
462 }
463 countMtx.Unlock()
464 }
465 wg.Wait()
466
467 printResults(results)
468}