pretty-git 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e190585a91de27221e54bc2cbc58b5f6d26a1428c2e5cd5189ff0c18d362e6c
4
- data.tar.gz: f8d2bc8f5d6c6a4887a745abd611a038166bcb54e95267a15d4868ee9d530003
3
+ metadata.gz: 7bd982eee724a56adcb2aa941fdecc2c95309c1bf681a5c3b9a1be0b8bf0306f
4
+ data.tar.gz: 1ef12cd144493563bbe99a869e81fe6aa9436136dc7aac52b713d5436975a48a
5
5
  SHA512:
6
- metadata.gz: e11fd51d24ed1e63fd5c773f76a46510bbcedad23c02ace63b25d7d80c366479fd02fe2bfabaa58693389c222bafe4fd9e6fc0b157b7d598e96294a3f888fbef
7
- data.tar.gz: 38fc87c6467d72ae6a1cb63a13b06c417e81f714d5b074feae961f121e061cea0733494020e0b8f562f36ea00b2d537a0d6fd5ac36eea942465680376f8eb71e
6
+ metadata.gz: fba3dc59a53571d80cf38e13a555b2ad62b44964f29039a1fe68000349f642d024bb854542d62d58608dd8f9a8169548b0444a0fc8488cccc9a8e3497ba68ade
7
+ data.tar.gz: 1f8037f3e7ef97e13b60be37c6d447c684586ffb78017d1905fc6b75d4d055ce533d4a87f04baf6e15fa734fa20fcbfe8854f5f4ef429c402106cbbf4a9a70b5
data/CHANGELOG.md CHANGED
@@ -5,7 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
6
6
 
7
7
  ## [Unreleased]
8
-
8
+ ### Added
9
+ - Docs: `docs/testing.md` covering the golden workflow, snapshot update/validation; linked from `README.md`, `README.ru.md`, and `CONTRIBUTING.md`.
10
+ - Tests: determinism invariants for YAML/XML renderers (stable output regardless of input order).
11
+
12
+ ### Changed
13
+ - Completions: refreshed bash/zsh completions — added short flags `-f`/`-o`/`-l`, values for `--time-bucket` (`day|week|month`), and repo path completion as the 2nd positional argument.
14
+
15
+ ## [0.1.5] - 2025-08-17
16
+ ### Added
17
+ - CLI: warn to stderr when `--theme`/`--no-color` are used with non-console `--format` values.
18
+ - Docs: expanded Filters documentation (branches, authors, paths, time semantics, verbose diagnostics), schemas/examples pointers, performance and CI usage.
19
+ - Tests: unit tests for `PrettyGit::Utils::TimeUtils`.
20
+
21
+ ### Changed
22
+ - Internals: extracted time parsing/normalization to `PrettyGit::Utils::TimeUtils` and centralized verbose logging via `PrettyGit::Logger`. `Git::Provider` routes verbose messages through the centralized logger (stderr).
23
+ - Verbose mode: documentation clarified to note that diagnostics are printed to stderr for easier CI parsing.
24
+
25
+ ### Deprecated
26
+ - Filters: legacy `:until` keyword in `PrettyGit::Filters` initialization is accepted for backward compatibility and emits a deprecation warning; use `:until_at` instead.
27
+
28
+ ### Fixed
29
+ - Filters: allow initialization via a single Hash argument (legacy call sites) while preserving `Struct` keyword semantics.
9
30
 
10
31
  ## [0.1.4] - 2025-08-17
11
32
  ### Added
data/README.md CHANGED
@@ -58,6 +58,13 @@ Generator of rich reports for a local Git repository: summary, activity, authors
58
58
  * **Exports**: `console`, `json`, `csv`, `md`, `yaml`, `xml`.
59
59
  * **Output**: to stdout or file via `--out`.
60
60
 
61
+ ## ❓ Why Pretty Git
62
+ * **One tool, many views**: activity, authorship, hotspots, languages, ownership — consistent UX and outputs.
63
+ * **Deterministic results**: stable sorting and formatting make it reliable for CI and diffs.
64
+ * **Format-first**: JSON/CSV/Markdown/YAML/XML out of the box with strict and documented rules.
65
+ * **Fast enough for daily use**: streams `git log` and aggregates in-memory; tips below for large repos.
66
+ * **Safe defaults**: sensible path and binary ignores for the `languages` report; colorized console output with themes.
67
+
61
68
  ## ⚙️ Requirements
62
69
  * **Ruby**: >= 3.4 (recommended 3.4.x)
63
70
  * **Git**: installed and available in `PATH`
@@ -142,12 +149,15 @@ Key options:
142
149
  * **--no-color** Disable colors in console
143
150
  * **--theme** `basic|bright|mono` — console theme (default `basic`; `mono` forces monochrome)
144
151
  * **--metric** `bytes|files|loc` — metric for `languages` report (default `bytes`)
152
+ * **--verbose** Print debug information (effective git command, filters)
145
153
 
146
154
  Examples with multiple values:
147
155
 
148
156
  ```bash
149
- # Multiple branches
157
+ # Multiple branches (treated as explicit revisions)
150
158
  pretty-git summary . --branch main --branch develop
159
+ ## This is equivalent to:
160
+ ## git log main develop -- ...
151
161
 
152
162
  # Filter authors (include/exclude)
153
163
  pretty-git authors . --author alice@example.com --exclude-author bot@company
@@ -157,7 +167,30 @@ pretty-git files . --path app,lib --exclude-path vendor,node_modules
157
167
  ```
158
168
 
159
169
  ### Filters
160
- Filters apply at commit fetch and later aggregation. Date format: ISO8601 or `YYYY-MM-DD`. If timezone is omitted your local zone is assumed; output timestamps are normalized to UTC.
170
+ Filters apply at commit fetch and later aggregation. Belowexact semantics and tips.
171
+
172
+ #### Branches / revisions
173
+ * `--branch BRANCH` may be provided multiple times.
174
+ * Multiple branches are treated as explicit revisions to `git log` (no implicit merge-base range). Example: `--branch main --branch develop` → `git log main develop -- ...`.
175
+ * If no branches are specified, the repository’s current `HEAD` is used.
176
+
177
+ #### Authors
178
+ * `--author` and `--exclude-author` accept name or email substrings (case-insensitive match by `git log`).
179
+ * Multiple values may be provided by repeating the option.
180
+
181
+ #### Paths
182
+ * `--path` and `--exclude-path` accept comma-separated values or repeated options.
183
+ * Globs are supported by git pathspec. Excludes are translated to `:(exclude)pattern` and applied consistently.
184
+ * When only excludes are present, `.` is included to ensure the pathspec is valid (mirrors tests in `spec/pretty_git/git/provider_spec.rb`).
185
+
186
+ #### Time period
187
+ * `--since` / `--until`: ISO8601 (e.g. `2025-01-31T12:00:00Z`) or `YYYY-MM-DD`.
188
+ * Date-only values are interpreted as UTC midnight to avoid timezone drift in different environments.
189
+ * Time values are normalized to UTC in outputs.
190
+
191
+ #### Verbose diagnostics
192
+ * `--verbose` prints the effective `git log` command and active filters to stderr.
193
+ * Useful for debugging filters, CI logs, or when reproducing results locally.
161
194
 
162
195
  ### Output format
163
196
  Set via `--format`. For file formats it’s recommended to use `--out`.
@@ -397,6 +430,13 @@ _Example terminal output (theme: basic)._
397
430
  {"report":"summary","generated_at":"2025-01-31T00:00:00Z","totals":{"commits":123}}
398
431
  ```
399
432
 
433
+ ## 🧾 Schemas and Examples
434
+ Machine-readable examples and schemas live under `docs/export_schemas/` and `docs/examples/`.
435
+
436
+ * **Schemas**: see `docs/export_schemas/README.md` for JSON and XML schema notes.
437
+ * **Examples**: example payloads for JSON/XML for each report under `docs/examples/`.
438
+ * Intended use: validation in CI, contract documentation, and integration tests.
439
+
400
440
  ### CSV
401
441
  * **Structure**: flat table, first line is header.
402
442
  * **Encoding**: UTF‑8 without BOM.
@@ -495,16 +535,46 @@ These lists mirror the implementation in `lib/pretty_git/analytics/languages.rb`
495
535
  ## 🔁 Determinism and Sorting
496
536
  Output is deterministic given the same input. Sorting for files/authors: by changes (desc), then by commits (desc), then by path/name (asc). Limits are applied after sorting; `all` or `0` means no limit.
497
537
 
538
+ ## ⚡ Performance Tips
539
+ * Prefer narrowing by `--path`/`--exclude-path` and `--since/--until` on large repositories.
540
+ * Use multiple `--branch` only when you explicitly want to include several heads; otherwise rely on current `HEAD`.
541
+ * For CI, cache the repository and fetch shallow history if full history is unnecessary for your report.
542
+
543
+ ## 🤖 CI Usage
544
+ Examples for common pipelines:
545
+
546
+ ```yaml
547
+ # GitHub Actions (excerpt)
548
+ jobs:
549
+ reports:
550
+ runs-on: ubuntu-latest
551
+ steps:
552
+ - uses: actions/checkout@v4
553
+ - uses: ruby/setup-ruby@v1
554
+ with:
555
+ ruby-version: '3.4'
556
+ - run: gem install pretty-git
557
+ - run: pretty-git authors . --format json --out authors.json
558
+ - uses: actions/upload-artifact@v4
559
+ with:
560
+ name: authors-report
561
+ path: authors.json
562
+ ```
563
+
498
564
  ## 🪟 Windows Notes
499
- Primary targets — macOS/Linux. Windows is supported best‑effort:
500
- * Running via Git Bash/WSL is OK
501
- * Colors can be disabled by `--no-color`
502
- * Carefully quote arguments when working with paths
565
+ Primary targets — macOS/Linux. Windows is supported best‑effort. See detailed notes in [docs/windows.md](docs/windows.md).
566
+
567
+ Highlights:
568
+ * Running via Git Bash/WSL is recommended.
569
+ * CRLF output from git is handled by the parser; exports use UTF‑8 with LF.
570
+ * Path filters are normalized to Unicode NFC when available; otherwise pass‑through.
571
+ * Colors can be disabled by `--no-color` or `--theme mono`.
503
572
 
504
573
  ## 🩺 Diagnostics and Errors
505
574
  Typical issues and solutions:
506
575
 
507
576
  * **Unknown report/format** — check the first argument and `--format`.
577
+ * **Debugging** — add `--verbose` to see the effective `git log` command and applied filters.
508
578
  * **Invalid date format** — use ISO8601 or `YYYY-MM-DD` (e.g., `2025-01-31` or `2025-01-31T12:00:00Z`).
509
579
  * **Git not available** — ensure `git` is installed and in the `PATH`.
510
580
  * **Empty result** — check your filters (`--since/--until`, `--branch`, `--path`); your selection might be too narrow.
@@ -527,5 +597,7 @@ bundle exec rubocop
527
597
 
528
598
  Style — RuboCop clean. Tests cover aggregators, renderers, CLI, and integration scenarios (determinism, format correctness).
529
599
 
600
+ For detailed testing strategy, determinism rules, and golden tests workflow (how to run/update snapshots), see `docs/testing.md`.
601
+
530
602
  ## 📄 License
531
603
  MIT © Contributors
data/README.ru.md CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  ## Содержание
22
22
  - [Возможности](#возможности)
23
+ - [Почему Pretty Git](#почему-pretty-git)
23
24
  - [Требования](#требования)
24
25
  - [Установка](#установка)
25
26
  - [Быстрый старт](#быстрый-старт)
@@ -41,11 +42,14 @@
41
42
  - [Экспорт в форматы](#экспорт-в-форматы)
42
43
  - [Console](#console)
43
44
  - [JSON](#json)
45
+ - [Схемы и примеры](#схемы-и-примеры)
44
46
  - [CSV](#csv)
45
47
  - [Markdown](#markdown)
46
48
  - [YAML](#yaml)
47
49
  - [XML](#xml)
48
50
  - [Детерминизм и сортировка](#детерминизм-и-сортировка)
51
+ - [Советы по производительности](#советы-по-производительности)
52
+ - [Использование в CI](#использование-в-ci)
49
53
  - [Советы по Windows](#советы-по-windows)
50
54
  - [Диагностика и ошибки](#диагностика-и-ошибки)
51
55
  - [FAQ](#faq)
@@ -58,6 +62,13 @@
58
62
  * __Экспорт__: `console`, `json`, `csv`, `md`, `yaml`, `xml`.
59
63
  * __Вывод__: в stdout или файл через `--out`.
60
64
 
65
+ ## ❓ Почему Pretty Git
66
+ * __Один инструмент — много представлений__: активность, авторство, риск, языки, владение — единый UX и форматы.
67
+ * __Детерминированные результаты__: стабильные сортировки и форматирование — удобно для CI и диффов.
68
+ * __Сразу форматы__: JSON/CSV/Markdown/YAML/XML из коробки с чёткими правилами.
69
+ * __Достаточно быстро__: потоковый `git log` и ин‑мемори агрегации; советы для больших репозиториев ниже.
70
+ * __Безопасные дефолты__: разумные игноры путей и бинарников для отчёта `languages`; цветной консольный вывод с темами.
71
+
61
72
  ## ⚙️ Требования
62
73
  * __Ruby__: >= 3.4 (рекомендуется 3.4.x)
63
74
  * __Git__: установлен и доступен в `PATH`
@@ -142,12 +153,15 @@ pretty-git <report> <repo_path> [options]
142
153
  * __--no-color__ Отключить цвета в консоли
143
154
  * __--theme__ `basic|bright|mono` — тема оформления консольного вывода (по умолчанию `basic`; `mono` принудительно отключает цвета)
144
155
  * __--metric__ `bytes|files|loc` — метрика для отчёта `languages` (по умолчанию `bytes`)
156
+ * **--verbose** Печатать отладочную информацию (эффективная команда git, применённые фильтры)
145
157
 
146
158
  Примеры значений с несколькими параметрами:
147
159
 
148
160
  ```bash
149
- # Несколько веток
161
+ # Несколько веток (трактуются как явные ревизии)
150
162
  pretty-git summary . --branch main --branch develop
163
+ ## Эквивалентно:
164
+ ## git log main develop -- ...
151
165
 
152
166
  # Фильтрация по авторам (включая/исключая)
153
167
  pretty-git authors . --author alice@example.com --exclude-author bot@company
@@ -157,7 +171,30 @@ pretty-git files . --path app,lib --exclude-path vendor,node_modules
157
171
  ```
158
172
 
159
173
  ### Фильтры
160
- Фильтры применяются на этапе выборки коммитов и последующей агрегации. Формат дат: ISO8601 или `YYYY-MM-DD`. Если часовой пояс не указан используется локальная зона пользователя; на выводе время нормализуется к UTC.
174
+ Фильтры применяются на этапе выборки коммитов и последующей агрегации. Нижеточные правила и советы.
175
+
176
+ #### Ветки / ревизии
177
+ * `--branch BRANCH` можно указывать несколько раз.
178
+ * Несколько веток трактуются как явные ревизии для `git log` (без неявного диапазона от merge-base). Пример: `--branch main --branch develop` → `git log main develop -- ...`.
179
+ * Если ветки не указаны — используется текущий `HEAD` репозитория.
180
+
181
+ #### Авторы
182
+ * `--author` и `--exclude-author` принимают подстроки имени или email (регистронезависимо по логике `git log`).
183
+ * Можно передавать несколько значений повтором опции.
184
+
185
+ #### Пути
186
+ * `--path` и `--exclude-path` принимают значения через запятую или повтором опции.
187
+ * Поддерживаются glob‑маски git pathspec. Исключения переводятся в `:(exclude)pattern` и применяются последовательно.
188
+ * Если заданы только исключения — автоматически добавляется `.` для корректного pathspec (как в тестах `spec/pretty_git/git/provider_spec.rb`).
189
+
190
+ #### Период времени
191
+ * `--since` / `--until`: ISO8601 (например, `2025-01-31T12:00:00Z`) или `YYYY-MM-DD`.
192
+ * Даты без времени интерпретируются как полночь UTC, чтобы исключить сдвиги между средами.
193
+ * На выводе время нормализуется к UTC.
194
+
195
+ #### Подробная диагностика (verbose)
196
+ * `--verbose` печатает эффективную команду `git log` и активные фильтры в stderr.
197
+ * Полезно для отладки фильтров, логов CI и воспроизведения результатов локально.
161
198
 
162
199
  ### Формат вывода
163
200
  Задаётся через `--format`. Для файловых форматов рекомендуется использовать `--out`.
@@ -423,6 +460,13 @@ _Пример вывода в терминале (тема: basic)._
423
460
  {"report":"summary","generated_at":"2025-01-31T00:00:00Z","totals":{"commits":123}}
424
461
  ```
425
462
 
463
+ ## 🧾 Схемы и примеры
464
+ Машиночитаемые примеры и схемы лежат в `docs/export_schemas/` и `docs/examples/`.
465
+
466
+ * __Схемы__: смотрите `docs/export_schemas/README.md` с заметками по JSON и XML схемам.
467
+ * __Примеры__: примеры нагрузок для JSON/XML по каждому отчёту — в `docs/examples/`.
468
+ * Назначение: валидация в CI, документация контрактов и интеграционные тесты.
469
+
426
470
  ### CSV
427
471
  * __Структура__: плоская таблица, первая строка — заголовок.
428
472
  * __Кодировка__: UTF‑8, без BOM.
@@ -496,6 +540,32 @@ _Пример вывода в терминале (тема: basic)._
496
540
  ## 🔁 Детерминизм и сортировка
497
541
  Вывод детерминирован при одинаковых входных данных. Сортировка для файлов/авторов: по количеству изменений (desc), затем по числу коммитов (desc), затем по пути/имени (asc). Лимиты применяются поверх отсортированного списка; значение `all` или `0` означает отсутствие ограничения.
498
542
 
543
+ ## ⚡ Советы по производительности
544
+ * На больших репозиториях сужайте выборку `--path/--exclude-path` и `--since/--until`.
545
+ * Несколько `--branch` используйте, только если нужно включить несколько голов; иначе полагайтесь на текущий `HEAD`.
546
+ * В CI кешируйте репозиторий и используйте shallow‑fetch, если полная история не нужна для выбранного отчёта.
547
+
548
+ ## 🤖 Использование в CI
549
+ Пример для GitHub Actions:
550
+
551
+ ```yaml
552
+ # GitHub Actions (фрагмент)
553
+ jobs:
554
+ reports:
555
+ runs-on: ubuntu-latest
556
+ steps:
557
+ - uses: actions/checkout@v4
558
+ - uses: ruby/setup-ruby@v1
559
+ with:
560
+ ruby-version: '3.4'
561
+ - run: gem install pretty-git
562
+ - run: pretty-git authors . --format json --out authors.json
563
+ - uses: actions/upload-artifact@v4
564
+ with:
565
+ name: authors-report
566
+ path: authors.json
567
+ ```
568
+
499
569
  ## 🪟 Советы по Windows
500
570
  Целевая платформа — macOS/Linux. Windows поддерживается в режиме best‑effort:
501
571
  * Запуск через Git Bash/WSL допустим
@@ -505,7 +575,8 @@ _Пример вывода в терминале (тема: basic)._
505
575
  ## 🩺 Диагностика и ошибки
506
576
  Типичные ошибки и решения:
507
577
 
508
- * __Неизвестный отчёт/формат__ — проверьте значение первого аргумента и `--format`.
578
+ * **Неизвестный отчёт/формат** — проверьте первый аргумент и `--format`.
579
+ * **Отладка** — добавьте `--verbose`, чтобы увидеть фактическую команду `git log` и применённые фильтры.
509
580
  * __Неверный формат даты__ — используйте ISO8601 или `YYYY-MM-DD` (например, `2025-01-31` или `2025-01-31T12:00:00Z`).
510
581
  * __Git недоступен__ — убедитесь, что `git` установлен и доступен в `PATH`.
511
582
  * __Пустой результат__ — проверьте фильтры (`--since/--until`, `--branch`, `--path`), возможно, выборка слишком узкая.
@@ -528,5 +599,7 @@ bundle exec rubocop
528
599
 
529
600
  Стиль — RuboCop без ошибок. Тесты покрывают агрегаторы, рендереры, CLI и интеграционные сценарии (детерминизм, корректность форматов).
530
601
 
602
+ Подробнее о стратегии тестирования, правилах детерминизма и процессе работы с golden‑тестами (как запускать/обновлять снапшоты) см. `docs/testing.md`.
603
+
531
604
  ## 📄 Лицензия
532
605
  MIT © Contributors
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'time'
4
+ require 'json'
5
+ require 'find'
4
6
 
5
7
  module PrettyGit
6
8
  module Analytics
@@ -79,30 +81,46 @@ module PrettyGit
79
81
  # Default metric: bytes (similar to GitHub Linguist approach).
80
82
  # rubocop:disable Metrics/ClassLength
81
83
  class Languages
84
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
82
85
  def self.call(_enum, filters)
83
86
  repo = filters.repo_path
84
- items = calculate(repo, include_globs: filters.paths, exclude_globs: filters.exclude_paths)
87
+ prof = ENV['PG_PROF'] == '1'
88
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) if prof
85
89
  metric = (filters.metric || 'bytes').to_s
90
+ items = calculate(repo, include_globs: filters.paths, exclude_globs: filters.exclude_paths, metric: metric)
86
91
  totals = compute_totals(items)
87
92
  items = add_percents(items, totals, metric)
88
93
  items = add_colors(items)
89
94
  items = sort_and_limit(items, filters.limit, metric)
90
95
 
91
- build_result(repo, items, totals, metric)
96
+ res = build_result(repo, items, totals, metric)
97
+ if prof
98
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ elapsed = (t1 - t0)
100
+ files = totals[:files]
101
+ warn format(
102
+ '[pg_prof] languages: time=%<sec>.3fs files=%<files>d metric=%<metric>s',
103
+ { sec: elapsed, files: files, metric: metric }
104
+ )
105
+ summary = { component: 'languages', time_sec: elapsed, files: files, metric: metric }
106
+ warn("[pg_prof_json] #{summary.to_json}")
107
+ end
108
+ res
92
109
  end
110
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
93
111
 
94
112
  # rubocop:disable Metrics/AbcSize
95
- def self.calculate(repo_path, include_globs:, exclude_globs:)
113
+ def self.calculate(repo_path, include_globs:, exclude_globs:, metric: 'bytes')
96
114
  by_lang = Hash.new { |h, k| h[k] = { bytes: 0, files: 0, loc: 0 } }
97
115
  Dir.chdir(repo_path) do
98
- each_source_file(include_globs, exclude_globs) do |abs_path|
99
- basename = File.basename(abs_path)
100
- ext = File.extname(abs_path).downcase
116
+ each_source_file(include_globs, exclude_globs) do |path|
117
+ basename = File.basename(path)
118
+ ext = File.extname(path).downcase
101
119
  lang = FILENAME_TO_LANG[basename] || EXT_TO_LANG[ext]
102
120
  next unless lang
103
121
 
104
- size = safe_file_size(abs_path)
105
- lines = safe_count_lines(abs_path)
122
+ size = safe_file_size(path)
123
+ lines = metric == 'loc' ? safe_count_lines(path) : 0
106
124
  agg = by_lang[lang]
107
125
  agg[:bytes] += size
108
126
  agg[:files] += 1
@@ -113,14 +131,33 @@ module PrettyGit
113
131
  end
114
132
  # rubocop:enable Metrics/AbcSize
115
133
 
116
- def self.each_source_file(include_globs, exclude_globs)
117
- # Build list of files under repo respecting includes/excludes
118
- all = Dir.glob('**/*', File::FNM_DOTMATCH).select { |p| File.file?(p) }
119
- files = all.reject { |p| vendor_path?(p) || binary_ext?(p) }
134
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
135
+ def self.each_source_file(include_globs, exclude_globs, &block)
136
+ # Traverse tree with early prune for vendor/binary paths, then apply include/exclude
137
+ files = []
138
+ Find.find('.') do |path|
139
+ rel = path.sub(%r{^\./}, '')
140
+ # Prune vendor dirs early
141
+ if File.directory?(path)
142
+ dir = File.basename(path)
143
+ if VENDOR_DIRS.include?(dir)
144
+ Find.prune
145
+ next
146
+ end
147
+ next
148
+ end
149
+ next unless File.file?(path)
150
+ next if rel.empty?
151
+ next if vendor_path?(rel) || binary_ext?(rel)
152
+
153
+ files << rel
154
+ end
155
+
120
156
  files = filter_includes(files, include_globs)
121
157
  files = filter_excludes(files, exclude_globs)
122
- files.each { |rel| yield File.expand_path(rel) }
158
+ files.each { |rel| block.call(rel) }
123
159
  end
160
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
124
161
 
125
162
  def self.safe_file_size(path)
126
163
  File.size(path)
@@ -141,7 +178,8 @@ module PrettyGit
141
178
  return files if globs.empty?
142
179
 
143
180
  allowed = globs.flat_map { |g| Dir.glob(g) }
144
- files.select { |f| allowed.include?(f) }
181
+ allowed_map = allowed.each_with_object({}) { |f, h| h[f] = true }
182
+ files.select { |f| allowed_map[f] }
145
183
  end
146
184
 
147
185
  def self.filter_excludes(files, globs)
@@ -149,7 +187,8 @@ module PrettyGit
149
187
  return files if globs.empty?
150
188
 
151
189
  blocked = globs.flat_map { |g| Dir.glob(g) }
152
- files.reject { |f| blocked.include?(f) }
190
+ blocked_map = blocked.each_with_object({}) { |f, h| h[f] = true }
191
+ files.reject { |f| blocked_map[f] }
153
192
  end
154
193
 
155
194
  def self.vendor_path?(path)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require_relative 'git/provider'
4
5
  require_relative 'analytics/summary'
5
6
  require_relative 'analytics/activity'
@@ -37,7 +38,9 @@ module PrettyGit
37
38
  private
38
39
 
39
40
  def ensure_repo!(path)
40
- return if File.directory?(File.join(path, '.git'))
41
+ # Use git to reliably detect work-trees/worktrees/bare repos
42
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--is-inside-work-tree', chdir: path)
43
+ return if status.success? && stdout.to_s.strip == 'true'
41
44
 
42
45
  raise ArgumentError, "Not a git repository: #{path}"
43
46
  end
@@ -47,21 +50,25 @@ module PrettyGit
47
50
  end
48
51
 
49
52
  def renderer_for(filters, io)
50
- case filters.format
51
- when 'console'
52
- use_color = !filters.no_color && filters.theme != 'mono'
53
- Render::ConsoleRenderer.new(io: io, color: use_color, theme: filters.theme)
54
- when 'csv'
55
- Render::CsvRenderer.new(io: io)
56
- when 'md'
57
- Render::MarkdownRenderer.new(io: io)
58
- when 'yaml'
59
- Render::YamlRenderer.new(io: io)
60
- when 'xml'
61
- Render::XmlRenderer.new(io: io)
62
- else
63
- Render::JsonRenderer.new(io: io)
53
+ if filters.format == 'console'
54
+ return Render::ConsoleRenderer.new(
55
+ io: io,
56
+ color: !filters.no_color && filters.theme != 'mono',
57
+ theme: filters.theme
58
+ )
64
59
  end
60
+
61
+ dispatch = {
62
+ 'csv' => Render::CsvRenderer,
63
+ 'md' => Render::MarkdownRenderer,
64
+ 'yaml' => Render::YamlRenderer,
65
+ 'xml' => Render::XmlRenderer,
66
+ 'json' => Render::JsonRenderer
67
+ }
68
+ klass = dispatch[filters.format]
69
+ raise ArgumentError, "Unknown format: #{filters.format}" unless klass
70
+
71
+ klass.new(io: io)
65
72
  end
66
73
 
67
74
  def analytics_for(report, enum, filters)
@@ -9,9 +9,6 @@ require_relative 'cli_helpers'
9
9
  module PrettyGit
10
10
  # Command-line interface entry point.
11
11
  class CLI
12
- SUPPORTED_REPORTS = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
13
- SUPPORTED_FORMATS = %w[console json csv md yaml xml].freeze
14
-
15
12
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
16
13
  def self.run(argv = ARGV, out: $stdout, err: $stderr)
17
14
  options = {
@@ -28,6 +25,7 @@ module PrettyGit
28
25
  out: nil,
29
26
  no_color: false,
30
27
  theme: 'basic',
28
+ _verbose: false,
31
29
  _version: false,
32
30
  _help: false
33
31
  }
@@ -1,29 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require 'fileutils'
4
5
  require_relative 'filters'
5
6
  require_relative 'app'
7
+ require_relative 'constants'
6
8
 
7
9
  module PrettyGit
8
10
  # Helpers extracted from `PrettyGit::CLI` to keep the CLI class small
9
11
  # and RuboCop-compliant. Provides parser configuration and execution utilities.
10
12
  # rubocop:disable Metrics/ModuleLength
11
13
  module CLIHelpers
12
- REPORTS = %w[summary activity authors files heatmap languages hotspots churn ownership].freeze
13
- FORMATS = %w[console json csv md yaml xml].freeze
14
- METRICS = %w[bytes files loc].freeze
14
+ REPORTS = PrettyGit::Constants::REPORTS
15
+ FORMATS = PrettyGit::Constants::FORMATS
16
+ METRICS = PrettyGit::Constants::METRICS
17
+ THEMES = PrettyGit::Constants::THEMES
15
18
 
16
19
  module_function
17
20
 
21
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
18
22
  def configure_parser(opts, options)
19
- opts.banner = 'Usage: pretty-git [REPORT] [REPO] [options]'
23
+ opts.banner = <<~BANNER
24
+ Usage: pretty-git [REPORT] [REPO] [options]
25
+
26
+ Reports: #{REPORTS.join(', ')}
27
+ Formats: #{FORMATS.join(', ')}
28
+ Themes: #{THEMES.join(', ')}
29
+ BANNER
30
+ opts.separator('')
31
+ opts.separator('Repository and branch:')
20
32
  add_repo_options(opts, options)
33
+ opts.separator('')
34
+ opts.separator('Time, authors, and bucketing:')
21
35
  add_time_author_options(opts, options)
36
+ opts.separator('')
37
+ opts.separator('Paths and limits:')
22
38
  add_path_limit_options(opts, options)
39
+ opts.separator('')
40
+ opts.separator('Format and output:')
23
41
  add_format_output_options(opts, options)
42
+ opts.separator('')
43
+ opts.separator('Metrics (languages report only):')
24
44
  add_metric_options(opts, options)
45
+ opts.separator('')
46
+ opts.separator('Other:')
25
47
  add_misc_options(opts, options)
48
+ opts.separator('')
49
+ opts.separator('Examples:')
50
+ opts.separator(' # Summary in JSON to stdout')
51
+ opts.separator(' $ pretty-git summary . --format json')
52
+ opts.separator('')
53
+ opts.separator(' # Authors since a date with a limit')
54
+ opts.separator(' $ pretty-git authors ~/repo --since 2025-01-01 --limit 20')
55
+ opts.separator('')
56
+ opts.separator(' # Files with includes/excludes (globs)')
57
+ opts.separator(' $ pretty-git files . --path app/**/*.rb --exclude-path spec/**')
58
+ opts.separator('')
59
+ opts.separator(' # Activity bucketed by week to Markdown file')
60
+ opts.separator(' $ pretty-git activity . --time-bucket week --format md --out out/report.md')
61
+ opts.separator('')
62
+ opts.separator(' # Languages by LOC to CSV (redirect to file)')
63
+ opts.separator(' $ pretty-git languages . --metric loc --format csv > langs.csv')
64
+ opts.separator('')
65
+ opts.separator(' # Hotspots on main with a higher limit to YAML')
66
+ opts.separator(' $ pretty-git hotspots . --branch main --limit 50 --format yaml')
26
67
  end
68
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
27
69
 
28
70
  def add_repo_options(opts, options)
29
71
  opts.on('--repo PATH', 'Path to git repository (default: .)') { |val| options[:repo] = val }
@@ -47,8 +89,14 @@ module PrettyGit
47
89
  def add_format_output_options(opts, options)
48
90
  opts.on('--format FMT', 'console|json|csv|md|yaml|xml') { |val| options[:format] = val }
49
91
  opts.on('--out FILE', 'Output file path') { |val| options[:out] = val }
50
- opts.on('--no-color', 'Disable colors in console output') { options[:no_color] = true }
51
- opts.on('--theme NAME', 'console color theme: basic|bright|mono') { |val| options[:theme] = val }
92
+ opts.on('--no-color', 'Disable colors in console output') do
93
+ options[:no_color] = true
94
+ options[:_no_color_provided] = true
95
+ end
96
+ opts.on('--theme NAME', 'console color theme: basic|bright|mono') do |val|
97
+ options[:theme] = val
98
+ options[:_theme_provided] = true
99
+ end
52
100
  end
53
101
 
54
102
  def add_metric_options(opts, options)
@@ -60,6 +108,7 @@ module PrettyGit
60
108
  def add_misc_options(opts, options)
61
109
  opts.on('--version', 'Show version') { options[:_version] = true }
62
110
  opts.on('--help', 'Show help') { options[:_help] = true }
111
+ opts.on('--verbose', 'Verbose output (debug)') { options[:_verbose] = true }
63
112
  end
64
113
 
65
114
  def parse_limit(str)
@@ -75,9 +124,14 @@ module PrettyGit
75
124
  code = handle_version_help(options, parser, out)
76
125
  return code unless code.nil?
77
126
 
78
- base_ok = valid_report?(options[:report]) && valid_theme?(options[:theme]) && valid_metric?(options[:metric])
127
+ base_ok = valid_base?(options)
79
128
  conflicts_ok = validate_conflicts(options, err)
80
- return nil if base_ok && conflicts_ok
129
+ if base_ok && conflicts_ok
130
+ early = warn_ignores(options, err)
131
+ return 0 if early == :early_exit
132
+
133
+ return nil
134
+ end
81
135
 
82
136
  print_validation_errors(options, err)
83
137
  1
@@ -96,19 +150,47 @@ module PrettyGit
96
150
  end
97
151
 
98
152
  def valid_report?(report) = REPORTS.include?(report)
99
- def valid_theme?(theme) = %w[basic bright mono].include?(theme)
153
+ def valid_theme?(theme) = THEMES.include?(theme)
154
+ def valid_format?(fmt) = FORMATS.include?(fmt)
155
+
156
+ def valid_base?(options)
157
+ valid_report?(options[:report]) &&
158
+ valid_theme?(options[:theme]) &&
159
+ valid_metric?(options[:metric]) &&
160
+ valid_format?(options[:format])
161
+ end
100
162
 
101
163
  def valid_metric?(metric)
102
164
  metric.nil? || METRICS.include?(metric)
103
165
  end
104
166
 
105
167
  def print_validation_errors(options, err)
106
- supported = REPORTS.join(', ')
107
- unless valid_report?(options[:report])
108
- err.puts "Unknown report: #{options[:report]}."
109
- err.puts "Supported: #{supported}"
110
- end
111
- err.puts "Unknown theme: #{options[:theme]}. Supported: basic, bright, mono" unless valid_theme?(options[:theme])
168
+ print_report_error(options, err)
169
+ print_theme_error(options, err)
170
+ print_format_error(options, err)
171
+ print_metric_error(options, err)
172
+ end
173
+
174
+ def print_report_error(options, err)
175
+ return if valid_report?(options[:report])
176
+
177
+ err.puts "Unknown report: #{options[:report]}."
178
+ err.puts "Supported: #{REPORTS.join(', ')}"
179
+ end
180
+
181
+ def print_theme_error(options, err)
182
+ return if valid_theme?(options[:theme])
183
+
184
+ err.puts "Unknown theme: #{options[:theme]}. Supported: #{THEMES.join(', ')}"
185
+ end
186
+
187
+ def print_format_error(options, err)
188
+ return if valid_format?(options[:format])
189
+
190
+ err.puts "Unknown format: #{options[:format]}. Supported: #{FORMATS.join(', ')}"
191
+ end
192
+
193
+ def print_metric_error(options, err)
112
194
  return if valid_metric?(options[:metric])
113
195
 
114
196
  err.puts "Unknown metric: #{options[:metric]}. Supported: #{METRICS.join(', ')}"
@@ -125,12 +207,45 @@ module PrettyGit
125
207
  ok
126
208
  end
127
209
 
210
+ # Print non-fatal warnings for flags that won't have effect with current options
211
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
212
+ def warn_ignores(options, err)
213
+ return unless err
214
+
215
+ fmt = options[:format]
216
+ return unless fmt && fmt != 'console'
217
+
218
+ err.puts "Warning: --theme has no effect when --format=#{fmt}" if options[:theme]
219
+ err.puts "Warning: --no-color has no effect when --format=#{fmt}" if options[:no_color]
220
+
221
+ # Exit early only if user explicitly provided these console-only flags
222
+ # AND no other significant options that warrant running the app were provided.
223
+ return unless options[:_theme_provided] || options[:_no_color_provided]
224
+
225
+ significant = significant_options?(options)
226
+ :early_exit unless significant
227
+ end
228
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
229
+
230
+ def significant_options?(options)
231
+ return true if options[:out]
232
+ return true if options[:metric]
233
+ return true if options[:time_bucket]
234
+ return true if options[:limit] && options[:limit] != 10
235
+
236
+ any_collection_present?(options, %i[branches authors exclude_authors paths exclude_paths])
237
+ end
238
+
239
+ def any_collection_present?(options, keys)
240
+ keys.any? { |k| options[k]&.any? }
241
+ end
242
+
128
243
  def build_filters(options)
129
244
  Filters.new(
130
245
  repo_path: options[:repo],
131
246
  branches: options[:branches],
132
247
  since: options[:since],
133
- until: options[:until],
248
+ until_at: options[:until],
134
249
  authors: options[:authors],
135
250
  exclude_authors: options[:exclude_authors],
136
251
  paths: options[:paths],
@@ -141,14 +256,25 @@ module PrettyGit
141
256
  format: options[:format],
142
257
  out: options[:out],
143
258
  no_color: options[:no_color],
144
- theme: options[:theme]
259
+ theme: options[:theme],
260
+ verbose: options[:_verbose]
145
261
  )
146
262
  end
147
263
 
148
264
  def execute(report, filters, options, out, err)
149
265
  if options[:out]
150
- File.open(options[:out], 'w') do |f|
151
- return PrettyGit::App.new.run(report, filters, out: f, err: err)
266
+ begin
267
+ dir = File.dirname(options[:out])
268
+ FileUtils.mkdir_p(dir) unless dir.nil? || dir == '.'
269
+ File.open(options[:out], 'w') do |f|
270
+ return PrettyGit::App.new.run(report, filters, out: f, err: err)
271
+ end
272
+ rescue Errno::EACCES
273
+ err.puts "Cannot write to: #{options[:out]} (permission denied)"
274
+ return 2
275
+ rescue Errno::ENOENT
276
+ err.puts "Cannot write to: #{options[:out]} (directory not found)"
277
+ return 2
152
278
  end
153
279
  end
154
280
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrettyGit
4
+ module Constants
5
+ REPORTS = %w[
6
+ summary activity authors files heatmap languages hotspots churn ownership
7
+ ].freeze
8
+
9
+ FORMATS = %w[console json csv md yaml xml].freeze
10
+
11
+ METRICS = %w[bytes files loc].freeze
12
+
13
+ THEMES = %w[basic bright mono].freeze
14
+ end
15
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'time'
4
+ require_relative 'utils/time_utils'
4
5
 
5
6
  module PrettyGit
6
7
  Filters = Struct.new(
7
8
  :repo_path,
8
9
  :branches,
9
10
  :since,
10
- :until,
11
+ :until_at,
11
12
  :authors,
12
13
  :exclude_authors,
13
14
  :paths,
@@ -19,35 +20,53 @@ module PrettyGit
19
20
  :out,
20
21
  :no_color,
21
22
  :theme,
23
+ :verbose,
22
24
  keyword_init: true
23
25
  ) do
24
- def since_iso8601
25
- time_to_iso8601(since)
26
+ # Backward-compat: allow initializing with `until:` keyword by remapping to :until_at
27
+ # Preserve Struct keyword_init behavior by overriding initialize instead of .new
28
+ def initialize(*args, **kwargs)
29
+ # Accept a single Hash positional argument for backward compatibility
30
+ kwargs = args.first if (kwargs.nil? || kwargs.empty?) && args.length == 1 && args.first.is_a?(Hash)
31
+ kwargs ||= {}
32
+
33
+ if kwargs.key?(:until)
34
+ Kernel.warn('[pretty-git] DEPRECATION: Filters initialized with :until. Use :until_at instead.')
35
+ kwargs = kwargs.dup
36
+ kwargs[:until_at] = kwargs.delete(:until)
37
+ end
38
+
39
+ # Keyword-init struct: prefer keyword form consistently to keep initialize simple
40
+ super(**kwargs)
26
41
  end
27
42
 
28
- def until_iso8601
29
- time_to_iso8601(self[:until])
43
+ # Backward-compat: support filters.until and filters.until=
44
+ def until
45
+ self[:until_at]
46
+ end
47
+
48
+ def until=(val)
49
+ self[:until_at] = val
30
50
  end
31
51
 
32
- private
52
+ # Backward-compat for hash-style access used in older specs
53
+ def [](key)
54
+ key = :until_at if key == :until
55
+ super
56
+ end
33
57
 
34
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
- def time_to_iso8601(val)
36
- return nil if val.nil? || val.to_s.strip.empty?
58
+ def []=(key, value)
59
+ key = :until_at if key == :until
60
+ super
61
+ end
37
62
 
38
- # If value is a date without time, interpret as UTC midnight to avoid
39
- # timezone-dependent shifts across environments.
40
- if val.is_a?(String) && val.match?(/^\d{4}-\d{2}-\d{2}$/)
41
- y, m, d = val.split('-').map(&:to_i)
42
- t = Time.new(y, m, d, 0, 0, 0, '+00:00')
43
- else
44
- # Otherwise parse normally and normalize to UTC.
45
- t = val.is_a?(Time) ? val : Time.parse(val.to_s)
46
- end
47
- t.utc.iso8601
48
- rescue ArgumentError
49
- raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
63
+ def since_iso8601
64
+ PrettyGit::Utils::TimeUtils.to_utc_iso8601(since)
65
+ end
66
+
67
+ # Keep method name for backwards compatibility across the codebase
68
+ def until_iso8601
69
+ PrettyGit::Utils::TimeUtils.to_utc_iso8601(self[:until_at])
50
70
  end
51
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
52
71
  end
53
72
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'time'
5
+ require 'json'
5
6
  require_relative '../types'
7
+ require_relative '../logger'
8
+ require_relative '../utils/path_utils'
6
9
 
7
10
  module PrettyGit
8
11
  module Git
@@ -17,10 +20,18 @@ module PrettyGit
17
20
  end
18
21
 
19
22
  # Returns Enumerator of PrettyGit::Types::Commit
20
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
23
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
21
24
  def each_commit
22
25
  Enumerator.new do |yld|
26
+ prof = ENV['PG_PROF'] == '1'
27
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ headers = 0
29
+ numstat_lines = 0
23
30
  cmd = build_git_command
31
+ PrettyGit::Logger.verbose(
32
+ "[pretty-git] git cmd: #{cmd.join(' ')} (cwd=#{@filters.repo_path})",
33
+ @filters.verbose
34
+ )
24
35
  Open3.popen3(*cmd, chdir: @filters.repo_path) do |_stdin, stdout, stderr, wait_thr|
25
36
  current = nil
26
37
  stdout.each_line do |line|
@@ -28,6 +39,7 @@ module PrettyGit
28
39
  # Try to start a new commit from header on any line
29
40
  header = start_commit_from_header(line)
30
41
  if header
42
+ headers += 1 if prof
31
43
  # emit previous commit if any
32
44
  emit_current(yld, current)
33
45
  current = header
@@ -36,6 +48,7 @@ module PrettyGit
36
48
 
37
49
  next if line.empty?
38
50
 
51
+ numstat_lines += 1 if prof
39
52
  append_numstat_line(current, line)
40
53
  end
41
54
 
@@ -47,9 +60,26 @@ module PrettyGit
47
60
  raise StandardError, (err && !err.empty? ? err : "git log failed with status #{status.exitstatus}")
48
61
  end
49
62
  end
63
+
64
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ if prof
66
+ # Emit a compact profile to stderr
67
+ elapsed = (t1 - t0)
68
+ warn format(
69
+ '[pg_prof] git_provider: time=%<sec>.3fs headers=%<headers>d numstat_lines=%<num>d',
70
+ { sec: elapsed, headers: headers, num: numstat_lines }
71
+ )
72
+ summary = {
73
+ component: 'git_provider',
74
+ time_sec: elapsed,
75
+ headers: headers,
76
+ numstat_lines: numstat_lines
77
+ }
78
+ warn("[pg_prof_json] #{summary.to_json}")
79
+ end
50
80
  end
51
81
  end
52
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
82
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
53
83
 
54
84
  private
55
85
 
@@ -73,10 +103,6 @@ module PrettyGit
73
103
  yld << commit
74
104
  end
75
105
 
76
- def record_separator?(line)
77
- line == SEP_RECORD
78
- end
79
-
80
106
  def start_commit_from_header(line)
81
107
  sha, author_name, author_email, authored_at, subject = line.split(SEP_FIELD, 5)
82
108
  return nil unless subject
@@ -121,13 +147,13 @@ module PrettyGit
121
147
 
122
148
  def add_author_and_branch_filters(args)
123
149
  @filters.authors&.each { |a| args << "--author=#{a}" }
124
- @filters.branches&.each { |b| args << "--branches=#{b}" }
150
+ # Treat branches as explicit revisions to include
151
+ @filters.branches&.each { |b| args << b }
125
152
  end
126
153
 
127
- # rubocop:disable Metrics/AbcSize
128
154
  def add_path_filters(args)
129
- path_args = Array(@filters.paths).compact
130
- exclude_args = Array(@filters.exclude_paths).compact
155
+ path_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.paths)
156
+ exclude_args = PrettyGit::Utils::PathUtils.normalize_globs(@filters.exclude_paths)
131
157
 
132
158
  # Nothing to filter by
133
159
  return if path_args.empty? && exclude_args.empty?
@@ -137,15 +163,14 @@ module PrettyGit
137
163
  # If only excludes provided, include all paths first
138
164
  args << '.' if path_args.empty? && !exclude_args.empty?
139
165
 
140
- # Include patterns as-is
166
+ # Include patterns (normalized)
141
167
  args.concat(path_args) unless path_args.empty?
142
168
 
143
- # Exclude patterns via git pathspec magic with glob
169
+ # Exclude patterns via git pathspec magic with glob (normalized)
144
170
  exclude_args.each do |pat|
145
171
  args << ":(exclude,glob)#{pat}"
146
172
  end
147
173
  end
148
- # rubocop:enable Metrics/AbcSize
149
174
 
150
175
  # rubocop:disable Metrics/CyclomaticComplexity
151
176
  def exclude_author?(name, email)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrettyGit
4
+ # Minimal centralized logger for PrettyGit.
5
+ # Writes to stderr by default; can accept custom IO via keyword.
6
+ module Logger
7
+ module_function
8
+
9
+ def warn(msg, err: $stderr)
10
+ err.puts(msg)
11
+ end
12
+
13
+ # Convenience: only emit when enabled is truthy
14
+ def verbose(msg, enabled, err: $stderr)
15
+ return unless enabled
16
+
17
+ warn(msg, err: err)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrettyGit
4
+ module Utils
5
+ # Utilities for path/glob normalization and handling cross-platform quirks.
6
+ module PathUtils
7
+ module_function
8
+
9
+ # Normalize a string path or glob to Unicode NFC form.
10
+ # Returns nil if input is nil.
11
+ def normalize_nfc(str)
12
+ return nil if str.nil?
13
+
14
+ s = str.to_s
15
+ # Only normalize if supported in this Ruby build; otherwise return as-is
16
+ if s.respond_to?(:unicode_normalize)
17
+ s.unicode_normalize(:nfc)
18
+ else
19
+ s
20
+ end
21
+ end
22
+
23
+ # Normalize each entry in an Array-like collection to NFC and compact
24
+ # nils. Returns an Array.
25
+ def normalize_globs(collection)
26
+ Array(collection).compact.map { |p| normalize_nfc(p) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PrettyGit
6
+ module Utils
7
+ # Utilities for time parsing and ISO8601 normalization used by filters.
8
+ module TimeUtils
9
+ module_function
10
+
11
+ # Converts various time inputs to ISO8601 in UTC.
12
+ # Accepts Time, String(ISO8601), or String(YYYY-MM-DD) treated as UTC midnight.
13
+ # Returns nil for nil/blank input. Raises ArgumentError for invalid values.
14
+ def to_utc_iso8601(val)
15
+ return nil if val.nil? || val.to_s.strip.empty?
16
+
17
+ parse_to_time(val).utc.iso8601
18
+ rescue ArgumentError
19
+ raise ArgumentError, "Invalid datetime: #{val} (expected ISO8601 or YYYY-MM-DD)"
20
+ end
21
+
22
+ def parse_to_time(val)
23
+ return val if val.is_a?(Time)
24
+ return parse_date_only(val) if val.is_a?(String) && date_only?(val)
25
+
26
+ Time.parse(val.to_s)
27
+ end
28
+
29
+ def parse_date_only(str)
30
+ y, m, d = str.split('-').map(&:to_i)
31
+ Time.new(y, m, d, 0, 0, 0, '+00:00')
32
+ end
33
+
34
+ def date_only?(str)
35
+ !!(str =~ /^\d{4}-\d{2}-\d{2}$/)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrettyGit
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.5'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pretty-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pretty Git Authors
@@ -74,8 +74,10 @@ files:
74
74
  - lib/pretty_git/app.rb
75
75
  - lib/pretty_git/cli.rb
76
76
  - lib/pretty_git/cli_helpers.rb
77
+ - lib/pretty_git/constants.rb
77
78
  - lib/pretty_git/filters.rb
78
79
  - lib/pretty_git/git/provider.rb
80
+ - lib/pretty_git/logger.rb
79
81
  - lib/pretty_git/render/console_renderer.rb
80
82
  - lib/pretty_git/render/csv_renderer.rb
81
83
  - lib/pretty_git/render/json_renderer.rb
@@ -85,6 +87,8 @@ files:
85
87
  - lib/pretty_git/render/xml_renderer.rb
86
88
  - lib/pretty_git/render/yaml_renderer.rb
87
89
  - lib/pretty_git/types.rb
90
+ - lib/pretty_git/utils/path_utils.rb
91
+ - lib/pretty_git/utils/time_utils.rb
88
92
  - lib/pretty_git/version.rb
89
93
  homepage: https://github.com/MikoMikocchi/pretty-git
90
94
  licenses: