| // Copyright 2022 The Tint Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // git-stats gathers statistics about changes made to a git repo. |
| package main |
| |
| import ( |
| "flag" |
| "fmt" |
| "os" |
| "os/exec" |
| "regexp" |
| "runtime" |
| "sort" |
| "strings" |
| "sync" |
| "text/tabwriter" |
| "time" |
| |
| "dawn.googlesource.com/dawn/tools/src/container" |
| "dawn.googlesource.com/dawn/tools/src/git" |
| ) |
| |
| // Flags |
| var ( |
| repo = flag.String("repo", ".", "path to git directory") |
| afterFlag = flag.String("after", "", "start date") |
| beforeFlag = flag.String("before", "", "end date") |
| daysFlag = flag.Int("days", 182, "interval in days (used if --after is not specified)") |
| ) |
| |
| // main entry point |
| func main() { |
| flag.Parse() |
| if err := run(); err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| os.Exit(1) |
| } |
| } |
| |
| // Date format strings |
| const ( |
| yyyymmdd = "2006-01-02" |
| yyyymm = "2006-01" |
| ) |
| |
| // Returns true if the file with the given path should be included for addition / deletion stats. |
| func shouldConsiderLinesOfFile(path string) bool { |
| for _, ignore := range []string{ |
| "Doxyfile", |
| "package-lock.json", |
| "src/tint/builtin_table.inl", |
| "src/tint/lang/core/intrinsic/table.inl", |
| "src/tint/lang/core/*.cc", |
| "src/tint/lang/core/*.h", |
| "test/tint/", |
| "third_party/gn/webgpu-cts/test_list.txt", |
| "third_party/khronos/", |
| "webgpu-cts/", |
| "src/external/petamoriken", |
| } { |
| if strings.HasPrefix(path, ignore) { |
| return false |
| } |
| } |
| return true |
| } |
| |
| // Returns true if the commit with the given hash should be included for addition / deletion stats. |
| func shouldConsiderLinesOfCommit(hash string) bool { |
| for _, ignore := range []string{ |
| "41e4d9a34c1d9dcb2eef3ff39ff9c1f987bfa02a", // Consistent formatting for Dawn/Tint. |
| "e87ac76f7ddf9237f3022cda90224bd0691fb318", // Merge tint -> dawn |
| "b0acbd436dbd499505a3fa8bf89e69231ec4d1e0", // Fix build/namespaces issues |
| } { |
| if hash == ignore { |
| return false |
| } |
| } |
| return true |
| } |
| |
| // Regular expression used to parse the email from an author string. Example: |
| // Bob Bobson <bob@bobmail.com> |
| // ____________^^^^^^^^^^^^^^^_ |
| var reEmail = regexp.MustCompile(`<([^>]+)>`) |
| |
| func run() error { |
| // Parse the --after and --before flags |
| var after, before time.Time |
| var err error |
| if *beforeFlag != "" { |
| before, err = time.Parse(yyyymmdd, *beforeFlag) |
| if err != nil { |
| return fmt.Errorf("Couldn't parse before date: %w", err) |
| } |
| } else { |
| before = time.Now() |
| } |
| if *afterFlag != "" { |
| after, err = time.Parse(yyyymmdd, *afterFlag) |
| if err != nil { |
| return fmt.Errorf("Couldn't parse after date: %w", err) |
| } |
| } else { |
| after = before.Add(-time.Hour * time.Duration(24**daysFlag)) |
| } |
| |
| // Find 'git' |
| gitExe, err := exec.LookPath("git") |
| if err != nil { |
| return err |
| } |
| |
| // Create the git.Git wrapper |
| g, err := git.New(gitExe) |
| if err != nil { |
| return err |
| } |
| |
| // Open the repo |
| r, err := g.Open(*repo) |
| if err != nil { |
| return err |
| } |
| |
| // Information obtained about a single commit |
| type CommitStat struct { |
| author string |
| commit *git.CommitInfo |
| insertions int |
| deletions int |
| fileDeltas container.Map[string, int] |
| } |
| |
| // Kick a goroutine to gather all the commits in the git log between |
| // 'after' and 'before', streaming the commits to the 'commits' chan. |
| // This chan will be closed by the goroutine when all commits have been |
| // gathered. |
| commits := make(chan git.CommitInfo, 256) |
| go func() { |
| log, err := r.LogBetween(after, before, &git.LogBetweenOptions{}) |
| if err != nil { |
| panic(fmt.Errorf("failed to gather commits: %w", err)) |
| } |
| for _, commit := range log { |
| commits <- commit |
| } |
| close(commits) |
| }() |
| |
| // Kick 'numWorkers' goroutines to gather the commit statistics of the |
| // commits in the 'commits' chan, streaming the commit statistics to the |
| // 'commitStats' chan. |
| commitStats := make(chan CommitStat, 256) |
| numWorkers := runtime.NumCPU() |
| wg := sync.WaitGroup{} |
| wg.Add(numWorkers) |
| for worker := 0; worker < numWorkers; worker++ { |
| go func() { |
| defer wg.Done() |
| for commit := range commits { |
| commit := commit |
| email := reEmail.FindStringSubmatch(commit.Author)[1] |
| stats, err := r.Stats(commit, nil) |
| if err != nil { |
| panic(fmt.Errorf("failed to get stats for commit '%v': %w", commit.Hash, err)) |
| } |
| |
| s := CommitStat{ |
| author: email, |
| commit: &commit, |
| fileDeltas: container.NewMap[string, int](), |
| } |
| if shouldConsiderLinesOfCommit(commit.Hash.String()) { |
| for file, stats := range stats { |
| if shouldConsiderLinesOfFile(file) { |
| s.insertions += stats.Insertions |
| s.deletions += stats.Deletions |
| s.fileDeltas[file] = stats.Insertions + stats.Deletions |
| } |
| } |
| } |
| commitStats <- s |
| } |
| }() |
| } |
| |
| // Kick a helper goroutine that waits for all the goroutines that feed the |
| // 'commitStats' chan to complete, and then closes the 'commitStats' chan. |
| go func() { |
| wg.Wait() |
| close(commitStats) |
| }() |
| |
| // CommitDelta holds the sum of line additions and deletions for a given |
| // commit. |
| type CommitDelta struct { |
| commit *git.CommitInfo |
| delta int |
| } |
| |
| // Stream in the commit statistics from the 'commitStats' chan, and collect |
| // statistics by author and by file. |
| statsByAuthor := container.NewMap[string, AuthorStats]() |
| fileDeltas := container.NewMap[string, int]() |
| commitDeltas := []CommitDelta{} |
| for cs := range commitStats { |
| as := statsByAuthor[cs.author] |
| as.insertions += cs.insertions |
| as.deletions += cs.deletions |
| as.commits++ |
| if as.commitsByMonth == nil { |
| as.commitsByMonth = container.NewMap[string, int]() |
| } |
| month := cs.commit.Date.Format(yyyymm) |
| as.commitsByMonth[month] = as.commitsByMonth[month] + 1 |
| statsByAuthor[cs.author] = as |
| |
| commitDelta := 0 |
| for path, delta := range cs.fileDeltas { |
| fileDeltas[path] = fileDeltas[path] + delta |
| commitDelta += delta |
| } |
| commitDeltas = append(commitDeltas, CommitDelta{cs.commit, commitDelta}) |
| } |
| |
| // Transform the 'statsByAuthor' map, so that authors that have statistics |
| // for both a @google.com and @chromium.org account have all their |
| // statistics merged into the @google.com account. |
| for google, g := range statsByAuthor { |
| if strings.HasSuffix(google, "@google.com") { |
| combined := strings.TrimSuffix(google, "@google.com") |
| chromium := combined + "@chromium.org" |
| if c, hasChromium := statsByAuthor[chromium]; hasChromium { |
| statsByAuthor[google] = combine(g, c) |
| delete(statsByAuthor, chromium) |
| } |
| } |
| } |
| |
| // Print those stats! |
| |
| fmt.Printf("Between %v and %v:\n", after, before) |
| |
| // Print the top 10 most modified files. |
| // This is helpful to identify files that are automatically generated, which |
| // we should exclude from the statistics. |
| { |
| type FileDelta struct { |
| file string |
| delta int |
| } |
| l := make([]FileDelta, 0, len(fileDeltas)) |
| for file, delta := range fileDeltas { |
| l = append(l, FileDelta{file, delta}) |
| } |
| sort.Slice(l, func(i, j int) bool { return l[i].delta > l[j].delta }) |
| n := len(l) |
| if n > 10 { |
| n = 10 |
| } |
| fmt.Println() |
| fmt.Printf("Top %v most modified files:\n", n) |
| fmt.Println() |
| tw := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', 0) |
| fmt.Fprintln(tw, " delta\t | file") |
| for _, fd := range l[:n] { |
| fmt.Fprintln(tw, |
| " ", fd.delta, |
| "\t |", fd.file) |
| } |
| tw.Flush() |
| } |
| |
| // Print the top 10 largest commits. |
| // This is helpful to identify commits that may contain a large bulk |
| // refactor, which we should exclude from the statistics. |
| { |
| sort.Slice(commitDeltas, func(i, j int) bool { |
| return commitDeltas[i].delta > commitDeltas[j].delta |
| }) |
| n := len(commitDeltas) |
| if n > 10 { |
| n = 10 |
| } |
| fmt.Println() |
| fmt.Printf("Top %v largest commits:\n", n) |
| fmt.Println() |
| tw := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', 0) |
| fmt.Fprintln(tw, |
| " delta\t | author\t | hash\t | description") |
| for _, fd := range commitDeltas[:n] { |
| fmt.Fprintln(tw, |
| " ", fd.delta, |
| "\t |", fd.commit.Author, |
| "\t |", fd.commit.Hash.String()[:6], |
| "\t |", fd.commit.Subject) |
| } |
| tw.Flush() |
| } |
| |
| // Print the contributions by author. |
| { |
| fmt.Println() |
| fmt.Println("Total contributions by author:") |
| tw := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', 0) |
| fmt.Println() |
| fmt.Fprintln(tw, " author\t | commits\t | added\t | removed") |
| for _, author := range statsByAuthor.Keys() { |
| s := statsByAuthor[author] |
| fmt.Fprintln(tw, |
| " "+author, |
| "\t |", s.commits, |
| "\t |", s.insertions, |
| "\t |", s.deletions) |
| } |
| tw.Flush() |
| } |
| |
| // Print the per-author contributions by month. |
| { |
| allMonths := container.NewSet[string]() |
| for _, author := range statsByAuthor { |
| for month := range author.commitsByMonth { |
| allMonths.Add(month) |
| } |
| } |
| |
| months := allMonths.List() |
| |
| fmt.Println() |
| fmt.Println("Commits by author by month:") |
| tw := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', 0) |
| fmt.Println() |
| fmt.Fprintf(tw, " author") |
| for _, month := range months { |
| fmt.Fprint(tw, "\t | ", month) |
| } |
| fmt.Fprintln(tw) |
| |
| for _, author := range statsByAuthor.Keys() { |
| fmt.Fprint(tw, " ", author) |
| cbm := statsByAuthor[author].commitsByMonth |
| for _, month := range months { |
| fmt.Fprint(tw, "\t | ", cbm[month]) |
| } |
| fmt.Fprintln(tw) |
| } |
| tw.Flush() |
| } |
| |
| return nil |
| } |
| |
| type AuthorStats struct { |
| commits int |
| commitsByMonth container.Map[string, int] |
| insertions int |
| deletions int |
| } |
| |
| // combine returns a new AuthorStats, with the summed statistics of 'a' and 'b'. |
| func combine(a, b AuthorStats) AuthorStats { |
| out := AuthorStats{ |
| commits: a.commits + b.commits, |
| insertions: a.insertions + b.insertions, |
| deletions: a.deletions + b.deletions, |
| } |
| out.commitsByMonth = container.NewMap[string, int]() |
| for month, commits := range a.commitsByMonth { |
| out.commitsByMonth[month] = commits |
| } |
| for month, commits := range b.commitsByMonth { |
| out.commitsByMonth[month] = out.commitsByMonth[month] + commits |
| } |
| return out |
| } |
| |
| func today() time.Time { |
| return time.Now() |
| } |
| |
| func date(t time.Time) string { |
| return t.Format(yyyymmdd) |
| } |