Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ gitcortex stats --input data.jsonl --stat activity --granularity week
gitcortex stats --since 7d # last 7 days
gitcortex stats --since 3m --stat contributors # last 3 months
gitcortex report --since 30d --output monthly.html

# Closed window (arbitrary start/end — e.g. past quarter)
gitcortex stats --from 2026-01-01 --to 2026-03-31 --stat contributors
gitcortex report --from 2026-01-01 --to 2026-03-31 --output q1.html
gitcortex report --from 2025-06-01 --output post-release.html # open-ended forward
gitcortex report --to 2024-12-31 --output pre-2025.html # open-ended backward
```

Available stats:
Expand Down
78 changes: 71 additions & 7 deletions cmd/gitcortex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ type statsFlags struct {
granularity string
stat string
since string
from string
to string
couplingMaxFiles int
couplingMinChanges int
churnHalfLife int
Expand All @@ -140,6 +142,8 @@ func addStatsFlags(cmd *cobra.Command, sf *statsFlags) {
cmd.Flags().IntVar(&sf.networkMinFiles, "network-min-files", 5, "Min shared files for dev-network edges")
cmd.Flags().StringVar(&sf.email, "email", "", "Filter by developer email (for profile stat)")
cmd.Flags().StringVar(&sf.since, "since", "", "Filter to recent period (e.g. 7d, 4w, 3m, 1y)")
cmd.Flags().StringVar(&sf.from, "from", "", "Window start date YYYY-MM-DD, inclusive (pair with --to for closed window; leave --to empty for open-ended)")
cmd.Flags().StringVar(&sf.to, "to", "", "Window end date YYYY-MM-DD, inclusive (pair with --from; leave --from empty for 'up to this date')")
cmd.Flags().IntVar(&sf.treeDepth, "tree-depth", 3, "Max depth for --stat structure (0 = unlimited)")
}

Expand All @@ -153,6 +157,18 @@ func validateStatsFlags(sf *statsFlags) error {
if sf.stat != "" && !isValidStat(sf.stat) {
return fmt.Errorf("invalid --stat %q; valid: summary, contributors, hotspots, directories, extensions, activity, busfactor, coupling, churn-risk, working-patterns, dev-network, profile, top-commits, pareto, structure", sf.stat)
}
if sf.since != "" && (sf.from != "" || sf.to != "") {
return fmt.Errorf("--since cannot be combined with --from/--to; pick one window spec")
}
if err := validateDate(sf.from, "--from"); err != nil {
return err
}
if err := validateDate(sf.to, "--to"); err != nil {
return err
}
if sf.from != "" && sf.to != "" && sf.from > sf.to {
return fmt.Errorf("--from (%s) must be on or before --to (%s)", sf.from, sf.to)
}
return nil
}

Expand All @@ -167,13 +183,18 @@ func statsCmd() *cobra.Command {
return err
}

sinceDate, err := parseSince(sf.since)
if err != nil {
return err
fromDate := sf.from
if sf.since != "" {
d, err := parseSince(sf.since)
if err != nil {
return err
}
fromDate = d
}

ds, err := stats.LoadMultiJSONL(sf.inputs, stats.LoadOptions{
From: sinceDate,
From: fromDate,
To: sf.to,
HalfLifeDays: sf.churnHalfLife,
CoupMaxFiles: sf.couplingMaxFiles,
})
Expand Down Expand Up @@ -666,6 +687,22 @@ func printCIJSON(violations []ciViolation) {
enc.Encode(violations)
}

// validateDate accepts "" (treated as "no bound") or a YYYY-MM-DD
// literal. The stats loader compares dates as strings — ISO-8601 date
// literals sort lexicographically, so comparison semantics don't need
// a time.Time round-trip. Parse is still used here to reject
// garbage like "2024-13-40" up front with a clear CLI error instead
// of silently loading an empty window.
func validateDate(s, flag string) error {
if s == "" {
return nil
}
if _, err := time.Parse("2006-01-02", s); err != nil {
return fmt.Errorf("invalid %s %q; expected YYYY-MM-DD", flag, s)
}
return nil
}

func parseSince(s string) (string, error) {
if s == "" {
return "", nil
Expand Down Expand Up @@ -738,6 +775,8 @@ func reportCmd() *cobra.Command {
topN int
email string
since string
from string
to string
couplingMaxFiles int
couplingMinChanges int
churnHalfLife int
Expand All @@ -748,13 +787,36 @@ func reportCmd() *cobra.Command {
Use: "report",
Short: "Generate a self-contained HTML report",
RunE: func(cmd *cobra.Command, args []string) error {
sinceDate, err := parseSince(since)
if err != nil {
// --since and --from/--to express overlapping intent
// (select a window); combining them is ambiguous — does
// --since push the start past --from, or does --from
// override? Reject the combination explicitly instead of
// picking one silently.
if since != "" && (from != "" || to != "") {
return fmt.Errorf("--since cannot be combined with --from/--to; pick one window spec")
}
if err := validateDate(from, "--from"); err != nil {
return err
}
if err := validateDate(to, "--to"); err != nil {
return err
}
if from != "" && to != "" && from > to {
return fmt.Errorf("--from (%s) must be on or before --to (%s)", from, to)
}

fromDate := from
if since != "" {
d, err := parseSince(since)
if err != nil {
return err
}
fromDate = d
}

ds, err := stats.LoadJSONL(input, stats.LoadOptions{
From: sinceDate,
From: fromDate,
To: to,
HalfLifeDays: churnHalfLife,
CoupMaxFiles: couplingMaxFiles,
})
Expand Down Expand Up @@ -802,6 +864,8 @@ func reportCmd() *cobra.Command {
cmd.Flags().IntVar(&churnHalfLife, "churn-half-life", 90, "Half-life in days for churn decay")
cmd.Flags().IntVar(&networkMinFiles, "network-min-files", 5, "Min shared files for dev-network edges")
cmd.Flags().StringVar(&since, "since", "", "Filter to recent period (e.g. 7d, 4w, 3m, 1y)")
cmd.Flags().StringVar(&from, "from", "", "Window start date YYYY-MM-DD, inclusive (pair with --to for closed window; leave --to empty for open-ended)")
cmd.Flags().StringVar(&to, "to", "", "Window end date YYYY-MM-DD, inclusive (pair with --from; leave --from empty for 'up to this date')")

return cmd
}
43 changes: 43 additions & 0 deletions cmd/gitcortex/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"strings"
"testing"
)

func TestValidateDate(t *testing.T) {
cases := []struct {
in string
wantErr bool
}{
// Happy path.
{"2024-01-01", false},
{"2026-04-20", false},
// Empty means "no bound" — the report command treats it as
// "don't apply this end of the window".
{"", false},
// Rejections — each must fail BEFORE LoadJSONL runs so the
// user sees a CLI-shaped error, not a silent empty dataset.
{"2024-13-01", true}, // month out of range
{"2024-02-30", true}, // day impossible for February
{"2024/01/01", true}, // wrong separator
{"20240101", true}, // wrong format
{"not-a-date", true},
{"Q1 2024", true}, // common user mistake
}
for _, c := range cases {
err := validateDate(c.in, "--from")
if c.wantErr && err == nil {
t.Errorf("validateDate(%q) = nil, want error", c.in)
}
if !c.wantErr && err != nil {
t.Errorf("validateDate(%q) = %v, want nil", c.in, err)
}
// When error is emitted, the flag name must appear so the CLI
// message points the user at the right arg. Prevents a future
// refactor from dropping the context.
if err != nil && !strings.Contains(err.Error(), "--from") {
t.Errorf("validateDate(%q) error %q does not mention flag name", c.in, err)
}
}
}
11 changes: 11 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ Section headers go to stderr, data to stdout. To capture only data:

Supported units: `d` (days), `w` (weeks), `m` (months), `y` (years).

For a **closed window** (arbitrary start/end dates, e.g. a past quarter), use `--from` and `--to` instead of `--since` on both `stats` and `report`:

```bash
./gitcortex stats --from 2026-01-01 --to 2026-03-31 --stat contributors # Q1 contributors
./gitcortex report --from 2026-01-01 --to 2026-03-31 --output q1.html # Q1 HTML report
./gitcortex stats --from 2025-06-01 # from date X onward
./gitcortex report --to 2024-12-31 --output pre-2025.html # up to date Y
```

Dates are `YYYY-MM-DD` and **both boundaries are inclusive** — `--from 2026-01-01 --to 2026-03-31` captures every commit on 2026-01-01 through 2026-03-31 at UTC day boundaries. `--since` and `--from`/`--to` are mutually exclusive (they both specify a window, combining them is ambiguous).

### Output formats

```bash
Expand Down
Loading