Browse Source

Add protobuf output format

Vadim Markovtsev 7 years ago
parent
commit
f4ed0405b1
7 changed files with 868 additions and 189 deletions
  1. 6 0
      .gitignore
  2. 130 168
      cmd/hercules/main.go
  3. 24 21
      couples.go
  4. 407 0
      pb/pb.pb.go
  5. 76 0
      pb/pb.proto
  6. 75 0
      pb/utils.go
  7. 150 0
      stdout/utils.go

+ 6 - 0
.gitignore

@@ -22,3 +22,9 @@ _testmain.go
 *.exe
 *.test
 *.prof
+
+*.tsv
+*.tsv.gz
+*.json
+
+**/__pycache__

+ 130 - 168
cmd/hercules/main.go

@@ -14,7 +14,6 @@ import (
 	"os"
 	"runtime/pprof"
 	"sort"
-	"strconv"
 	"strings"
 
 	"gopkg.in/src-d/go-billy.v3/osfs"
@@ -24,143 +23,11 @@ import (
 	"gopkg.in/src-d/go-git.v4/storage/filesystem"
 	"gopkg.in/src-d/go-git.v4/storage/memory"
 	"gopkg.in/src-d/hercules.v2"
+	"gopkg.in/src-d/hercules.v2/stdout"
+	"gopkg.in/src-d/hercules.v2/pb"
+	"github.com/gogo/protobuf/proto"
 )
 
-func safeString(str string) string {
-	str = strings.Replace(str, "\\", "\\\\", -1)
-	str = strings.Replace(str, "\"", "\\\"", -1)
-	return "\"" + str + "\""
-}
-
-func printMatrix(matrix [][]int64, name string, fixNegative bool) {
-	// determine the maximum length of each value
-	var maxnum int64 = -(1 << 32)
-	var minnum int64 = 1 << 32
-	for _, status := range matrix {
-		for _, val := range status {
-			if val > maxnum {
-				maxnum = val
-			}
-			if val < minnum {
-				minnum = val
-			}
-		}
-	}
-	width := len(strconv.FormatInt(maxnum, 10))
-	if !fixNegative && minnum < 0 {
-		width = len(strconv.FormatInt(minnum, 10))
-	}
-	last := len(matrix[len(matrix)-1])
-	indent := 2
-	if name != "" {
-		fmt.Printf("  %s: |-\n", safeString(name))
-		indent += 2
-	}
-	// print the resulting triangular matrix
-	for _, status := range matrix {
-		fmt.Print(strings.Repeat(" ", indent-1))
-		for i := 0; i < last; i++ {
-			var val int64
-			if i < len(status) {
-				val = status[i]
-				// not sure why this sometimes happens...
-				// TODO(vmarkovtsev): find the root cause of tiny negative balances
-				if fixNegative && val < 0 {
-					val = 0
-				}
-			}
-			fmt.Printf(" %[1]*[2]d", width, val)
-		}
-		fmt.Println()
-	}
-}
-
-func printCouples(result *hercules.CouplesResult, peopleDict []string) {
-	fmt.Println("files_coocc:")
-	fmt.Println("  index:")
-	for _, file := range result.Files {
-		fmt.Printf("    - %s\n", safeString(file))
-	}
-
-	fmt.Println("  matrix:")
-	for _, files := range result.FilesMatrix {
-		fmt.Print("    - {")
-		indices := []int{}
-		for file := range files {
-			indices = append(indices, file)
-		}
-		sort.Ints(indices)
-		for i, file := range indices {
-			fmt.Printf("%d: %d", file, files[file])
-			if i < len(indices)-1 {
-				fmt.Print(", ")
-			}
-		}
-		fmt.Println("}")
-	}
-
-	fmt.Println("people_coocc:")
-	fmt.Println("  index:")
-	for _, person := range peopleDict {
-		fmt.Printf("    - %s\n", safeString(person))
-	}
-
-	fmt.Println("  matrix:")
-	for _, people := range result.PeopleMatrix {
-		fmt.Print("    - {")
-		indices := []int{}
-		for file := range people {
-			indices = append(indices, file)
-		}
-		sort.Ints(indices)
-		for i, person := range indices {
-			fmt.Printf("%d: %d", person, people[person])
-			if i < len(indices)-1 {
-				fmt.Print(", ")
-			}
-		}
-		fmt.Println("}")
-	}
-
-	fmt.Println("  author_files:") // sorted by number of files each author changed
-	peopleFiles := sortByNumberOfFiles(result.PeopleFiles, peopleDict)
-	for _, authorFiles := range peopleFiles {
-		fmt.Printf("    - %s:\n", safeString(authorFiles.Author))
-		sort.Strings(authorFiles.Files)
-		for _, file := range authorFiles.Files {
-			fmt.Printf("      - %s\n", safeString(file)) // sorted by path
-		}
-	}
-}
-
-func sortByNumberOfFiles(peopleFiles [][]string, peopleDict []string) AuthorFilesList {
-	var pfl AuthorFilesList
-	for peopleIdx, files := range peopleFiles {
-		if peopleIdx < len(peopleDict) {
-			pfl = append(pfl, AuthorFiles{peopleDict[peopleIdx], files})
-		}
-	}
-	sort.Sort(pfl)
-	return pfl
-}
-
-type AuthorFiles struct {
-	Author string
-	Files  []string
-}
-
-type AuthorFilesList []AuthorFiles
-
-func (s AuthorFilesList) Len() int {
-	return len(s)
-}
-func (s AuthorFilesList) Swap(i, j int) {
-	s[i], s[j] = s[j], s[i]
-}
-func (s AuthorFilesList) Less(i, j int) bool {
-	return len(s[i].Files) < len(s[j].Files)
-}
-
 func sortedKeys(m map[string][][]int64) []string {
 	keys := make([]string, 0, len(m))
 	for k := range m {
@@ -171,17 +38,18 @@ func sortedKeys(m map[string][][]int64) []string {
 }
 
 func main() {
-	var with_files bool
-	var with_people bool
-	var with_couples bool
+	var protobuf bool
+	var withFiles bool
+	var withPeople bool
+	var withCouples bool
 	var people_dict_path string
 	var profile bool
 	var granularity, sampling, similarity_threshold int
 	var commitsFile string
 	var debug bool
-	flag.BoolVar(&with_files, "files", false, "Output detailed statistics per each file.")
-	flag.BoolVar(&with_people, "people", false, "Output detailed statistics per each developer.")
-	flag.BoolVar(&with_couples, "couples", false, "Gather the co-occurrence matrix "+
+	flag.BoolVar(&withFiles, "files", false, "Output detailed statistics per each file.")
+	flag.BoolVar(&withPeople, "people", false, "Output detailed statistics per each developer.")
+	flag.BoolVar(&withCouples, "couples", false, "Gather the co-occurrence matrix "+
 		"for files and people.")
 	flag.StringVar(&people_dict_path, "people-dict", "", "Path to the developers' email associations.")
 	flag.BoolVar(&profile, "profile", false, "Collect the profile to hercules.pprof.")
@@ -194,6 +62,7 @@ func main() {
 		"commit history to follow instead of the default rev-list "+
 		"--first-parent. The format is the list of hashes, each hash on a "+
 		"separate line. The first hash is the root.")
+	flag.BoolVar(&protobuf, "pb", false, "The output format will be Protocol Buffers instead of YAML.")
 	flag.Parse()
 	if granularity <= 0 {
 		fmt.Fprint(os.Stderr, "Warning: adjusted the granularity to 1 day\n")
@@ -212,19 +81,19 @@ func main() {
 	}
 	uri := flag.Arg(0)
 	var repository *git.Repository
-	var storage storage.Storer
+	var backend storage.Storer
 	var err error
 	if strings.Contains(uri, "://") {
 		if len(flag.Args()) == 2 {
-			storage, err = filesystem.NewStorage(osfs.New(flag.Arg(1)))
+			backend, err = filesystem.NewStorage(osfs.New(flag.Arg(1)))
 			if err != nil {
 				panic(err)
 			}
 		} else {
-			storage = memory.NewStorage()
+			backend = memory.NewStorage()
 		}
 		fmt.Fprint(os.Stderr, "cloning...\r")
-		repository, err = git.Clone(storage, nil, &git.CloneOptions{
+		repository, err = git.Clone(backend, nil, &git.CloneOptions{
 			URL: uri,
 		})
 		fmt.Fprint(os.Stderr, "          \r")
@@ -265,7 +134,7 @@ func main() {
 	pipeline.AddItem(&hercules.TreeDiff{})
 	id_matcher := &hercules.IdentityDetector{}
 	var peopleCount int
-	if with_people || with_couples {
+	if withPeople || withCouples {
 		if people_dict_path != "" {
 			id_matcher.LoadPeopleDict(people_dict_path)
 			peopleCount = len(id_matcher.ReversePeopleDict) - 1
@@ -283,7 +152,7 @@ func main() {
 	}
 	pipeline.AddItem(burndowner)
 	var coupler *hercules.Couples
-	if with_couples {
+	if withCouples {
 		coupler = &hercules.Couples{PeopleNumber: peopleCount}
 		pipeline.AddItem(coupler)
 	}
@@ -294,43 +163,136 @@ func main() {
 		panic(err)
 	}
 	fmt.Fprint(os.Stderr, "writing...    \r")
-	burndown_results := result[burndowner].(hercules.BurndownResult)
-	var couples_result hercules.CouplesResult
-	if with_couples {
-		couples_result = result[coupler].(hercules.CouplesResult)
+	burndownResults := result[burndowner].(hercules.BurndownResult)
+	var couplesResult hercules.CouplesResult
+	if withCouples {
+		couplesResult = result[coupler].(hercules.CouplesResult)
 	}
-	if len(burndown_results.GlobalHistory) == 0 {
+	if len(burndownResults.GlobalHistory) == 0 {
 		return
 	}
-	// print the start date, granularity, sampling
+	begin := commits[0].Author.When.Unix()
+	end := commits[len(commits)-1].Author.When.Unix()
+	if !protobuf {
+		printResults(uri, begin, end, granularity, sampling,
+			withFiles, withPeople, withCouples,
+			burndownResults, couplesResult, id_matcher.ReversePeopleDict)
+	} else {
+		serializeResults(uri, begin, end, granularity, sampling,
+			withFiles, withPeople, withCouples,
+			burndownResults, couplesResult, id_matcher.ReversePeopleDict)
+	}
+}
+
+func printResults(
+	uri string, begin, end int64, granularity, sampling int,
+	withFiles, withPeople, withCouples bool,
+	burndownResults hercules.BurndownResult,
+	couplesResult hercules.CouplesResult,
+	reversePeopleDict []string) {
+
 	fmt.Println("burndown:")
 	fmt.Println("  version: 1")
-	fmt.Println("  begin:", commits[0].Author.When.Unix())
-	fmt.Println("  end:", commits[len(commits)-1].Author.When.Unix())
+	fmt.Println("  begin:", begin)
+	fmt.Println("  end:", end)
 	fmt.Println("  granularity:", granularity)
 	fmt.Println("  sampling:", sampling)
 	fmt.Println("project:")
-	printMatrix(burndown_results.GlobalHistory, uri, true)
-	if with_files {
+	stdout.PrintMatrix(burndownResults.GlobalHistory, uri, true)
+	if withFiles {
 		fmt.Println("files:")
-		keys := sortedKeys(burndown_results.FileHistories)
+		keys := sortedKeys(burndownResults.FileHistories)
 		for _, key := range keys {
-			printMatrix(burndown_results.FileHistories[key], key, true)
+			stdout.PrintMatrix(burndownResults.FileHistories[key], key, true)
 		}
 	}
-	if with_people {
+	if withPeople {
 		fmt.Println("people_sequence:")
-		for key := range burndown_results.PeopleHistories {
-			fmt.Println("  - " + safeString(id_matcher.ReversePeopleDict[key]))
+		for key := range burndownResults.PeopleHistories {
+			fmt.Println("  - " + stdout.SafeString(reversePeopleDict[key]))
 		}
 		fmt.Println("people:")
-		for key, val := range burndown_results.PeopleHistories {
-			printMatrix(val, id_matcher.ReversePeopleDict[key], true)
+		for key, val := range burndownResults.PeopleHistories {
+			stdout.PrintMatrix(val, reversePeopleDict[key], true)
 		}
 		fmt.Println("people_interaction: |-")
-		printMatrix(burndown_results.PeopleMatrix, "", false)
+		stdout.PrintMatrix(burndownResults.PeopleMatrix, "", false)
+	}
+	if withCouples {
+		stdout.PrintCouples(&couplesResult, reversePeopleDict)
 	}
-	if with_couples {
-		printCouples(&couples_result, id_matcher.ReversePeopleDict)
+}
+
+func serializeResults(
+	uri string, begin, end int64, granularity, sampling int,
+	withFiles, withPeople, withCouples bool,
+	burndownResults hercules.BurndownResult,
+	couplesResult hercules.CouplesResult,
+	reversePeopleDict []string) {
+
+  header := pb.Metadata{
+	  Version: 1,
+	  Cmdline: strings.Join(os.Args, " "),
+	  Repository: uri,
+    BeginUnixTime: begin,
+	  EndUnixTime: end,
+	  Granularity: int32(granularity),
+	  Sampling: int32(sampling),
+  }
+
+	message := pb.AnalysisResults{
+		Header: &header,
+		BurndownProject: pb.ToBurndownSparseMatrix(burndownResults.GlobalHistory, uri),
+	}
+
+	if withFiles {
+		message.BurndownFile = make([]*pb.BurndownSparseMatrix, len(burndownResults.FileHistories))
+		keys := sortedKeys(burndownResults.FileHistories)
+		i := 0
+		for _, key := range keys {
+			message.BurndownFile[i] = pb.ToBurndownSparseMatrix(
+				burndownResults.FileHistories[key], key)
+			i++
+		}
+	}
+
+	if withPeople {
+		message.BurndownDeveloper = make(
+		  []*pb.BurndownSparseMatrix, len(burndownResults.PeopleHistories))
+		for key, val := range burndownResults.PeopleHistories {
+			message.BurndownDeveloper[key] = pb.ToBurndownSparseMatrix(val, reversePeopleDict[key])
+		}
+		message.DevelopersInteraction = pb.DenseToCompressedSparseRowMatrix(
+			burndownResults.PeopleMatrix)
+	}
+
+	if withCouples {
+		message.FileCouples = &pb.Couples{
+			Index: couplesResult.Files,
+			Matrix: pb.MapToCompressedSparseRowMatrix(couplesResult.FilesMatrix),
+		}
+		message.DeveloperCouples = &pb.Couples{
+			Index: reversePeopleDict,
+			Matrix: pb.MapToCompressedSparseRowMatrix(couplesResult.PeopleMatrix),
+		}
+		message.TouchedFiles = &pb.DeveloperTouchedFiles{
+      Developer: make([]*pb.TouchedFiles, len(reversePeopleDict)),
+		}
+		for key := range reversePeopleDict {
+			files := couplesResult.PeopleFiles[key]
+			int32Files := make([]int32, len(files))
+			for i, f := range files {
+				int32Files[i] = int32(f)
+			}
+			message.TouchedFiles.Developer[key] = &pb.TouchedFiles{
+				File: int32Files,
+			}
+		}
+	}
+
+	serialized, err := proto.Marshal(&message)
+	if err != nil {
+		panic(err)
 	}
+  os.Stdout.Write(serialized)
 }

+ 24 - 21
couples.go

@@ -22,8 +22,8 @@ type Couples struct {
 
 type CouplesResult struct {
 	PeopleMatrix []map[int]int64
-	PeopleFiles  [][]string
-	FilesMatrix  []map[int]int
+	PeopleFiles  [][]int
+	FilesMatrix  []map[int]int64
 	Files        []string
 }
 
@@ -107,14 +107,24 @@ func (couples *Couples) Consume(deps map[string]interface{}) (map[string]interfa
 }
 
 func (couples *Couples) Finalize() interface{} {
+	filesSequence := make([]string, len(couples.files))
+	i := 0
+	for file := range couples.files {
+		filesSequence[i] = file
+		i++
+	}
+	sort.Strings(filesSequence)
+	filesIndex := map[string]int{}
+	for i, file := range filesSequence {
+		filesIndex[file] = i
+	}
+
 	peopleMatrix := make([]map[int]int64, couples.PeopleNumber+1)
-	peopleFiles := make([][]string, couples.PeopleNumber+1)
+	peopleFiles := make([][]int, couples.PeopleNumber+1)
 	for i := range peopleMatrix {
 		peopleMatrix[i] = map[int]int64{}
 		for file, commits := range couples.people[i] {
-			//could be normalized further, by replacing file with idx in fileSequence
-			//but this would trade the space for readability of result
-			peopleFiles[i] = append(peopleFiles[i], file)
+			peopleFiles[i] = append(peopleFiles[i], filesIndex[file])
 			for j, otherFiles := range couples.people {
 				if i == j {
 					continue
@@ -130,24 +140,17 @@ func (couples *Couples) Finalize() interface{} {
 			}
 		}
 		peopleMatrix[i][i] = int64(couples.people_commits[i])
+		sort.Ints(peopleFiles[i])
 	}
-	filesSequence := make([]string, len(couples.files))
-	i := 0
-	for file := range couples.files {
-		filesSequence[i] = file
-		i++
-	}
-	sort.Strings(filesSequence)
-	filesIndex := map[string]int{}
-	for i, file := range filesSequence {
-		filesIndex[file] = i
-	}
-	filesMatrix := make([]map[int]int, len(filesIndex))
+
+	filesMatrix := make([]map[int]int64, len(filesIndex))
 	for i := range filesMatrix {
-		filesMatrix[i] = map[int]int{}
+		filesMatrix[i] = map[int]int64{}
 		for otherFile, cooccs := range couples.files[filesSequence[i]] {
-			filesMatrix[i][filesIndex[otherFile]] = cooccs
+			filesMatrix[i][filesIndex[otherFile]] = int64(cooccs)
 		}
 	}
-	return CouplesResult{PeopleMatrix: peopleMatrix, PeopleFiles: peopleFiles, Files: filesSequence, FilesMatrix: filesMatrix}
+	return CouplesResult{
+		PeopleMatrix: peopleMatrix, PeopleFiles: peopleFiles,
+		Files: filesSequence, FilesMatrix: filesMatrix}
 }

+ 407 - 0
pb/pb.pb.go

@@ -0,0 +1,407 @@
+// Code generated by protoc-gen-gogo. DO NOT EDIT.
+// source: pb/pb.proto
+
+/*
+Package pb is a generated protocol buffer package.
+
+It is generated from these files:
+	pb/pb.proto
+
+It has these top-level messages:
+	Metadata
+	BurndownSparseMatrixRow
+	BurndownSparseMatrix
+	CompressedSparseRowMatrix
+	Couples
+	TouchedFiles
+	DeveloperTouchedFiles
+	AnalysisResults
+*/
+package pb
+
+import proto "github.com/gogo/protobuf/proto"
+import fmt "fmt"
+import math "math"
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.GoGoProtoPackageIsVersion2 // please upgrade the proto package
+
+type Metadata struct {
+	// this format is versioned
+	Version int32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
+	// complete command line used to write this message
+	Cmdline string `protobuf:"bytes,2,opt,name=cmdline,proto3" json:"cmdline,omitempty"`
+	// repository's name
+	Repository string `protobuf:"bytes,3,opt,name=repository,proto3" json:"repository,omitempty"`
+	// timestamp of the first analysed commit
+	BeginUnixTime int64 `protobuf:"varint,4,opt,name=begin_unix_time,json=beginUnixTime,proto3" json:"begin_unix_time,omitempty"`
+	// timestamp of the last analysed commit
+	EndUnixTime int64 `protobuf:"varint,5,opt,name=end_unix_time,json=endUnixTime,proto3" json:"end_unix_time,omitempty"`
+	// how many days are in each band [burndown_project, burndown_file, burndown_developer]
+	Granularity int32 `protobuf:"varint,6,opt,name=granularity,proto3" json:"granularity,omitempty"`
+	// how frequently we measure the state of each band [burndown_project, burndown_file, burndown_developer]
+	Sampling int32 `protobuf:"varint,7,opt,name=sampling,proto3" json:"sampling,omitempty"`
+}
+
+func (m *Metadata) Reset()                    { *m = Metadata{} }
+func (m *Metadata) String() string            { return proto.CompactTextString(m) }
+func (*Metadata) ProtoMessage()               {}
+func (*Metadata) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{0} }
+
+func (m *Metadata) GetVersion() int32 {
+	if m != nil {
+		return m.Version
+	}
+	return 0
+}
+
+func (m *Metadata) GetCmdline() string {
+	if m != nil {
+		return m.Cmdline
+	}
+	return ""
+}
+
+func (m *Metadata) GetRepository() string {
+	if m != nil {
+		return m.Repository
+	}
+	return ""
+}
+
+func (m *Metadata) GetBeginUnixTime() int64 {
+	if m != nil {
+		return m.BeginUnixTime
+	}
+	return 0
+}
+
+func (m *Metadata) GetEndUnixTime() int64 {
+	if m != nil {
+		return m.EndUnixTime
+	}
+	return 0
+}
+
+func (m *Metadata) GetGranularity() int32 {
+	if m != nil {
+		return m.Granularity
+	}
+	return 0
+}
+
+func (m *Metadata) GetSampling() int32 {
+	if m != nil {
+		return m.Sampling
+	}
+	return 0
+}
+
+type BurndownSparseMatrixRow struct {
+	// the first `len(column)` elements are stored,
+	// the rest `number_of_columns - len(column)` values are zeros
+	Column []uint32 `protobuf:"varint,1,rep,packed,name=column" json:"column,omitempty"`
+}
+
+func (m *BurndownSparseMatrixRow) Reset()                    { *m = BurndownSparseMatrixRow{} }
+func (m *BurndownSparseMatrixRow) String() string            { return proto.CompactTextString(m) }
+func (*BurndownSparseMatrixRow) ProtoMessage()               {}
+func (*BurndownSparseMatrixRow) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{1} }
+
+func (m *BurndownSparseMatrixRow) GetColumn() []uint32 {
+	if m != nil {
+		return m.Column
+	}
+	return nil
+}
+
+type BurndownSparseMatrix struct {
+	Name            string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	NumberOfRows    int32  `protobuf:"varint,2,opt,name=number_of_rows,json=numberOfRows,proto3" json:"number_of_rows,omitempty"`
+	NumberOfColumns int32  `protobuf:"varint,3,opt,name=number_of_columns,json=numberOfColumns,proto3" json:"number_of_columns,omitempty"`
+	// `len(row)` matches `number_of_rows`
+	Row []*BurndownSparseMatrixRow `protobuf:"bytes,4,rep,name=row" json:"row,omitempty"`
+}
+
+func (m *BurndownSparseMatrix) Reset()                    { *m = BurndownSparseMatrix{} }
+func (m *BurndownSparseMatrix) String() string            { return proto.CompactTextString(m) }
+func (*BurndownSparseMatrix) ProtoMessage()               {}
+func (*BurndownSparseMatrix) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{2} }
+
+func (m *BurndownSparseMatrix) GetName() string {
+	if m != nil {
+		return m.Name
+	}
+	return ""
+}
+
+func (m *BurndownSparseMatrix) GetNumberOfRows() int32 {
+	if m != nil {
+		return m.NumberOfRows
+	}
+	return 0
+}
+
+func (m *BurndownSparseMatrix) GetNumberOfColumns() int32 {
+	if m != nil {
+		return m.NumberOfColumns
+	}
+	return 0
+}
+
+func (m *BurndownSparseMatrix) GetRow() []*BurndownSparseMatrixRow {
+	if m != nil {
+		return m.Row
+	}
+	return nil
+}
+
+type CompressedSparseRowMatrix struct {
+	NumberOfRows    int32 `protobuf:"varint,1,opt,name=number_of_rows,json=numberOfRows,proto3" json:"number_of_rows,omitempty"`
+	NumberOfColumns int32 `protobuf:"varint,2,opt,name=number_of_columns,json=numberOfColumns,proto3" json:"number_of_columns,omitempty"`
+	// https://en.wikipedia.org/wiki/Sparse_matrix#Compressed_sparse_row_.28CSR.2C_CRS_or_Yale_format.29
+	Data    []int64 `protobuf:"varint,3,rep,packed,name=data" json:"data,omitempty"`
+	Indices []int32 `protobuf:"varint,4,rep,packed,name=indices" json:"indices,omitempty"`
+	Indptr  []int64 `protobuf:"varint,5,rep,packed,name=indptr" json:"indptr,omitempty"`
+}
+
+func (m *CompressedSparseRowMatrix) Reset()                    { *m = CompressedSparseRowMatrix{} }
+func (m *CompressedSparseRowMatrix) String() string            { return proto.CompactTextString(m) }
+func (*CompressedSparseRowMatrix) ProtoMessage()               {}
+func (*CompressedSparseRowMatrix) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{3} }
+
+func (m *CompressedSparseRowMatrix) GetNumberOfRows() int32 {
+	if m != nil {
+		return m.NumberOfRows
+	}
+	return 0
+}
+
+func (m *CompressedSparseRowMatrix) GetNumberOfColumns() int32 {
+	if m != nil {
+		return m.NumberOfColumns
+	}
+	return 0
+}
+
+func (m *CompressedSparseRowMatrix) GetData() []int64 {
+	if m != nil {
+		return m.Data
+	}
+	return nil
+}
+
+func (m *CompressedSparseRowMatrix) GetIndices() []int32 {
+	if m != nil {
+		return m.Indices
+	}
+	return nil
+}
+
+func (m *CompressedSparseRowMatrix) GetIndptr() []int64 {
+	if m != nil {
+		return m.Indptr
+	}
+	return nil
+}
+
+type Couples struct {
+	// name of each `matrix`'s row and column
+	Index []string `protobuf:"bytes,1,rep,name=index" json:"index,omitempty"`
+	// is always square
+	Matrix *CompressedSparseRowMatrix `protobuf:"bytes,2,opt,name=matrix" json:"matrix,omitempty"`
+}
+
+func (m *Couples) Reset()                    { *m = Couples{} }
+func (m *Couples) String() string            { return proto.CompactTextString(m) }
+func (*Couples) ProtoMessage()               {}
+func (*Couples) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{4} }
+
+func (m *Couples) GetIndex() []string {
+	if m != nil {
+		return m.Index
+	}
+	return nil
+}
+
+func (m *Couples) GetMatrix() *CompressedSparseRowMatrix {
+	if m != nil {
+		return m.Matrix
+	}
+	return nil
+}
+
+type TouchedFiles struct {
+	File []int32 `protobuf:"varint,1,rep,packed,name=file" json:"file,omitempty"`
+}
+
+func (m *TouchedFiles) Reset()                    { *m = TouchedFiles{} }
+func (m *TouchedFiles) String() string            { return proto.CompactTextString(m) }
+func (*TouchedFiles) ProtoMessage()               {}
+func (*TouchedFiles) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{5} }
+
+func (m *TouchedFiles) GetFile() []int32 {
+	if m != nil {
+		return m.File
+	}
+	return nil
+}
+
+type DeveloperTouchedFiles struct {
+	// order corresponds to `developer_couples::index`
+	Developer []*TouchedFiles `protobuf:"bytes,1,rep,name=developer" json:"developer,omitempty"`
+}
+
+func (m *DeveloperTouchedFiles) Reset()                    { *m = DeveloperTouchedFiles{} }
+func (m *DeveloperTouchedFiles) String() string            { return proto.CompactTextString(m) }
+func (*DeveloperTouchedFiles) ProtoMessage()               {}
+func (*DeveloperTouchedFiles) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{6} }
+
+func (m *DeveloperTouchedFiles) GetDeveloper() []*TouchedFiles {
+	if m != nil {
+		return m.Developer
+	}
+	return nil
+}
+
+type AnalysisResults struct {
+	// these two are always included
+	Header          *Metadata             `protobuf:"bytes,1,opt,name=header" json:"header,omitempty"`
+	BurndownProject *BurndownSparseMatrix `protobuf:"bytes,2,opt,name=burndown_project,json=burndownProject" json:"burndown_project,omitempty"`
+	// this is included if `-files` was specified
+	BurndownFile []*BurndownSparseMatrix `protobuf:"bytes,3,rep,name=burndown_file,json=burndownFile" json:"burndown_file,omitempty"`
+	// these two are included if `-people` was specified
+	BurndownDeveloper []*BurndownSparseMatrix `protobuf:"bytes,4,rep,name=burndown_developer,json=burndownDeveloper" json:"burndown_developer,omitempty"`
+	// rows and cols order correspond to `burndown_developer`
+	DevelopersInteraction *CompressedSparseRowMatrix `protobuf:"bytes,5,opt,name=developers_interaction,json=developersInteraction" json:"developers_interaction,omitempty"`
+	// these three are included if `-couples` was specified
+	FileCouples      *Couples               `protobuf:"bytes,6,opt,name=file_couples,json=fileCouples" json:"file_couples,omitempty"`
+	DeveloperCouples *Couples               `protobuf:"bytes,7,opt,name=developer_couples,json=developerCouples" json:"developer_couples,omitempty"`
+	TouchedFiles     *DeveloperTouchedFiles `protobuf:"bytes,8,opt,name=touched_files,json=touchedFiles" json:"touched_files,omitempty"`
+}
+
+func (m *AnalysisResults) Reset()                    { *m = AnalysisResults{} }
+func (m *AnalysisResults) String() string            { return proto.CompactTextString(m) }
+func (*AnalysisResults) ProtoMessage()               {}
+func (*AnalysisResults) Descriptor() ([]byte, []int) { return fileDescriptorPb, []int{7} }
+
+func (m *AnalysisResults) GetHeader() *Metadata {
+	if m != nil {
+		return m.Header
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetBurndownProject() *BurndownSparseMatrix {
+	if m != nil {
+		return m.BurndownProject
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetBurndownFile() []*BurndownSparseMatrix {
+	if m != nil {
+		return m.BurndownFile
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetBurndownDeveloper() []*BurndownSparseMatrix {
+	if m != nil {
+		return m.BurndownDeveloper
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetDevelopersInteraction() *CompressedSparseRowMatrix {
+	if m != nil {
+		return m.DevelopersInteraction
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetFileCouples() *Couples {
+	if m != nil {
+		return m.FileCouples
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetDeveloperCouples() *Couples {
+	if m != nil {
+		return m.DeveloperCouples
+	}
+	return nil
+}
+
+func (m *AnalysisResults) GetTouchedFiles() *DeveloperTouchedFiles {
+	if m != nil {
+		return m.TouchedFiles
+	}
+	return nil
+}
+
+func init() {
+	proto.RegisterType((*Metadata)(nil), "Metadata")
+	proto.RegisterType((*BurndownSparseMatrixRow)(nil), "BurndownSparseMatrixRow")
+	proto.RegisterType((*BurndownSparseMatrix)(nil), "BurndownSparseMatrix")
+	proto.RegisterType((*CompressedSparseRowMatrix)(nil), "CompressedSparseRowMatrix")
+	proto.RegisterType((*Couples)(nil), "Couples")
+	proto.RegisterType((*TouchedFiles)(nil), "TouchedFiles")
+	proto.RegisterType((*DeveloperTouchedFiles)(nil), "DeveloperTouchedFiles")
+	proto.RegisterType((*AnalysisResults)(nil), "AnalysisResults")
+}
+
+func init() { proto.RegisterFile("pb/pb.proto", fileDescriptorPb) }
+
+var fileDescriptorPb = []byte{
+	// 639 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x54, 0x4d, 0x6f, 0xd3, 0x4c,
+	0x10, 0x96, 0x6b, 0x3b, 0x1f, 0xe3, 0xe4, 0x4d, 0xbb, 0x6a, 0xfb, 0xfa, 0xed, 0xe1, 0x55, 0xb0,
+	0x10, 0x8a, 0x5a, 0xc9, 0x88, 0x20, 0x2e, 0x70, 0x01, 0x5a, 0x21, 0x71, 0xa8, 0x80, 0x6d, 0x39,
+	0x5b, 0x8e, 0x3d, 0x6d, 0x17, 0xd9, 0xbb, 0xd6, 0xee, 0xba, 0x49, 0xff, 0x11, 0x77, 0xfe, 0x14,
+	0x07, 0x7e, 0x04, 0xf2, 0xfa, 0x23, 0x01, 0x52, 0xe0, 0xe6, 0x99, 0x79, 0x9e, 0xf1, 0xf3, 0xcc,
+	0x8c, 0x0d, 0x5e, 0xb1, 0x78, 0x5c, 0x2c, 0xc2, 0x42, 0x0a, 0x2d, 0x82, 0xaf, 0x16, 0x0c, 0xce,
+	0x51, 0xc7, 0x69, 0xac, 0x63, 0xe2, 0x43, 0xff, 0x16, 0xa5, 0x62, 0x82, 0xfb, 0xd6, 0xd4, 0x9a,
+	0xb9, 0xb4, 0x0d, 0xab, 0x4a, 0x92, 0xa7, 0x19, 0xe3, 0xe8, 0xef, 0x4c, 0xad, 0xd9, 0x90, 0xb6,
+	0x21, 0xf9, 0x1f, 0x40, 0x62, 0x21, 0x14, 0xd3, 0x42, 0xde, 0xf9, 0xb6, 0x29, 0x6e, 0x64, 0xc8,
+	0x23, 0x98, 0x2c, 0xf0, 0x9a, 0xf1, 0xa8, 0xe4, 0x6c, 0x15, 0x69, 0x96, 0xa3, 0xef, 0x4c, 0xad,
+	0x99, 0x4d, 0xc7, 0x26, 0xfd, 0x91, 0xb3, 0xd5, 0x25, 0xcb, 0x91, 0x04, 0x30, 0x46, 0x9e, 0x6e,
+	0xa0, 0x5c, 0x83, 0xf2, 0x90, 0xa7, 0x1d, 0x66, 0x0a, 0xde, 0xb5, 0x8c, 0x79, 0x99, 0xc5, 0x92,
+	0xe9, 0x3b, 0xbf, 0x67, 0x34, 0x6e, 0xa6, 0xc8, 0x11, 0x0c, 0x54, 0x9c, 0x17, 0x19, 0xe3, 0xd7,
+	0x7e, 0xdf, 0x94, 0xbb, 0x38, 0x78, 0x02, 0xff, 0xbe, 0x2e, 0x25, 0x4f, 0xc5, 0x92, 0x5f, 0x14,
+	0xb1, 0x54, 0x78, 0x1e, 0x6b, 0xc9, 0x56, 0x54, 0x2c, 0xc9, 0x21, 0xf4, 0x12, 0x91, 0x95, 0x79,
+	0xe5, 0xdb, 0x9e, 0x8d, 0x69, 0x13, 0x05, 0x9f, 0x2d, 0xd8, 0xdf, 0xc6, 0x21, 0x04, 0x1c, 0x1e,
+	0xe7, 0x68, 0xc6, 0x34, 0xa4, 0xe6, 0x99, 0x3c, 0x84, 0x7f, 0x78, 0x99, 0x2f, 0x50, 0x46, 0xe2,
+	0x2a, 0x92, 0x62, 0xa9, 0xcc, 0xa8, 0x5c, 0x3a, 0xaa, 0xb3, 0xef, 0xae, 0xa8, 0x58, 0x2a, 0x72,
+	0x0c, 0x7b, 0x6b, 0x54, 0xfd, 0x1a, 0x65, 0xc6, 0xe6, 0xd2, 0x49, 0x0b, 0x3c, 0xad, 0xd3, 0xe4,
+	0x18, 0x6c, 0x29, 0x96, 0xbe, 0x33, 0xb5, 0x67, 0xde, 0xdc, 0x0f, 0xef, 0x51, 0x4f, 0x2b, 0x50,
+	0xf0, 0xc5, 0x82, 0xff, 0x4e, 0x45, 0x5e, 0x48, 0x54, 0x0a, 0xd3, 0x1a, 0x42, 0xc5, 0xb2, 0xd1,
+	0xfb, 0xab, 0x36, 0xeb, 0x6f, 0xb5, 0xed, 0x6c, 0xd7, 0x46, 0xc0, 0xa9, 0x6e, 0xc6, 0xb7, 0xa7,
+	0xf6, 0xcc, 0xa6, 0x4e, 0x7b, 0x3f, 0x8c, 0xa7, 0x2c, 0x41, 0x65, 0x34, 0xbb, 0xb4, 0x0d, 0xab,
+	0x01, 0x33, 0x9e, 0x16, 0x5a, 0xfa, 0xae, 0xc1, 0x37, 0x51, 0x70, 0x01, 0xfd, 0x53, 0x51, 0x16,
+	0x19, 0x2a, 0xb2, 0x0f, 0x2e, 0xe3, 0x29, 0xae, 0xcc, 0x0a, 0x86, 0xb4, 0x0e, 0xc8, 0x1c, 0x7a,
+	0xb9, 0xb1, 0x60, 0x74, 0x78, 0xf3, 0xa3, 0xf0, 0x5e, 0x93, 0xb4, 0x41, 0x06, 0x01, 0x8c, 0x2e,
+	0x45, 0x99, 0xdc, 0x60, 0xfa, 0x86, 0x55, 0x9d, 0x09, 0x38, 0x57, 0x2c, 0x43, 0xd3, 0xd8, 0xa5,
+	0xe6, 0x39, 0x38, 0x83, 0x83, 0x33, 0xbc, 0xc5, 0x4c, 0x14, 0x28, 0x7f, 0x00, 0x9f, 0xc0, 0x30,
+	0x6d, 0x0b, 0x86, 0xe1, 0xcd, 0xc7, 0xe1, 0x26, 0x82, 0xae, 0xeb, 0xc1, 0x37, 0x1b, 0x26, 0xaf,
+	0x78, 0x9c, 0xdd, 0x29, 0xa6, 0x28, 0xaa, 0x32, 0xd3, 0x8a, 0x3c, 0x80, 0xde, 0x0d, 0xc6, 0xa9,
+	0x61, 0x57, 0x8a, 0x87, 0x61, 0xfb, 0x7d, 0xd1, 0xa6, 0x40, 0x5e, 0xc2, 0xee, 0xa2, 0xd9, 0x65,
+	0x54, 0x48, 0xf1, 0x09, 0x13, 0xdd, 0xd8, 0x3b, 0xd8, 0xbe, 0xe4, 0x49, 0x0b, 0x7f, 0x5f, 0xa3,
+	0xc9, 0x73, 0x18, 0x77, 0x1d, 0x8c, 0x37, 0xdb, 0x28, 0xbd, 0x87, 0x3e, 0x6a, 0xb1, 0x95, 0x01,
+	0x72, 0x06, 0xa4, 0xe3, 0xae, 0xad, 0x3a, 0xbf, 0x6b, 0xb0, 0xd7, 0x12, 0xba, 0x99, 0x91, 0x0f,
+	0x70, 0xd8, 0x91, 0x55, 0xc4, 0xb8, 0x46, 0x19, 0x27, 0xba, 0xfa, 0x75, 0xb8, 0x7f, 0x5c, 0xd4,
+	0xc1, 0x9a, 0xf9, 0x76, 0x4d, 0x24, 0x27, 0x30, 0xaa, 0xbc, 0x44, 0x49, 0x7d, 0x11, 0xe6, 0xfb,
+	0xf6, 0xe6, 0x83, 0xb0, 0xb9, 0x10, 0xea, 0x55, 0xd5, 0xf6, 0x5c, 0x9e, 0xc1, 0x5e, 0xd7, 0xa5,
+	0x63, 0xf4, 0x7f, 0x62, 0xec, 0x76, 0x90, 0x96, 0xf6, 0x02, 0xc6, 0xba, 0x5e, 0xa6, 0x99, 0x9b,
+	0xf2, 0x07, 0x86, 0x72, 0x18, 0x6e, 0xbd, 0x06, 0x3a, 0xd2, 0x1b, 0xd1, 0xa2, 0x67, 0xfe, 0x99,
+	0x4f, 0xbf, 0x07, 0x00, 0x00, 0xff, 0xff, 0xe3, 0xad, 0x60, 0xf9, 0x42, 0x05, 0x00, 0x00,
+}

+ 76 - 0
pb/pb.proto

@@ -0,0 +1,76 @@
+syntax = "proto3";
+
+message Metadata {
+    // this format is versioned
+    int32 version = 1;
+    // complete command line used to write this message
+    string cmdline = 2;
+    // repository's name
+    string repository = 3;
+    // timestamp of the first analysed commit
+    int64 begin_unix_time = 4;
+    // timestamp of the last analysed commit
+    int64 end_unix_time = 5;
+    // how many days are in each band [burndown_project, burndown_file, burndown_developer]
+    int32 granularity = 6;
+    // how frequently we measure the state of each band [burndown_project, burndown_file, burndown_developer]
+    int32 sampling = 7;
+}
+
+message BurndownSparseMatrixRow {
+    // the first `len(column)` elements are stored,
+    // the rest `number_of_columns - len(column)` values are zeros
+    repeated uint32 column = 1;
+}
+
+message BurndownSparseMatrix {
+    string name = 1;
+    int32 number_of_rows = 2;
+    int32 number_of_columns = 3;
+    // `len(row)` matches `number_of_rows`
+    repeated BurndownSparseMatrixRow row = 4;
+}
+
+message CompressedSparseRowMatrix {
+    int32 number_of_rows = 1;
+    int32 number_of_columns = 2;
+    // https://en.wikipedia.org/wiki/Sparse_matrix#Compressed_sparse_row_.28CSR.2C_CRS_or_Yale_format.29
+    repeated int64 data = 3;
+    repeated int32 indices = 4;
+    repeated int64 indptr = 5;
+}
+
+message Couples {
+    // name of each `matrix`'s row and column
+    repeated string index = 1;
+    // is always square
+    CompressedSparseRowMatrix matrix = 2;
+}
+
+message TouchedFiles {
+    repeated int32 file = 1;  // values correspond to `file_couples::index`
+}
+
+message DeveloperTouchedFiles {
+    // order corresponds to `developer_couples::index`
+    repeated TouchedFiles developer = 1;
+}
+
+message AnalysisResults {
+    // these two are always included
+    Metadata header = 1;
+    BurndownSparseMatrix burndown_project = 2;
+
+    // this is included if `-files` was specified
+    repeated BurndownSparseMatrix burndown_file = 3;
+
+    // these two are included if `-people` was specified
+    repeated BurndownSparseMatrix burndown_developer = 4;
+    // rows and cols order correspond to `burndown_developer`
+    CompressedSparseRowMatrix developers_interaction = 5;
+
+    // these three are included if `-couples` was specified
+    Couples file_couples = 6;
+    Couples developer_couples = 7;
+    DeveloperTouchedFiles touched_files = 8;
+}

+ 75 - 0
pb/utils.go

@@ -0,0 +1,75 @@
+package pb
+
+func ToBurndownSparseMatrix(matrix [][]int64, name string) *BurndownSparseMatrix {
+  r := BurndownSparseMatrix{
+	  Name: name,
+	  NumberOfRows: int32(len(matrix)),
+	  NumberOfColumns: int32(len(matrix[len(matrix)-1])),
+	  Row: make([]*BurndownSparseMatrixRow, len(matrix)),
+  }
+  for i, status := range matrix {
+	  nnz := make([]uint32, 0, len(status))
+	  changed := false
+	  for j := range status {
+		  v := status[len(status) - 1 - j]
+		  if v < 0 {
+			  v = 0
+		  }
+		  if !changed {
+			  changed = v != 0
+		  }
+		  if changed {
+			  nnz = append(nnz, uint32(v))
+		  }
+	  }
+	  r.Row[i] = &BurndownSparseMatrixRow{
+		  Column: make([]uint32, len(nnz)),
+	  }
+	  for j := range nnz {
+		  r.Row[i].Column[j] = nnz[len(nnz) - 1 - j]
+	  }
+	}
+	return &r
+}
+
+func DenseToCompressedSparseRowMatrix(matrix [][]int64) *CompressedSparseRowMatrix {
+	r := CompressedSparseRowMatrix{
+		NumberOfRows: int32(len(matrix)),
+		NumberOfColumns: int32(len(matrix[0])),
+		Data: make([]int64, 0),
+		Indices: make([]int32, 0),
+		Indptr: make([]int64, 1),
+	}
+	r.Indptr[0] = 0
+	for _, row := range matrix {
+		for x, col := range row {
+			nnz := 0
+			if col != 0 {
+				r.Data = append(r.Data, col)
+				r.Indices = append(r.Indices, int32(x))
+				nnz += 1
+			}
+			r.Indptr = append(r.Indptr, r.Indptr[len(r.Indptr) - 1] + int64(nnz))
+		}
+	}
+	return &r
+}
+
+func MapToCompressedSparseRowMatrix(matrix []map[int]int64) *CompressedSparseRowMatrix {
+	r := CompressedSparseRowMatrix{
+		NumberOfRows: int32(len(matrix)),
+		NumberOfColumns: int32(len(matrix)),
+		Data: make([]int64, 0),
+		Indices: make([]int32, 0),
+		Indptr: make([]int64, 1),
+	}
+	r.Indptr[0] = 0
+	for _, row := range matrix {
+		for x, col := range row {
+			r.Data = append(r.Data, col)
+			r.Indices = append(r.Indices, int32(x))
+			r.Indptr = append(r.Indptr, r.Indptr[len(r.Indptr) - 1] + int64(len(row)))
+		}
+	}
+	return &r
+}

+ 150 - 0
stdout/utils.go

@@ -0,0 +1,150 @@
+package stdout
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+	
+	"gopkg.in/src-d/hercules.v2"
+)
+
+func SafeString(str string) string {
+	str = strings.Replace(str, "\\", "\\\\", -1)
+	str = strings.Replace(str, "\"", "\\\"", -1)
+	return "\"" + str + "\""
+}
+
+func PrintMatrix(matrix [][]int64, name string, fixNegative bool) {
+	// determine the maximum length of each value
+	var maxnum int64 = -(1 << 32)
+	var minnum int64 = 1 << 32
+	for _, status := range matrix {
+		for _, val := range status {
+			if val > maxnum {
+				maxnum = val
+			}
+			if val < minnum {
+				minnum = val
+			}
+		}
+	}
+	width := len(strconv.FormatInt(maxnum, 10))
+	if !fixNegative && minnum < 0 {
+		width = len(strconv.FormatInt(minnum, 10))
+	}
+	last := len(matrix[len(matrix)-1])
+	indent := 2
+	if name != "" {
+		fmt.Printf("  %s: |-\n", SafeString(name))
+		indent += 2
+	}
+	// print the resulting triangular matrix
+	for _, status := range matrix {
+		fmt.Print(strings.Repeat(" ", indent-1))
+		for i := 0; i < last; i++ {
+			var val int64
+			if i < len(status) {
+				val = status[i]
+				// not sure why this sometimes happens...
+				// TODO(vmarkovtsev): find the root cause of tiny negative balances
+				if fixNegative && val < 0 {
+					val = 0
+				}
+			}
+			fmt.Printf(" %[1]*[2]d", width, val)
+		}
+		fmt.Println()
+	}
+}
+
+func PrintCouples(result *hercules.CouplesResult, peopleDict []string) {
+	fmt.Println("files_coocc:")
+	fmt.Println("  index:")
+	for _, file := range result.Files {
+		fmt.Printf("    - %s\n", SafeString(file))
+	}
+
+	fmt.Println("  matrix:")
+	for _, files := range result.FilesMatrix {
+		fmt.Print("    - {")
+		indices := []int{}
+		for file := range files {
+			indices = append(indices, file)
+		}
+		sort.Ints(indices)
+		for i, file := range indices {
+			fmt.Printf("%d: %d", file, files[file])
+			if i < len(indices)-1 {
+				fmt.Print(", ")
+			}
+		}
+		fmt.Println("}")
+	}
+
+	fmt.Println("people_coocc:")
+	fmt.Println("  index:")
+	for _, person := range peopleDict {
+		fmt.Printf("    - %s\n", SafeString(person))
+	}
+
+	fmt.Println("  matrix:")
+	for _, people := range result.PeopleMatrix {
+		fmt.Print("    - {")
+		indices := []int{}
+		for file := range people {
+			indices = append(indices, file)
+		}
+		sort.Ints(indices)
+		for i, person := range indices {
+			fmt.Printf("%d: %d", person, people[person])
+			if i < len(indices)-1 {
+				fmt.Print(", ")
+			}
+		}
+		fmt.Println("}")
+	}
+
+	fmt.Println("  author_files:") // sorted by number of files each author changed
+	peopleFiles := sortByNumberOfFiles(result.PeopleFiles, peopleDict, result.Files)
+	for _, authorFiles := range peopleFiles {
+		fmt.Printf("    - %s:\n", SafeString(authorFiles.Author))
+		sort.Strings(authorFiles.Files)
+		for _, file := range authorFiles.Files {
+			fmt.Printf("      - %s\n", SafeString(file)) // sorted by path
+		}
+	}
+}
+
+func sortByNumberOfFiles(
+		peopleFiles [][]int, peopleDict []string, filesDict []string) authorFilesList {
+	var pfl authorFilesList
+	for peopleIdx, files := range peopleFiles {
+		if peopleIdx < len(peopleDict) {
+			fileNames := make([]string, len(files))
+			for i, fi := range files {
+				fileNames[i] = filesDict[fi]
+			}
+			pfl = append(pfl, authorFiles{peopleDict[peopleIdx], fileNames})
+		}
+	}
+	sort.Sort(pfl)
+	return pfl
+}
+
+type authorFiles struct {
+	Author string
+	Files  []string
+}
+
+type authorFilesList []authorFiles
+
+func (s authorFilesList) Len() int {
+	return len(s)
+}
+func (s authorFilesList) Swap(i, j int) {
+	s[i], s[j] = s[j], s[i]
+}
+func (s authorFilesList) Less(i, j int) bool {
+	return len(s[i].Files) < len(s[j].Files)
+}