From 28331e4eac8d49867f1db477ffbaa1635eabfee2 Mon Sep 17 00:00:00 2001 From: Patrick DeVivo Date: Mon, 24 Jan 2022 11:35:59 -0500 Subject: [PATCH 1/4] feat: improvements to `summary` command - use the `bubbletea` library to show some loaders - preload commits into a temp table to avoid a double scan - move `summary` into a separate package --- cmd/summary.go | 153 ++------------------ cmd/summary/summary.go | 312 +++++++++++++++++++++++++++++++++++++++++ go.mod | 4 + go.sum | 13 ++ 4 files changed, 339 insertions(+), 143 deletions(-) create mode 100644 cmd/summary/summary.go diff --git a/cmd/summary.go b/cmd/summary.go index 93d66c8f..d0ae3a60 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -3,164 +3,31 @@ package cmd import ( "fmt" "os" - "strings" - "text/tabwriter" - "time" - "github.com/charmbracelet/lipgloss" - "github.com/jmoiron/sqlx" - "github.com/mergestat/timediff" + tea "github.com/charmbracelet/bubbletea" + "github.com/mergestat/mergestat/cmd/summary" "github.com/spf13/cobra" - "golang.org/x/text/language" - "golang.org/x/text/message" ) -var headingStyle = lipgloss.NewStyle(). - Bold(true) - -// var underlineStyle = lipgloss.NewStyle().Underline(true) - -// var textStyle = lipgloss.NewStyle() - -type CommitSummary struct { - Total int `db:"total"` - TotalNonMerges int `db:"total_non_merges"` - FirstCommit time.Time `db:"first_commit"` - LastCommit time.Time `db:"last_commit"` - DistinctAuthors int `db:"distinct_authors"` - DistinctFiles int `db:"distinct_files"` -} - -const commitSummarySQL = ` -SELECT - (SELECT count(*) FROM commits) AS total, - (SELECT count(*) FROM commits WHERE parents < 2) AS total_non_merges, - (SELECT author_when FROM commits ORDER BY author_when ASC LIMIT 1) AS first_commit, - (SELECT author_when FROM commits ORDER BY author_when DESC LIMIT 1) AS last_commit, - (SELECT count(distinct(author_email || author_name)) FROM commits) AS distinct_authors, - (SELECT count(distinct(path)) FROM files) AS distinct_files -` - -type CommitAuthorSummary struct { - AuthorName string `db:"author_name"` - AuthorEmail string `db:"author_email"` - Commits int `db:"commit_count"` - Additions int `db:"additions"` - Deletions int `db:"deletions"` - DistinctFiles int `db:"distinct_files"` - FirstCommit string `db:"first_commit"` - LastCommit string `db:"last_commit"` -} - -const commitAuthorSummarySQL = ` -SELECT - author_name, author_email, - count(distinct hash) AS commit_count, - sum(additions) AS additions, - sum(deletions) AS deletions, - count(distinct file_path) AS distinct_files, - min(author_when) AS first_commit, - max(author_when) AS last_commit -FROM commits, stats('', commits.hash) -GROUP BY author_name, author_email -ORDER BY commit_count DESC -LIMIT 25 -` - var summaryCmd = &cobra.Command{ Use: "summary", Long: "prints a summary of commit activity in the default repository.", Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { - var db *sqlx.DB + var ui *summary.TermUI var err error - if db, err = sqlx.Open("sqlite3", ":memory:"); err != nil { - handleExitError(fmt.Errorf("failed to initialize database connection: %v", err)) - } - defer func() { - if err := db.Close(); err != nil { - handleExitError(err) - } - }() - - p := message.NewPrinter(language.English) - - var commitSummary CommitSummary - if err := db.QueryRowx(commitSummarySQL).StructScan(&commitSummary); err != nil { - handleExitError(err) - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) - - rows := []string{ - strings.Join([]string{headingStyle.Render("Commits"), p.Sprintf("%d", commitSummary.Total)}, "\t"), - strings.Join([]string{headingStyle.Render("Non-Merge Commits"), p.Sprintf("%d", commitSummary.TotalNonMerges)}, "\t"), - strings.Join([]string{headingStyle.Render("Files"), p.Sprintf("%d", commitSummary.DistinctFiles)}, "\t"), - strings.Join([]string{headingStyle.Render("Unique Authors"), p.Sprintf("%d", commitSummary.DistinctAuthors)}, "\t"), - strings.Join([]string{headingStyle.Render("First Commit"), fmt.Sprintf("%s (%s)", timediff.TimeDiff(commitSummary.FirstCommit), commitSummary.FirstCommit.Format("2006-01-02"))}, "\t"), - strings.Join([]string{headingStyle.Render("Latest Commit"), fmt.Sprintf("%s (%s)", timediff.TimeDiff(commitSummary.LastCommit), commitSummary.FirstCommit.Format("2006-01-02"))}, "\t"), - } - p.Fprintln(w, strings.Join(rows, "\n")) - - if err := w.Flush(); err != nil { + if ui, err = summary.NewTermUI(); err != nil { handleExitError(err) } + defer ui.Close() - p.Println() - p.Println() - - var commitAuthorSummaries []*CommitAuthorSummary - if err := db.Select(&commitAuthorSummaries, commitAuthorSummarySQL); err != nil { - handleExitError(err) - } - - r := strings.Join([]string{ - "Author", - "Commits", - "Commit %", - "Files Δ", - "Additions", - "Deletions", - "First Commit", - "Latest Commit", - }, "\t") - - p.Fprintln(w, r) - - for _, authorRow := range commitAuthorSummaries { - commitPercent := (float32(authorRow.Commits) / float32(commitSummary.Total)) * 100.0 - - var firstCommit, lastCommit time.Time - if firstCommit, err = time.Parse(time.RFC3339, authorRow.FirstCommit); err != nil { + // check if output is a terminal (https://rosettacode.org/wiki/Check_output_device_is_a_terminal#Go) + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { + if err := tea.NewProgram(ui).Start(); err != nil { handleExitError(err) } - if lastCommit, err = time.Parse(time.RFC3339, authorRow.LastCommit); err != nil { - handleExitError(err) - } - - r := strings.Join([]string{ - authorRow.AuthorName, - p.Sprintf("%d", authorRow.Commits), - p.Sprintf("%.2f%%", commitPercent), - p.Sprintf("%d", authorRow.DistinctFiles), - p.Sprintf("%d", authorRow.Additions), - p.Sprintf("%d", authorRow.Deletions), - p.Sprintf("%s (%s)", timediff.TimeDiff(firstCommit), firstCommit.Format("2006-01-02")), - p.Sprintf("%s (%s)", timediff.TimeDiff(lastCommit), lastCommit.Format("2006-01-02")), - }, "\t") - - p.Fprintln(w, r) - } - - d := commitSummary.DistinctAuthors - len(commitAuthorSummaries) - if d == 1 { - p.Fprintf(w, "...1 more author\n") - } else if d > 1 { - p.Fprintf(w, "...%d more authors\n", d) - } - - if err := w.Flush(); err != nil { - handleExitError(err) + } else { + fmt.Print(ui.PrintNoTTY()) } }, } diff --git a/cmd/summary/summary.go b/cmd/summary/summary.go new file mode 100644 index 00000000..a405d6f7 --- /dev/null +++ b/cmd/summary/summary.go @@ -0,0 +1,312 @@ +package summary + +import ( + "bytes" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/jmoiron/sqlx" + "github.com/mergestat/timediff" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +type CommitSummary struct { + Total int `db:"total"` + TotalNonMerges int `db:"total_non_merges"` + FirstCommit string `db:"first_commit"` + LastCommit string `db:"last_commit"` + FirstCommitT time.Time + LastCommitT time.Time + DistinctAuthors int `db:"distinct_authors"` + DistinctFiles int `db:"distinct_files"` +} + +const preloadCommitsSQL = ` +CREATE TABLE preloaded_commits AS SELECT * FROM commits; +` + +const commitSummarySQL = ` +SELECT + (SELECT count(*) FROM preloaded_commits) AS total, + (SELECT count(*) FROM preloaded_commits WHERE parents < 2) AS total_non_merges, + (SELECT author_when FROM preloaded_commits ORDER BY author_when ASC LIMIT 1) AS first_commit, + (SELECT author_when FROM preloaded_commits ORDER BY author_when DESC LIMIT 1) AS last_commit, + (SELECT count(distinct(author_email || author_name)) FROM preloaded_commits) AS distinct_authors, + (SELECT count(distinct(path)) FROM files) AS distinct_files +` + +type CommitAuthorSummary struct { + AuthorName string `db:"author_name"` + AuthorEmail string `db:"author_email"` + Commits int `db:"commit_count"` + Additions int `db:"additions"` + Deletions int `db:"deletions"` + DistinctFiles int `db:"distinct_files"` + FirstCommit string `db:"first_commit"` + LastCommit string `db:"last_commit"` +} + +const commitAuthorSummarySQL = ` +SELECT + author_name, author_email, + count(distinct hash) AS commit_count, + sum(additions) AS additions, + sum(deletions) AS deletions, + count(distinct file_path) AS distinct_files, + min(author_when) AS first_commit, + max(author_when) AS last_commit +FROM preloaded_commits, stats('', preloaded_commits.hash) +GROUP BY author_name, author_email +ORDER BY commit_count DESC +LIMIT 25 +` + +type TermUI struct { + db *sqlx.DB + err error + spinner spinner.Model + commitsPreloaded bool + commitSummary *CommitSummary + commitAuthorSummaries []*CommitAuthorSummary +} + +func NewTermUI() (*TermUI, error) { + var db *sqlx.DB + var err error + if db, err = sqlx.Open("sqlite3", "file::memory:?cache=shared"); err != nil { + return nil, fmt.Errorf("failed to initialize database connection: %v", err) + } + db.SetMaxOpenConns(1) + s := spinner.New() + s.Spinner = spinner.Spinner{ + Frames: []string{".", "..", "..."}, + FPS: 300 * time.Millisecond, + } + + return &TermUI{ + db: db, + spinner: s, + }, nil +} + +func (t *TermUI) Init() tea.Cmd { + return tea.Batch( + t.spinner.Tick, + t.preloadCommits, + t.loadCommitSummary, + t.loadAuthorCommitSummary, + ) +} + +func (t *TermUI) preloadCommits() tea.Msg { + if _, err := t.db.Exec(preloadCommitsSQL); err != nil { + return err + } + + t.commitsPreloaded = true + return nil +} + +func (t *TermUI) loadCommitSummary() tea.Msg { + for !t.commitsPreloaded { + time.Sleep(2 * time.Second) + } + var commitSummary CommitSummary + if err := t.db.QueryRowx(commitSummarySQL).StructScan(&commitSummary); err != nil { + return err + } + + var err error + if commitSummary.FirstCommitT, err = time.Parse(time.RFC3339, commitSummary.FirstCommit); err != nil { + return err + } + if commitSummary.LastCommitT, err = time.Parse(time.RFC3339, commitSummary.LastCommit); err != nil { + return err + } + + t.commitSummary = &commitSummary + return nil +} + +func (t *TermUI) loadAuthorCommitSummary() tea.Msg { + for !t.commitsPreloaded { + time.Sleep(2 * time.Second) + } + var commitAuthorSummaries []*CommitAuthorSummary + if err := t.db.Select(&commitAuthorSummaries, commitAuthorSummarySQL); err != nil { + return err + } + + t.commitAuthorSummaries = commitAuthorSummaries + return nil +} + +func (t *TermUI) renderCommitSummaryTable(boldHeader bool) string { + var b bytes.Buffer + p := message.NewPrinter(language.English) + w := tabwriter.NewWriter(&b, 0, 0, 3, ' ', tabwriter.TabIndent) + + var total, totalNonMerges, distinctFiles, distinctAuthors, firstCommit, lastCommit string + + if t.commitSummary != nil { + total = p.Sprintf("%d", t.commitSummary.Total) + totalNonMerges = p.Sprintf("%d", t.commitSummary.TotalNonMerges) + distinctFiles = p.Sprintf("%d", t.commitSummary.DistinctFiles) + distinctAuthors = p.Sprintf("%d", t.commitSummary.DistinctAuthors) + firstCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(t.commitSummary.FirstCommitT), t.commitSummary.FirstCommitT.Format("2006-01-02")) + lastCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(t.commitSummary.LastCommitT), t.commitSummary.LastCommitT.Format("2006-01-02")) + } else { + total = t.spinner.View() + totalNonMerges = t.spinner.View() + distinctFiles = t.spinner.View() + distinctAuthors = t.spinner.View() + firstCommit = t.spinner.View() + lastCommit = t.spinner.View() + } + + var headingStyle = lipgloss.NewStyle().Bold(boldHeader) + + rows := []string{ + strings.Join([]string{headingStyle.Render("Commits"), total}, "\t"), + strings.Join([]string{headingStyle.Render("Non-Merge Commits"), totalNonMerges}, "\t"), + strings.Join([]string{headingStyle.Render("Files"), distinctFiles}, "\t"), + strings.Join([]string{headingStyle.Render("Unique Authors"), distinctAuthors}, "\t"), + strings.Join([]string{headingStyle.Render("First Commit"), firstCommit}, "\t"), + strings.Join([]string{headingStyle.Render("Latest Commit"), lastCommit}, "\t"), + } + + p.Fprintln(w, strings.Join(rows, "\n")) + if err := w.Flush(); err != nil { + return err.Error() + } + + p.Fprintln(&b) + p.Fprintln(&b) + + return b.String() +} + +func (t *TermUI) renderCommitAuthorSummary() string { + var b bytes.Buffer + p := message.NewPrinter(language.English) + w := tabwriter.NewWriter(&b, 0, 0, 3, ' ', tabwriter.TabIndent) + + if t.commitAuthorSummaries != nil && t.commitSummary != nil { + r := strings.Join([]string{ + "Author", + "Commits", + "Commit %", + "Files Δ", + "Additions", + "Deletions", + "First Commit", + "Latest Commit", + }, "\t") + + p.Fprintln(w, r) + + for _, authorRow := range t.commitAuthorSummaries { + commitPercent := (float32(authorRow.Commits) / float32(t.commitSummary.Total)) * 100.0 + + var firstCommit, lastCommit time.Time + var err error + if firstCommit, err = time.Parse(time.RFC3339, authorRow.FirstCommit); err != nil { + return err.Error() + } + if lastCommit, err = time.Parse(time.RFC3339, authorRow.LastCommit); err != nil { + return err.Error() + } + + r := strings.Join([]string{ + authorRow.AuthorName, + p.Sprintf("%d", authorRow.Commits), + p.Sprintf("%.2f%%", commitPercent), + p.Sprintf("%d", authorRow.DistinctFiles), + p.Sprintf("%d", authorRow.Additions), + p.Sprintf("%d", authorRow.Deletions), + p.Sprintf("%s (%s)", timediff.TimeDiff(firstCommit), firstCommit.Format("2006-01-02")), + p.Sprintf("%s (%s)", timediff.TimeDiff(lastCommit), lastCommit.Format("2006-01-02")), + }, "\t") + + p.Fprintln(w, r) + } + + if err := w.Flush(); err != nil { + return err.Error() + } + + d := t.commitSummary.DistinctAuthors - len(t.commitAuthorSummaries) + if d == 1 { + p.Fprintf(&b, "...1 more author\n") + } else if d > 1 { + p.Fprintf(&b, "...%d more authors\n", d) + } + } else { + p.Fprintln(&b, "Loading authors", t.spinner.View()) + } + + return b.String() +} + +func (t *TermUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case error: + t.err = msg + return t, tea.Quit + + case tea.KeyMsg: + switch msg.String() { + + case "ctrl+c", "q": + return t, tea.Quit + } + + default: + if t.commitSummary != nil && t.commitAuthorSummaries != nil && t.commitsPreloaded { + return t, tea.Quit + } + var cmd tea.Cmd + t.spinner, cmd = t.spinner.Update(msg) + return t, cmd + } + + return t, nil +} + +func (t *TermUI) View() string { + if t.err != nil { + return t.err.Error() + } + + var b bytes.Buffer + fmt.Fprint(&b, t.renderCommitSummaryTable(true)) + fmt.Fprint(&b, t.renderCommitAuthorSummary()) + + return b.String() +} + +func (t *TermUI) PrintNoTTY() string { + t.preloadCommits() + t.loadCommitSummary() + t.loadAuthorCommitSummary() + + if t.err != nil { + return t.err.Error() + } + + var b bytes.Buffer + fmt.Fprint(&b, t.renderCommitSummaryTable(false)) + fmt.Fprint(&b, t.renderCommitAuthorSummary()) + + return b.String() +} + +func (t *TermUI) Close() error { + return t.db.Close() +} diff --git a/go.mod b/go.mod index 038cc844..31e8f9f0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/BurntSushi/toml v1.0.0 github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/augmentable-dev/vtab v0.0.0-20210915151038-6572bfc4e313 + github.com/charmbracelet/bubbles v0.10.2 + github.com/charmbracelet/bubbletea v0.19.3 github.com/charmbracelet/lipgloss v0.4.0 github.com/clbanning/mxj/v2 v2.5.5 github.com/dnaeon/go-vcr/v2 v2.0.1 @@ -36,6 +38,7 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f // indirect github.com/acomagu/bufpipe v1.0.3 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/containerd/console v1.0.2 // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect @@ -54,6 +57,7 @@ require ( github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.9.0 // indirect github.com/oklog/ulid v1.3.1 // indirect diff --git a/go.sum b/go.sum index 14186fd1..c3bfb123 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,7 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/augmentable-dev/vtab v0.0.0-20210915151038-6572bfc4e313 h1:kyV6lLIx/yV3xES4xavMd5wBy6/pe6h8szDro/HYQX8= github.com/augmentable-dev/vtab v0.0.0-20210915151038-6572bfc4e313/go.mod h1:V9gfz3soLoNlNg57mb6q91j7VznSDo+r2xyszQ4CzkY= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -92,6 +93,11 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.10.2 h1:VK1Q7nnBMDFTlrMmvBgE9nidtU5udsIcZvFXvjE2Cfk= +github.com/charmbracelet/bubbles v0.10.2/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -112,6 +118,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -327,6 +335,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -382,6 +391,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= @@ -435,6 +446,7 @@ github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= @@ -720,6 +732,7 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From cfeaa84d8a6988ddbf3f8a10a5b35a3c3d3724c7 Mon Sep 17 00:00:00 2001 From: Patrick DeVivo Date: Tue, 25 Jan 2022 17:45:42 -0500 Subject: [PATCH 2/4] feat: add file path based filtering, some other improvements --- cmd/summary.go | 9 +++-- cmd/summary/summary.go | 75 +++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/cmd/summary.go b/cmd/summary.go index d0ae3a60..2e8b4e29 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -12,11 +12,16 @@ import ( var summaryCmd = &cobra.Command{ Use: "summary", Long: "prints a summary of commit activity in the default repository.", - Args: cobra.ExactArgs(0), + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { + var pathPattern string + if len(args) > 0 { + pathPattern = args[0] + } + var ui *summary.TermUI var err error - if ui, err = summary.NewTermUI(); err != nil { + if ui, err = summary.NewTermUI(pathPattern); err != nil { handleExitError(err) } defer ui.Close() diff --git a/cmd/summary/summary.go b/cmd/summary/summary.go index a405d6f7..fee5c6ef 100644 --- a/cmd/summary/summary.go +++ b/cmd/summary/summary.go @@ -2,6 +2,7 @@ package summary import ( "bytes" + "database/sql" "fmt" "strings" "text/tabwriter" @@ -17,18 +18,17 @@ import ( ) type CommitSummary struct { - Total int `db:"total"` - TotalNonMerges int `db:"total_non_merges"` - FirstCommit string `db:"first_commit"` - LastCommit string `db:"last_commit"` - FirstCommitT time.Time - LastCommitT time.Time - DistinctAuthors int `db:"distinct_authors"` - DistinctFiles int `db:"distinct_files"` + Total int `db:"total"` + TotalNonMerges int `db:"total_non_merges"` + FirstCommit sql.NullString `db:"first_commit"` + LastCommit sql.NullString `db:"last_commit"` + DistinctAuthors int `db:"distinct_authors"` + DistinctFiles int `db:"distinct_files"` } const preloadCommitsSQL = ` -CREATE TABLE preloaded_commits AS SELECT * FROM commits; +CREATE TABLE preloaded_commit_stats AS SELECT * FROM commits, stats('', commits.hash) WHERE file_path LIKE ?; +CREATE TABLE preloaded_commits AS SELECT hash, author_name, author_email, author_when, parents FROM preloaded_commit_stats GROUP BY hash; ` const commitSummarySQL = ` @@ -38,7 +38,7 @@ SELECT (SELECT author_when FROM preloaded_commits ORDER BY author_when ASC LIMIT 1) AS first_commit, (SELECT author_when FROM preloaded_commits ORDER BY author_when DESC LIMIT 1) AS last_commit, (SELECT count(distinct(author_email || author_name)) FROM preloaded_commits) AS distinct_authors, - (SELECT count(distinct(path)) FROM files) AS distinct_files + (SELECT count(distinct(path)) FROM files WHERE path LIKE ?) AS distinct_files ` type CommitAuthorSummary struct { @@ -61,7 +61,7 @@ SELECT count(distinct file_path) AS distinct_files, min(author_when) AS first_commit, max(author_when) AS last_commit -FROM preloaded_commits, stats('', preloaded_commits.hash) +FROM preloaded_commit_stats GROUP BY author_name, author_email ORDER BY commit_count DESC LIMIT 25 @@ -69,29 +69,36 @@ LIMIT 25 type TermUI struct { db *sqlx.DB + pathPattern string err error spinner spinner.Model commitsPreloaded bool commitSummary *CommitSummary - commitAuthorSummaries []*CommitAuthorSummary + commitAuthorSummaries *[]*CommitAuthorSummary } -func NewTermUI() (*TermUI, error) { +func NewTermUI(pathPattern string) (*TermUI, error) { var db *sqlx.DB var err error if db, err = sqlx.Open("sqlite3", "file::memory:?cache=shared"); err != nil { return nil, fmt.Errorf("failed to initialize database connection: %v", err) } db.SetMaxOpenConns(1) + s := spinner.New() s.Spinner = spinner.Spinner{ Frames: []string{".", "..", "..."}, FPS: 300 * time.Millisecond, } + if pathPattern == "" { + pathPattern = "%" + } + return &TermUI{ - db: db, - spinner: s, + db: db, + pathPattern: pathPattern, + spinner: s, }, nil } @@ -105,7 +112,7 @@ func (t *TermUI) Init() tea.Cmd { } func (t *TermUI) preloadCommits() tea.Msg { - if _, err := t.db.Exec(preloadCommitsSQL); err != nil { + if _, err := t.db.Exec(preloadCommitsSQL, t.pathPattern); err != nil { return err } @@ -118,15 +125,7 @@ func (t *TermUI) loadCommitSummary() tea.Msg { time.Sleep(2 * time.Second) } var commitSummary CommitSummary - if err := t.db.QueryRowx(commitSummarySQL).StructScan(&commitSummary); err != nil { - return err - } - - var err error - if commitSummary.FirstCommitT, err = time.Parse(time.RFC3339, commitSummary.FirstCommit); err != nil { - return err - } - if commitSummary.LastCommitT, err = time.Parse(time.RFC3339, commitSummary.LastCommit); err != nil { + if err := t.db.QueryRowx(commitSummarySQL, t.pathPattern).StructScan(&commitSummary); err != nil { return err } @@ -143,7 +142,7 @@ func (t *TermUI) loadAuthorCommitSummary() tea.Msg { return err } - t.commitAuthorSummaries = commitAuthorSummaries + t.commitAuthorSummaries = &commitAuthorSummaries return nil } @@ -159,8 +158,19 @@ func (t *TermUI) renderCommitSummaryTable(boldHeader bool) string { totalNonMerges = p.Sprintf("%d", t.commitSummary.TotalNonMerges) distinctFiles = p.Sprintf("%d", t.commitSummary.DistinctFiles) distinctAuthors = p.Sprintf("%d", t.commitSummary.DistinctAuthors) - firstCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(t.commitSummary.FirstCommitT), t.commitSummary.FirstCommitT.Format("2006-01-02")) - lastCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(t.commitSummary.LastCommitT), t.commitSummary.LastCommitT.Format("2006-01-02")) + + firstCommit, lastCommit = "", "" + + if t.commitSummary.FirstCommit.Valid { + when, _ := time.Parse(time.RFC3339, t.commitSummary.FirstCommit.String) + firstCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(when), when.Format("2006-01-02")) + } + + if t.commitSummary.LastCommit.Valid { + when, _ := time.Parse(time.RFC3339, t.commitSummary.LastCommit.String) + lastCommit = fmt.Sprintf("%s (%s)", timediff.TimeDiff(when), when.Format("2006-01-02")) + } + } else { total = t.spinner.View() totalNonMerges = t.spinner.View() @@ -198,6 +208,11 @@ func (t *TermUI) renderCommitAuthorSummary() string { w := tabwriter.NewWriter(&b, 0, 0, 3, ' ', tabwriter.TabIndent) if t.commitAuthorSummaries != nil && t.commitSummary != nil { + + if len(*t.commitAuthorSummaries) == 0 { + return "" + } + r := strings.Join([]string{ "Author", "Commits", @@ -211,7 +226,7 @@ func (t *TermUI) renderCommitAuthorSummary() string { p.Fprintln(w, r) - for _, authorRow := range t.commitAuthorSummaries { + for _, authorRow := range *t.commitAuthorSummaries { commitPercent := (float32(authorRow.Commits) / float32(t.commitSummary.Total)) * 100.0 var firstCommit, lastCommit time.Time @@ -241,7 +256,7 @@ func (t *TermUI) renderCommitAuthorSummary() string { return err.Error() } - d := t.commitSummary.DistinctAuthors - len(t.commitAuthorSummaries) + d := t.commitSummary.DistinctAuthors - len(*t.commitAuthorSummaries) if d == 1 { p.Fprintf(&b, "...1 more author\n") } else if d > 1 { From 1cc7bcb0b597be5e87dd50321e6a80f6aaec53ca Mon Sep 17 00:00:00 2001 From: Patrick DeVivo Date: Tue, 25 Jan 2022 19:08:12 -0500 Subject: [PATCH 3/4] doc: improve some help text --- cmd/root.go | 7 +++---- cmd/summary.go | 9 +++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 040da9a4..af0a0899 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,10 +75,9 @@ func handleExitError(err error) { var rootCmd = &cobra.Command{ Use: `mergestat "SELECT * FROM commits"`, Args: cobra.MaximumNArgs(2), - Long: ` - mergestat is a CLI for querying git repositories with SQL, using SQLite virtual tables. - Example queries can be found in the GitHub repo: https://github.com/mergestat/mergestat`, - Short: `query your github repos with SQL`, + Long: `mergestat is a CLI for querying git repositories with SQL, using SQLite virtual tables. +Example queries can be found in the GitHub repo: https://github.com/mergestat/mergestat`, + Short: `Query git repositories with SQL`, Run: func(cmd *cobra.Command, args []string) { var err error diff --git a/cmd/summary.go b/cmd/summary.go index 2e8b4e29..3e356100 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -10,8 +10,13 @@ import ( ) var summaryCmd = &cobra.Command{ - Use: "summary", - Long: "prints a summary of commit activity in the default repository.", + Use: "summary [file pattern]", + Short: "Print a summary of commit activity", + Long: `Prints a summary of commit activity in the default repository (either the current directory or supplied by --repo). +Specify a file pattern as an argument to filter for commits that only modified a certain file or directory. +The path is used in a SQL LIKE clause, so use '%' as a wildcard. +Read more here: https://sqlite.org/lang_expr.html#the_like_glob_regexp_and_match_operators +`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { var pathPattern string From 4f0f8b1658c2a22f77cbe27c664fbf4a920a4eb0 Mon Sep 17 00:00:00 2001 From: Patrick DeVivo Date: Tue, 25 Jan 2022 19:21:55 -0500 Subject: [PATCH 4/4] fix: reduce sleep to something more reasonable --- cmd/summary/summary.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/summary/summary.go b/cmd/summary/summary.go index fee5c6ef..4d91788d 100644 --- a/cmd/summary/summary.go +++ b/cmd/summary/summary.go @@ -122,7 +122,7 @@ func (t *TermUI) preloadCommits() tea.Msg { func (t *TermUI) loadCommitSummary() tea.Msg { for !t.commitsPreloaded { - time.Sleep(2 * time.Second) + time.Sleep(300 * time.Millisecond) } var commitSummary CommitSummary if err := t.db.QueryRowx(commitSummarySQL, t.pathPattern).StructScan(&commitSummary); err != nil { @@ -135,7 +135,7 @@ func (t *TermUI) loadCommitSummary() tea.Msg { func (t *TermUI) loadAuthorCommitSummary() tea.Msg { for !t.commitsPreloaded { - time.Sleep(2 * time.Second) + time.Sleep(300 * time.Millisecond) } var commitAuthorSummaries []*CommitAuthorSummary if err := t.db.Select(&commitAuthorSummaries, commitAuthorSummarySQL); err != nil {