From f54d40039d51b7293090605f0f31f3bd3dff3858 Mon Sep 17 00:00:00 2001 From: lex0c Date: Mon, 20 Apr 2026 00:13:02 -0300 Subject: [PATCH] Add --from/--to closed-window filter to report and stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report command only supported --since (relative window "last N days/months"). stats was the same. Users wanting a past quarter or a release-to-release window had to either re-run extract (heavy, costs minutes on large repos) or fall back to diff — which outputs a comparison table instead of a full report/stats view. Add --from and --to on both report and stats (already existed on diff). Dates are YYYY-MM-DD, both boundaries inclusive, UTC day granularity (matches LoadOptions.From/To semantics already used by diff). --since and --from/--to are mutually exclusive at the CLI layer — both specify a window and combining them is ambiguous ("does --since shift --from forward, or override it?"). --from > --to is rejected up front. Parity check: "Q1 2025 on pi-hole" returns 156 commits / 17 devs under both `stats --stat summary` and `report`, with boundaries 2025-01-01 through 2025-03-31 inclusive. Edge cases verified: open-forward (only --from), open-backward (only --to), single-day window, and pre-repo-birth empty window (0 commits, graceful). Filter lives at load time, not extract time — consistent with the design principle that extract produces canonical raw data and consumers apply analytical lenses. Rename chains stay intact in the JSONL; only what gets displayed changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++ cmd/gitcortex/main.go | 78 ++++++++++++++++++++++++++++++++++---- cmd/gitcortex/main_test.go | 43 +++++++++++++++++++++ docs/RUNBOOK.md | 11 ++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 cmd/gitcortex/main_test.go diff --git a/README.md b/README.md index 37ffb99..29ea136 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/gitcortex/main.go b/cmd/gitcortex/main.go index d4da8ab..50267c4 100644 --- a/cmd/gitcortex/main.go +++ b/cmd/gitcortex/main.go @@ -120,6 +120,8 @@ type statsFlags struct { granularity string stat string since string + from string + to string couplingMaxFiles int couplingMinChanges int churnHalfLife int @@ -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)") } @@ -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 } @@ -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, }) @@ -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 @@ -738,6 +775,8 @@ func reportCmd() *cobra.Command { topN int email string since string + from string + to string couplingMaxFiles int couplingMinChanges int churnHalfLife int @@ -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, }) @@ -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 } diff --git a/cmd/gitcortex/main_test.go b/cmd/gitcortex/main_test.go new file mode 100644 index 0000000..57410bf --- /dev/null +++ b/cmd/gitcortex/main_test.go @@ -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) + } + } +} diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 8bf5256..0499a4e 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -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