blob: 9e01754f53a3ce4233641887353c26aa0a831545 [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"
Junichi Uekawa331f13e2020-08-06 10:40:23 +090030 "runtime"
Vadim Bendeburybde09a92019-10-15 23:21:18 -070031 "sort"
Vadim Bendeburybde09a92019-10-15 23:21:18 -070032 "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
Vadim Bendeburybde09a92019-10-15 23:21:18 -0700347func main() {
348 repoRoot, err := findRepoRoot()
349 if err != nil {
350 fmt.Fprintln(os.Stderr, err)
351 os.Exit(1)
352 }
353
354 pm, err := prepareProjectMap(repoRoot)
355 if err != nil {
356 fmt.Fprintln(os.Stderr, err)
357 os.Exit(1)
358 }
359
360 // project map (pm) includes all projects present in xml files in
361 // .repo/manifests, but not all of them might be included in the repo
362 // checkout, let's trust 'repo list' command to report the correct
363 // list of projects.
364 repos, stderr, err := runCommand("repo", "list")
365 if err != nil {
366 fmt.Fprintln(os.Stderr, stderr)
367 os.Exit(1)
368 }
369
370 var countMtx sync.Mutex
371
372 startedCounter := 0
373 runningCounter := 0
374 results := make(map[string]gitTreeReport)
375 cwd, err := os.Getwd()
376 if err != nil {
377 fmt.Fprintln(os.Stderr, err)
378 os.Exit(1)
379 }
380
Junichi Uekawa331f13e2020-08-06 10:40:23 +0900381 // Use the number of cores as number of goroutines. Because we
382 // are fork/exec multiple instances, exceeding the number of
383 // cores does not give us much gain.
384 maxGoCount := runtime.NumCPU()
Vadim Bendeburybde09a92019-10-15 23:21:18 -0700385
386 repoList := strings.Split(repos, "\n")
387
388 throttlingNeeded := maxGoCount < len(repoList)
389 var ch chan bool
390 if throttlingNeeded {
391 // Create a channel to use it as a throttle to prevent from starting
392 // too many git queries concurrently.
393 ch = make(chan bool, maxGoCount)
394 fmt.Printf("Throttling at %d concurrent checks\n", maxGoCount)
395 }
396
397 var wg sync.WaitGroup
398 for _, line := range repoList {
399 gitPath := strings.TrimSpace(strings.Split(line, ":")[0])
400 wg.Add(1)
401 go func() {
402 defer func() {
403 runningCounter--
404 countMtx.Unlock()
405 if throttlingNeeded {
406 <-ch
407 }
408 wg.Done()
409 }()
410 if throttlingNeeded {
411 ch <- true
412 }
413 countMtx.Lock()
414 startedCounter++
415 runningCounter++
416 countMtx.Unlock()
417 gitTree := path.Join(repoRoot, gitPath)
418 report := checkGitTree(gitTree, (*pm)[gitPath].Tracking)
419
420 relpath, err := filepath.Rel(cwd, gitTree)
421
422 if err != nil {
423 fmt.Fprintln(os.Stderr, stderr)
424 // In the unlikely event of filepath.Rel()
425 // failing, use full git path as the key in
426 // the results map.
427 relpath = gitPath
428 }
429
430 countMtx.Lock()
431 results[relpath] = report
432 reportProgress(startedCounter, runningCounter)
433 }()
434 }
435
436 stillRunning := true
437 for stillRunning {
Junichi Uekawa773a86f2020-03-05 16:18:01 +0900438 time.Sleep(time.Second)
Vadim Bendeburybde09a92019-10-15 23:21:18 -0700439 countMtx.Lock()
440 reportProgress(startedCounter, runningCounter)
441 if runningCounter == 0 {
442 stillRunning = false
443 }
444 countMtx.Unlock()
445 }
446 wg.Wait()
447
448 printResults(results)
449}