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