Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 1 | // 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 | |
| 17 | package main |
| 18 | |
| 19 | import ( |
| 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 Uekawa | 331f13e | 2020-08-06 10:40:23 +0900 | [diff] [blame] | 30 | "runtime" |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 31 | "sort" |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 32 | "strings" |
| 33 | "sync" |
Junichi Uekawa | 773a86f | 2020-03-05 16:18:01 +0900 | [diff] [blame] | 34 | "time" |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 35 | ) |
| 36 | |
| 37 | type 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 | |
| 46 | type defaultTracking struct { |
| 47 | Revision string `xml:"revision,attr"` |
| 48 | Remote string `xml:"remote,attr"` |
| 49 | } |
| 50 | |
| 51 | type include struct { |
| 52 | Name string `xml:"name,attr"` |
| 53 | } |
| 54 | |
| 55 | type 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. |
| 62 | type 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. |
| 71 | type gitTreeReport struct { |
| 72 | branches string |
| 73 | status string |
| 74 | osErrors string |
| 75 | errorMsg string |
| 76 | } |
| 77 | |
| 78 | // ProjectMap maps project paths into project structures. |
| 79 | type ProjectMap map[string]project |
| 80 | |
| 81 | var reHex = regexp.MustCompile("^[0-9a-fA-F]+$") |
| 82 | |
| 83 | // reDetached and reNoBranch cover two possible default branch states. |
| 84 | var reDetached = regexp.MustCompile(`^\* .*\(HEAD detached (?:at|from) (?:[^ ]+)\)[^ ]* ([^ ]+)`) |
| 85 | var reNoBranch = regexp.MustCompile(`^\* .*\(no branch\)[^ ]* ([^ ]+) `) |
| 86 | |
| 87 | type color int |
| 88 | |
| 89 | const ( |
| 90 | colorRed color = iota |
| 91 | colorBlue |
| 92 | ) |
| 93 | |
| 94 | func 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. |
| 114 | func 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 | |
| 159 | func 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 | |
| 194 | func 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. |
| 225 | func 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. |
| 249 | func 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 | |
| 311 | func reportProgress(startedCounter, runningCounter int) { |
Junichi Uekawa | b0a4a29 | 2020-08-06 14:53:28 +0900 | [diff] [blame^] | 312 | // Use unbuffered write so that output is updated even without |
| 313 | // a \n. |
| 314 | os.Stdout.WriteString(fmt.Sprintf("Started %3d still going %3d\r", startedCounter, runningCounter)) |
| 315 | |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 316 | } |
| 317 | |
| 318 | func printResults(results map[string]gitTreeReport) { |
| 319 | var keys []string |
| 320 | |
| 321 | for key, result := range results { |
| 322 | if result.branches+result.status+result.osErrors+result.errorMsg == "" { |
| 323 | continue |
| 324 | } |
| 325 | keys = append(keys, key) |
| 326 | } |
| 327 | |
| 328 | sort.Strings(keys) |
| 329 | |
| 330 | fmt.Println() // Go down from status stats line. |
| 331 | for _, key := range keys { |
| 332 | fmt.Printf("%s\n", colorize(key, colorBlue)) |
| 333 | if results[key].errorMsg != "" { |
| 334 | fmt.Printf("%s\n", colorize(results[key].errorMsg, colorRed)) |
| 335 | } |
| 336 | if results[key].osErrors != "" { |
| 337 | fmt.Printf("%s\n", colorize(results[key].osErrors, colorRed)) |
| 338 | } |
| 339 | if results[key].branches != "" { |
| 340 | fmt.Printf("%s\n", results[key].branches) |
| 341 | } |
| 342 | if results[key].status != "" { |
| 343 | fmt.Printf("%s\n", results[key].status) |
| 344 | } |
| 345 | fmt.Println() |
| 346 | } |
| 347 | |
| 348 | } |
| 349 | |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 350 | func main() { |
| 351 | repoRoot, err := findRepoRoot() |
| 352 | if err != nil { |
| 353 | fmt.Fprintln(os.Stderr, err) |
| 354 | os.Exit(1) |
| 355 | } |
| 356 | |
| 357 | pm, err := prepareProjectMap(repoRoot) |
| 358 | if err != nil { |
| 359 | fmt.Fprintln(os.Stderr, err) |
| 360 | os.Exit(1) |
| 361 | } |
| 362 | |
| 363 | // project map (pm) includes all projects present in xml files in |
| 364 | // .repo/manifests, but not all of them might be included in the repo |
| 365 | // checkout, let's trust 'repo list' command to report the correct |
| 366 | // list of projects. |
| 367 | repos, stderr, err := runCommand("repo", "list") |
| 368 | if err != nil { |
| 369 | fmt.Fprintln(os.Stderr, stderr) |
| 370 | os.Exit(1) |
| 371 | } |
| 372 | |
| 373 | var countMtx sync.Mutex |
| 374 | |
| 375 | startedCounter := 0 |
| 376 | runningCounter := 0 |
| 377 | results := make(map[string]gitTreeReport) |
| 378 | cwd, err := os.Getwd() |
| 379 | if err != nil { |
| 380 | fmt.Fprintln(os.Stderr, err) |
| 381 | os.Exit(1) |
| 382 | } |
| 383 | |
Junichi Uekawa | 331f13e | 2020-08-06 10:40:23 +0900 | [diff] [blame] | 384 | // Use the number of cores as number of goroutines. Because we |
| 385 | // are fork/exec multiple instances, exceeding the number of |
| 386 | // cores does not give us much gain. |
| 387 | maxGoCount := runtime.NumCPU() |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 388 | |
| 389 | repoList := strings.Split(repos, "\n") |
| 390 | |
Junichi Uekawa | 7c77164 | 2020-08-06 14:41:50 +0900 | [diff] [blame] | 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) |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 394 | |
| 395 | var wg sync.WaitGroup |
| 396 | for _, line := range repoList { |
| 397 | gitPath := strings.TrimSpace(strings.Split(line, ":")[0]) |
| 398 | wg.Add(1) |
| 399 | go func() { |
| 400 | defer func() { |
| 401 | runningCounter-- |
| 402 | countMtx.Unlock() |
Junichi Uekawa | 7c77164 | 2020-08-06 14:41:50 +0900 | [diff] [blame] | 403 | <-ch |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 404 | wg.Done() |
| 405 | }() |
Junichi Uekawa | 7c77164 | 2020-08-06 14:41:50 +0900 | [diff] [blame] | 406 | ch <- true |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 407 | countMtx.Lock() |
| 408 | startedCounter++ |
| 409 | runningCounter++ |
| 410 | countMtx.Unlock() |
| 411 | gitTree := path.Join(repoRoot, gitPath) |
| 412 | report := checkGitTree(gitTree, (*pm)[gitPath].Tracking) |
| 413 | |
| 414 | relpath, err := filepath.Rel(cwd, gitTree) |
| 415 | |
| 416 | if err != nil { |
| 417 | fmt.Fprintln(os.Stderr, stderr) |
| 418 | // In the unlikely event of filepath.Rel() |
| 419 | // failing, use full git path as the key in |
| 420 | // the results map. |
| 421 | relpath = gitPath |
| 422 | } |
| 423 | |
| 424 | countMtx.Lock() |
| 425 | results[relpath] = report |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 426 | }() |
| 427 | } |
| 428 | |
Junichi Uekawa | b0a4a29 | 2020-08-06 14:53:28 +0900 | [diff] [blame^] | 429 | // Update the progress 30 times a second. |
| 430 | finishProgressReporting := make(chan bool) |
| 431 | progressReportingFinished := make(chan struct{}) |
| 432 | go func() { |
| 433 | for { |
| 434 | countMtx.Lock() |
| 435 | reportProgress(startedCounter, runningCounter) |
| 436 | select { |
| 437 | case <-finishProgressReporting: |
| 438 | // Finish. |
| 439 | close(progressReportingFinished) |
| 440 | countMtx.Unlock() |
| 441 | return |
| 442 | default: |
| 443 | // Keep on running if chan is still open. |
| 444 | countMtx.Unlock() |
| 445 | } |
| 446 | time.Sleep(time.Second / 30) |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 447 | } |
Junichi Uekawa | b0a4a29 | 2020-08-06 14:53:28 +0900 | [diff] [blame^] | 448 | }() |
| 449 | |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 450 | wg.Wait() |
| 451 | |
Junichi Uekawa | b0a4a29 | 2020-08-06 14:53:28 +0900 | [diff] [blame^] | 452 | finishProgressReporting <- true |
| 453 | <-progressReportingFinished |
| 454 | |
Vadim Bendebury | bde09a9 | 2019-10-15 23:21:18 -0700 | [diff] [blame] | 455 | printResults(results) |
| 456 | } |