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" |
| 30 | "sort" |
| 31 | "strconv" |
| 32 | "strings" |
| 33 | "sync" |
| 34 | ) |
| 35 | |
| 36 | type 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 | |
| 45 | type defaultTracking struct { |
| 46 | Revision string `xml:"revision,attr"` |
| 47 | Remote string `xml:"remote,attr"` |
| 48 | } |
| 49 | |
| 50 | type include struct { |
| 51 | Name string `xml:"name,attr"` |
| 52 | } |
| 53 | |
| 54 | type 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. |
| 61 | type 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. |
| 70 | type gitTreeReport struct { |
| 71 | branches string |
| 72 | status string |
| 73 | osErrors string |
| 74 | errorMsg string |
| 75 | } |
| 76 | |
| 77 | // ProjectMap maps project paths into project structures. |
| 78 | type ProjectMap map[string]project |
| 79 | |
| 80 | var reHex = regexp.MustCompile("^[0-9a-fA-F]+$") |
| 81 | |
| 82 | // reDetached and reNoBranch cover two possible default branch states. |
| 83 | var reDetached = regexp.MustCompile(`^\* .*\(HEAD detached (?:at|from) (?:[^ ]+)\)[^ ]* ([^ ]+)`) |
| 84 | var reNoBranch = regexp.MustCompile(`^\* .*\(no branch\)[^ ]* ([^ ]+) `) |
| 85 | |
| 86 | type color int |
| 87 | |
| 88 | const ( |
| 89 | colorRed color = iota |
| 90 | colorBlue |
| 91 | ) |
| 92 | |
| 93 | func 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. |
| 113 | func 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 | |
| 158 | func 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 | |
| 193 | func 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. |
| 224 | func 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. |
| 248 | func 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 | |
| 310 | func reportProgress(startedCounter, runningCounter int) { |
| 311 | fmt.Printf("Started %3d still going %3d\r", startedCounter, runningCounter) |
| 312 | } |
| 313 | |
| 314 | func 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. |
| 350 | func 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 | |
| 364 | func 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 | } |