openspec-stat 1.4.4 → 1.4.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.
package/README.md CHANGED
@@ -10,279 +10,127 @@ English | [简体中文](./README.zh-CN.md)
10
10
 
11
11
  A CLI tool for tracking team members' OpenSpec proposals and code changes in Git repositories.
12
12
 
13
- ## Features
14
-
15
- - ✅ Track Git commits within specified time ranges
16
- - ✅ Identify commits containing both OpenSpec proposals and code changes
17
- - ✅ **Proposal-based statistics summary** - Aggregate statistics by proposal to avoid merge commit bias
18
- - ✅ Group statistics by author (commits, proposals, code changes)
19
- - ✅ Support multiple branches and wildcard filtering
20
- - ✅ Author name mapping (handle multiple Git accounts for the same person)
21
- - ✅ Track only recently active members (default: 2 weeks)
22
- - ✅ Multiple output formats: Table, JSON, CSV, Markdown
23
- - ✅ Internationalization support: English and Chinese (简体中文)
24
- - ✅ **🆕 Multi-repository mode (BETA)** - Analyze multiple local/remote repositories in one run
25
-
26
- ## Installation
27
-
28
- ### Global Installation
13
+ ## Install
29
14
 
30
15
  ```bash
16
+ # global install
31
17
  npm install -g openspec-stat
32
- # or
33
- pnpm add -g openspec-stat
34
- ```
35
-
36
- ### Local Project Installation
37
-
38
- ```bash
39
- npm install openspec-stat --save-dev
40
- # or
41
- pnpm add -D openspec-stat
18
+ # or local (dev dependency)
19
+ npm install -D openspec-stat
42
20
  ```
43
21
 
44
- ## Usage
45
-
46
- ### Basic Usage
22
+ ## Quick start
47
23
 
48
- Run in a Git repository directory:
24
+ Default window: yesterday 20:00 today 20:00.
49
25
 
50
26
  ```bash
27
+ # basic run
51
28
  openspec-stat
52
- ```
53
-
54
- This will track commits in the default time range (yesterday 20:00 ~ today 20:00).
55
-
56
- ### Multi-Repository Mode (BETA)
57
-
58
- ⚠️ **Experimental Feature**: Multi-repository mode allows analyzing multiple repositories (local or remote) in a single run.
59
29
 
60
- ```bash
61
- # Initialize multi-repo configuration (interactive wizard)
62
- openspec-stat init --multi
30
+ # custom time range
31
+ openspec-stat --since "2024-01-01 00:00:00" --until "2024-01-31 23:59:59"
63
32
 
64
- # Run multi-repo analysis
33
+ # multi-repo (uses config)
65
34
  openspec-stat multi -c .openspec-stats.multi.json
66
-
67
- # Run with detailed contributor statistics
68
- openspec-stat multi -c .openspec-stats.multi.json --show-contributors
69
-
70
- # Generate template
71
- openspec-stat init --template multi -o config.json
72
35
  ```
73
36
 
74
- **Perfect for team managers who:**
75
- - Have local access to backend repos but not frontend repos
76
- - Need to track contributions across multiple repositories
77
- - Want combined statistics without running multiple commands
37
+ ## Key features
78
38
 
79
- **Note**: By default, multi-repo mode only shows aggregated statistics to avoid information overload. Use `--show-contributors` to see detailed statistics for each contributor.
39
+ - Track Git commits in a time window and per-branch filters
40
+ - Detect commits containing both OpenSpec proposals and code changes
41
+ - Proposal-based aggregation to avoid merge-commit bias
42
+ - Author grouping with name mapping (multiple Git identities per person)
43
+ - Multi-branch wildcards and **multi-repository mode (BETA)**
44
+ - Outputs: table, JSON, CSV, Markdown; languages: en / zh-CN
80
45
 
81
- See [Multi-Repository Guide](./MULTI_REPO_GUIDE.md) for detailed documentation.
46
+ ## Common flags (full list: `openspec-stat --help`)
82
47
 
83
- ### Command Line Options
48
+ - `-r, --repo <path>`: repository path (default: current directory)
49
+ - `-b, --branches <list>`: comma-separated branches, supports wildcards
50
+ - `-s, --since <datetime>` / `-u, --until <datetime>`: time window
51
+ - `-a, --author <name>`: filter by author
52
+ - `-c, --config <path>`: config file
53
+ - `--json | --csv | --markdown`: output format
54
+ - `-l, --lang <language>`: `en` or `zh-CN`
55
+ - `-v, --verbose`: verbose output
84
56
 
85
- ```bash
86
- openspec-stat [options]
87
-
88
- Options:
89
- -r, --repo <path> Repository path (default: current directory)
90
- -b, --branches <branches> Branch list, comma-separated (e.g., origin/master,origin/release/v1.0)
91
- -s, --since <datetime> Start time (default: yesterday 20:00)
92
- -u, --until <datetime> End time (default: today 20:00)
93
- -a, --author <name> Filter by specific author
94
- --json Output in JSON format
95
- --csv Output in CSV format
96
- --markdown Output in Markdown format
97
- -c, --config <path> Configuration file path
98
- -v, --verbose Verbose output mode
99
- -l, --lang <language> Language for output (en, zh-CN) (default: "en")
100
- -V, --version Display version number
101
- -h, --help Display help information
102
- ```
57
+ ## Multi-repo mode (BETA)
103
58
 
104
- ### Examples
59
+ Analyze multiple local/remote repositories in one run.
105
60
 
106
61
  ```bash
107
- # Track default time range
108
- openspec-stat
109
-
110
- # Track specific time range
111
- openspec-stat --since "2024-01-01 00:00:00" --until "2024-01-31 23:59:59"
112
-
113
- # Track specific branches
114
- openspec-stat --branches "origin/master,origin/release/v1.0"
115
-
116
- # Track specific author
117
- openspec-stat --author "John Doe"
118
-
119
- # Output JSON format
120
- openspec-stat --json > stats.json
121
-
122
- # Output Markdown report
123
- openspec-stat --markdown > report.md
124
-
125
- # Verbose output mode
126
- openspec-stat --verbose
127
-
128
- # Use custom configuration file
129
- openspec-stat --config ./my-config.json
62
+ openspec-stat init --multi # interactive setup
63
+ openspec-stat multi -c .openspec-stats.multi.json # aggregated view
64
+ openspec-stat multi -c .openspec-stats.multi.json --show-contributors
65
+ ```
130
66
 
131
- # Use Chinese output
132
- openspec-stat --lang zh-CN
67
+ See [Multi-Repository Guide](./MULTI_REPO_GUIDE.md) for full details.
133
68
 
134
- # Combine with other options
135
- openspec-stat --lang zh-CN --verbose
136
- ```
69
+ **Remote cache**: remote repos are cloned once and reused under
70
+ `~/.openspec-stat/cached/repos/<repo-name>-<hash>`. Use `--cache-mode temporary`
71
+ to force one-off clones, or `--force-clone` to refresh a single run.
137
72
 
138
- ## Configuration File
73
+ ## Configuration (short)
139
74
 
140
- Create `.openspec-stats.json` or `openspec-stats.config.json` in the project root:
75
+ Create `.openspec-stats.json` or `openspec-stats.config.json` in the repo root.
141
76
 
142
77
  ```json
143
78
  {
144
- "defaultBranches": [
145
- "origin/master",
146
- "origin/main",
147
- "origin/release/*"
148
- ],
79
+ "defaultBranches": ["origin/master", "origin/main", "origin/release/*"],
149
80
  "defaultSinceHours": -30,
150
81
  "defaultUntilHours": 18,
151
- "authorMapping": {
152
- "John Doe": "John Doe",
153
- "john.doe@company.com": "John Doe",
154
- "johnd": "John Doe"
155
- },
82
+ "authorMapping": {"john.doe@company.com": "John Doe"},
156
83
  "openspecDir": "openspec/",
157
- "excludeExtensions": [
158
- ".md",
159
- ".txt",
160
- ".png",
161
- ".jpg",
162
- ".jpeg",
163
- ".gif",
164
- ".svg",
165
- ".ico",
166
- ".webp"
167
- ],
84
+ "excludeExtensions": [".md", ".txt", ".png", ".jpg", "..."],
168
85
  "activeUserWeeks": 2
169
86
  }
170
87
  ```
171
88
 
172
- ### Configuration Options
173
-
174
- - **defaultBranches**: Default branches to track (supports wildcards)
175
- - **defaultSinceHours**: Default start time offset (hours, negative means going back)
176
- - **defaultUntilHours**: Default end time (hour of the day)
177
- - **authorMapping**: Author name mapping to unify multiple Git accounts for the same person
178
- - **openspecDir**: OpenSpec proposals directory (default: `openspec/`)
179
- - **excludeExtensions**: File extensions to exclude (not counted as code changes)
180
- - **activeUserWeeks**: Active user time window (weeks, default: 2)
181
-
182
- ## Statistics Logic
183
-
184
- The tool identifies commits that meet both conditions:
185
-
186
- 1. Contains file changes in the `openspec/` directory (OpenSpec proposals)
187
- 2. Contains code file changes (excluding documentation files)
188
-
189
- Statistics include:
190
-
191
- - **Commits**: Total number of qualifying commits
192
- - **OpenSpec Proposals**: Counted by `openspec/changes/{proposal-name}` directories
193
- - **Code Files**: Number of modified code files
194
- - **Additions**: Lines of code added
195
- - **Deletions**: Lines of code deleted
196
- - **Net Changes**: Additions - Deletions
197
-
198
- The tool provides two perspectives:
89
+ Key fields: default branches/time window, author mapping (merge identities), OpenSpec directory, excluded extensions, active user window.
199
90
 
200
- 1. **Proposal Summary**: Aggregates statistics by proposal, showing total code changes per proposal and all contributors. This avoids statistical bias from merge commits.
201
- 2. **Author Summary**: Groups statistics by contributor, showing individual author contributions.
202
-
203
- ## Output Formats
204
-
205
- ### Table Format (Default)
91
+ ## Output
206
92
 
207
93
  ```
208
- 📊 OpenSpec Statistics Report
209
- Time Range: 2024-01-01 00:00:00 ~ 2024-01-31 23:59:59
94
+ 📊 OpenSpec Report
95
+ Time: 2024-01-01 00:00:00 ~ 2024-01-31 23:59:59
210
96
  Branches: origin/master
211
- Total Commits: 15
212
-
213
- 📋 Proposal Summary (by proposal)
214
- ┌──────────────┬─────────┬──────────────────┬────────────┬───────────┬───────────┬─────────────┐
215
- │ Proposal │ Commits │ ContributorsCode Files │ Additions │ Deletions │ Net Changes
216
- ├──────────────┼─────────┼──────────────────┼────────────┼───────────┼───────────┼─────────────┤
217
- │ feature-123 │ 5 │ John Doe, Jane S.│ 30 │ +890 -234 │ +656 │
218
- │ feature-456 │ 3 │ John Doe │ 15 │ +344 │ -100 │ +244 │
219
- └──────────────┴─────────┴──────────────────┴────────────┴───────────┴───────────┴─────────────┘
220
- 📊 Total: 2 proposals | 8 commits | 45 files | +1234/-334 lines (net: +900)
221
-
222
- 👥 Author Summary (by contributor)
223
- ┌──────────┬─────────┬──────────────────┬────────────┬───────────┬───────────┬─────────────┐
224
- Author │ CommitsOpenSpec ProposalsCode Files Additions Deletions │ Net Changes │
225
- ├──────────┼─────────┼──────────────────┼────────────┼───────────┼───────────┼─────────────┤
226
- │ John Doe │ 8 │ 3 │ 45 │ +1234 │ -567 │ +667 │
227
- │ Jane S. │ 7 │ 2 │ 32 │ +890 │ -234 │ +656 │
228
- └──────────┴─────────┴──────────────────┴────────────┴───────────┴───────────┴─────────────┘
229
- ```
230
-
231
- ### JSON Format
232
-
233
- ```bash
234
- openspec-stat --json
235
- ```
236
-
237
- ### CSV Format
238
-
239
- ```bash
240
- openspec-stat --csv > stats.csv
241
- ```
242
-
243
- ### Markdown Format
244
-
245
- ```bash
246
- openspec-stat --markdown > report.md
97
+ Total Commits: 8
98
+
99
+ Proposal Summary
100
+ ┌──────────────┬─────────┬───────────┬───────────┐
101
+ │ Proposal │ Commits │ Files │ Net Δ
102
+ ├──────────────┼─────────┼───────────┼───────────┤
103
+ │ feature-123 │ 5 │ 30 │ +656
104
+ └──────────────┴─────────┴───────────┴───────────┘
105
+
106
+ Author Summary
107
+ ┌─────────┬─────────┬───────────┬───────────┐
108
+ Author Commits Proposals │ Net Δ │
109
+ ├─────────┼─────────┼───────────┼───────────┤
110
+ John D.8 3 +667
111
+ └─────────┴─────────┴───────────┴───────────┘
247
112
  ```
248
113
 
249
- ## Language Support
114
+ Use `--markdown`, `--json`, or `--csv` for other formats.
250
115
 
251
- The tool supports both English and Chinese output. You can specify the language using the `--lang` option:
116
+ ## Language
252
117
 
253
- ```bash
254
- # English output (default)
255
- openspec-stat --lang en
256
-
257
- # Chinese output
258
- openspec-stat --lang zh-CN
259
- ```
260
-
261
- The tool will also automatically detect your system language. If your system locale is set to Chinese, the output will default to Chinese.
262
-
263
- For a Chinese version of this README, see [README.zh-CN.md](./README.zh-CN.md).
118
+ `--lang en` (default) or `--lang zh-CN`. Locale-based auto-detection is also supported.
264
119
 
265
120
  ## Development
266
121
 
267
122
  ```bash
268
- # Install dependencies
269
123
  pnpm install
270
-
271
- # Development mode
272
124
  pnpm dev
273
-
274
- # Build
275
125
  pnpm build
276
-
277
- # Local testing
278
126
  node dist/cjs/cli.js
279
127
  ```
280
128
 
281
129
  ## Contributing & Release Process
282
130
 
283
- - See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup, branching, and pull-request expectations.
284
- - See [RELEASE.md](./RELEASE.md) for the Changesets-driven versioning and publishing workflow.
131
+ - See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and PR expectations.
132
+ - See [RELEASE.md](./RELEASE.md) for the Changesets-driven publishing workflow.
285
133
 
286
- ## LICENSE
134
+ ## License
287
135
 
288
136
  MIT
package/README.zh-CN.md CHANGED
@@ -4,280 +4,130 @@
4
4
  [![NPM downloads](http://img.shields.io/npm/dm/openspec-stat.svg?style=flat)](https://npmjs.com/package/openspec-stat)
5
5
  [![CI](https://github.com/Orchardxyz/openspec-stat/actions/workflows/ci.yml/badge.svg)](https://github.com/Orchardxyz/openspec-stat/actions/workflows/ci.yml)
6
6
 
7
- 用于追踪团队成员在 Git 仓库中的 OpenSpec 提案和代码变更的命令行工具。
7
+ 用于追踪团队成员在 Git 仓库中的 OpenSpec 提案与代码变更的命令行工具。
8
8
 
9
9
  [English](./README.md) | 简体中文
10
10
 
11
- ## 功能特性
12
-
13
- - ✅ 追踪指定时间范围内的 Git 提交
14
- - ✅ 识别同时包含 OpenSpec 提案和代码变更的提交
15
- - ✅ **提案维度统计汇总** - 按提案聚合统计,避免 merge commit 导致的统计偏差
16
- - ✅ 按作者分组统计(提交数、提案数、代码变更)
17
- - ✅ 支持多分支和通配符过滤
18
- - ✅ 作者名称映射(处理同一人的多个 Git 账号)
19
- - ✅ 仅追踪最近活跃的成员(默认:2 周)
20
- - ✅ 多种输出格式:表格、JSON、CSV、Markdown
21
- - ✅ 国际化支持:英文和中文
22
- - ✅ **🆕 多仓库模式(BETA)** - 一次运行分析多个本地/远程仓库
23
-
24
11
  ## 安装
25
12
 
26
- ### 全局安装
27
-
28
13
  ```bash
14
+ # 全局安装
29
15
  npm install -g openspec-stat
30
- #
31
- pnpm add -g openspec-stat
32
- ```
33
-
34
- ### 本地项目安装
35
-
36
- ```bash
37
- npm install openspec-stat --save-dev
38
- # 或
39
- pnpm add -D openspec-stat
16
+ # 或项目内开发依赖
17
+ npm install -D openspec-stat
40
18
  ```
41
19
 
42
- ## 使用方法
43
-
44
- ### 基本用法
20
+ ## 快速开始
45
21
 
46
- Git 仓库目录中运行:
22
+ 默认时间窗口:昨天 20:00 → 今天 20:00。
47
23
 
48
24
  ```bash
25
+ # 基础运行
49
26
  openspec-stat
50
- ```
51
-
52
- 这将追踪默认时间范围内的提交(昨天 20:00 ~ 今天 20:00)。
53
-
54
- ### 多仓库模式(BETA)
55
-
56
- ⚠️ **实验性功能**:多仓库模式允许在单次运行中分析多个仓库(本地或远程)。
57
27
 
58
- ```bash
59
- # 初始化多仓库配置(交互式向导)
60
- openspec-stat init --multi
28
+ # 自定义时间范围
29
+ openspec-stat --since "2024-01-01 00:00:00" --until "2024-01-31 23:59:59"
61
30
 
62
- # 运行多仓库分析
31
+ # 多仓库(需配置文件)
63
32
  openspec-stat multi -c .openspec-stats.multi.json
64
-
65
- # 运行并显示详细的贡献者统计
66
- openspec-stat multi -c .openspec-stats.multi.json --show-contributors
67
-
68
- # 生成配置模板
69
- openspec-stat init --template multi -o config.json
70
33
  ```
71
34
 
72
- **适用于以下场景的团队管理者:**
73
- - 拥有后端仓库的本地访问权限,但没有前端仓库
74
- - 需要跨多个仓库追踪贡献情况
75
- - 希望获得合并统计结果,而无需运行多个命令
35
+ ## 关键特性
76
36
 
77
- **注意**:默认情况下,多仓库模式仅显示聚合统计信息,以避免信息过载。使用 `--show-contributors` 可查看每个贡献者的详细统计信息。
37
+ - 按时间窗口与分支过滤追踪 Git 提交
38
+ - 识别同时包含 OpenSpec 提案与代码变更的提交
39
+ - 提案维度聚合,避免 merge commit 统计偏差
40
+ - 作者分组与名称映射(合并多个 Git 身份)
41
+ - 多分支通配与 **多仓库模式(BETA)**
42
+ - 输出:表格、JSON、CSV、Markdown;语言:en / zh-CN
78
43
 
79
- 详细文档请参阅 [多仓库模式指南](./MULTI_REPO_GUIDE.md)。
44
+ ## 常用参数(完整见 `openspec-stat --help`)
80
45
 
81
- ### 命令行选项
46
+ - `-r, --repo <path>`:仓库路径(默认当前目录)
47
+ - `-b, --branches <list>`:逗号分隔分支,支持通配
48
+ - `-s, --since` / `-u, --until`:时间范围
49
+ - `-a, --author <name>`:按作者过滤
50
+ - `-c, --config <path>`:配置文件
51
+ - `--json | --csv | --markdown`:输出格式
52
+ - `-l, --lang <language>`:`en` 或 `zh-CN`
53
+ - `-v, --verbose`:详细模式
82
54
 
83
- ```bash
84
- openspec-stat [选项]
85
-
86
- 选项:
87
- -r, --repo <path> 仓库路径(默认:当前目录)
88
- -b, --branches <branches> 分支列表,逗号分隔(例如:origin/master,origin/release/v1.0)
89
- -s, --since <datetime> 开始时间(默认:昨天 20:00)
90
- -u, --until <datetime> 结束时间(默认:今天 20:00)
91
- -a, --author <name> 按特定作者筛选
92
- --json 以 JSON 格式输出
93
- --csv 以 CSV 格式输出
94
- --markdown 以 Markdown 格式输出
95
- -c, --config <path> 配置文件路径
96
- -v, --verbose 详细输出模式
97
- -l, --lang <language> 输出语言(en, zh-CN)(默认:"en")
98
- -V, --version 显示版本号
99
- -h, --help 显示帮助信息
100
- ```
55
+ ## 多仓库模式(BETA)
101
56
 
102
- ### 使用示例
57
+ 一次运行分析多个本地/远程仓库。
103
58
 
104
59
  ```bash
105
- # 追踪默认时间范围
106
- openspec-stat
107
-
108
- # 追踪特定时间范围
109
- openspec-stat --since "2024-01-01 00:00:00" --until "2024-01-31 23:59:59"
110
-
111
- # 追踪特定分支
112
- openspec-stat --branches "origin/master,origin/release/v1.0"
113
-
114
- # 追踪特定作者
115
- openspec-stat --author "张三"
116
-
117
- # 输出 JSON 格式
118
- openspec-stat --json > stats.json
119
-
120
- # 输出 Markdown 报告
121
- openspec-stat --markdown > report.md
122
-
123
- # 详细输出模式
124
- openspec-stat --verbose
60
+ openspec-stat init --multi # 交互式初始化
61
+ openspec-stat multi -c .openspec-stats.multi.json # 聚合视图
62
+ openspec-stat multi -c .openspec-stats.multi.json --show-contributors
63
+ ```
125
64
 
126
- # 使用自定义配置文件
127
- openspec-stat --config ./my-config.json
65
+ 详见 [多仓库模式指南](./MULTI_REPO_GUIDE.md)。
128
66
 
129
- # 使用中文输出
130
- openspec-stat --lang zh-CN
67
+ **远程缓存**:远程仓库会首次克隆后复用,路径为
68
+ `~/.openspec-stat/cached/repos/<仓库名>-<哈希>`。使用 `--cache-mode temporary`
69
+ 可改为一次性克隆,或用 `--force-clone` 在单次运行中强制重新克隆。
131
70
 
132
- # 组合多个选项
133
- openspec-stat --lang zh-CN --verbose
134
- ```
71
+ ## 配置(简版)
135
72
 
136
- ## 配置文件
137
-
138
- 在项目根目录创建 `.openspec-stats.json` 或 `openspec-stats.config.json`:
73
+ 在仓库根目录创建 `.openspec-stats.json` 或 `openspec-stats.config.json`。
139
74
 
140
75
  ```json
141
76
  {
142
- "defaultBranches": [
143
- "origin/master",
144
- "origin/main",
145
- "origin/release/*"
146
- ],
77
+ "defaultBranches": ["origin/master", "origin/main", "origin/release/*"],
147
78
  "defaultSinceHours": -30,
148
79
  "defaultUntilHours": 18,
149
- "authorMapping": {
150
- "张三": "张三",
151
- "zhangsan@company.com": "张三",
152
- "zs": "张三"
153
- },
80
+ "authorMapping": {"zhangsan@company.com": "张三"},
154
81
  "openspecDir": "openspec/",
155
- "excludeExtensions": [
156
- ".md",
157
- ".txt",
158
- ".png",
159
- ".jpg",
160
- ".jpeg",
161
- ".gif",
162
- ".svg",
163
- ".ico",
164
- ".webp"
165
- ],
82
+ "excludeExtensions": [".md", ".txt", ".png", ".jpg", "..."],
166
83
  "activeUserWeeks": 2
167
84
  }
168
85
  ```
169
86
 
170
- ### 配置选项说明
171
-
172
- - **defaultBranches**:默认追踪的分支(支持通配符)
173
- - **defaultSinceHours**:默认开始时间偏移(小时,负数表示往前推)
174
- - **defaultUntilHours**:默认结束时间(当天的小时数)
175
- - **authorMapping**:作者名称映射,用于统一同一人的多个 Git 账号
176
- - **openspecDir**:OpenSpec 提案目录(默认:`openspec/`)
177
- - **excludeExtensions**:排除的文件扩展名(不计入代码变更)
178
- - **activeUserWeeks**:活跃用户时间窗口(周,默认:2)
179
-
180
- ## 统计逻辑
181
-
182
- 工具会识别同时满足以下两个条件的提交:
183
-
184
- 1. 包含 `openspec/` 目录中的文件变更(OpenSpec 提案)
185
- 2. 包含代码文件变更(排除文档文件)
186
-
187
- 统计内容包括:
188
-
189
- - **提交数**:符合条件的提交总数
190
- - **OpenSpec 提案**:按 `openspec/changes/{提案名称}` 目录统计
191
- - **代码文件**:修改的代码文件数量
192
- - **新增行数**:新增的代码行数
193
- - **删除行数**:删除的代码行数
194
- - **净变更**:新增行数 - 删除行数
195
-
196
- 工具提供两个统计视角:
87
+ 关键字段:默认分支/时间窗口、作者映射(合并身份)、OpenSpec 目录、排除扩展名、活跃用户窗口。
197
88
 
198
- 1. **提案汇总**:按提案聚合统计,显示每个提案的总代码变更量和所有贡献者,避免 merge commit 导致的统计偏差
199
- 2. **作者汇总**:按贡献者分组统计,显示各个作者的个人贡献情况
200
-
201
- ## 输出格式
202
-
203
- ### 表格格式(默认)
89
+ ## 输出示例
204
90
 
205
91
  ```
206
- 📊 OpenSpec 统计报告
207
- 时间范围:2024-01-01 00:00:00 ~ 2024-01-31 23:59:59
92
+ 📊 OpenSpec 统计
93
+ 时间:2024-01-01 00:00:00 ~ 2024-01-31 23:59:59
208
94
  分支:origin/master
209
- 总提交数:15
210
-
211
- 📋 提案汇总(按提案统计)
212
- ┌──────────────┬─────────┬──────────────────┬────────────┬───────────┬───────────┬─────────────┐
213
- │ 提案 │ 提交数 │ 贡献者 代码文件 │ 新增行数 │ 删除行数 │ 净变更
214
- ├──────────────┼─────────┼──────────────────┼────────────┼───────────┼───────────┼─────────────┤
215
- │ feature-123 │ 5 │ 张三, 李四 │ 30 │ +890 -234 │ +656 │
216
- │ feature-456 │ 3 │ 张三 │ 15 │ +344 │ -100 │ +244 │
217
- └──────────────┴─────────┴──────────────────┴────────────┴───────────┴───────────┴─────────────┘
218
- 📊 总计:2 个提案 | 8 次提交 | 45 个文件 | +1234/-334 行(净变更:+900)
219
-
220
- 👥 作者汇总(按贡献者统计)
221
- ┌──────────┬─────────┬──────────────────┬────────────┬───────────┬───────────┬─────────────┐
222
- 作者 提交数 提案数 代码文件 │ 新增行数 │ 删除行数 │ 净变更
223
- ├──────────┼─────────┼──────────────────┼────────────┼───────────┼───────────┼─────────────┤
224
- │ 张三 │ 8 │ 3 │ 45 │ +1234 │ -567 │ +667 │
225
- │ 李四 │ 7 │ 2 │ 32 │ +890 │ -234 │ +656 │
226
- └──────────┴─────────┴──────────────────┴────────────┴───────────┴───────────┴─────────────┘
95
+ 提交总数:8
96
+
97
+ 提案汇总
98
+ ┌──────────────┬─────────┬───────────┬───────────┐
99
+ │ 提案 │ 提交数 │ 文件数 │ 净变更
100
+ ├──────────────┼─────────┼───────────┼───────────┤
101
+ │ feature-123 │ 5 │ 30 │ +656
102
+ └──────────────┴─────────┴───────────┴───────────┘
103
+
104
+ 作者汇总
105
+ ┌─────────┬─────────┬───────────┬───────────┐
106
+ 作者 │ 提交数 │ 提案数 │ 净变更 │
107
+ ├─────────┼─────────┼───────────┼───────────┤
108
+ 张三 8 3 +667
109
+ └─────────┴─────────┴───────────┴───────────┘
227
110
  ```
228
111
 
229
- ### JSON 格式
112
+ 更多格式:`--markdown`、`--json`、`--csv`。
230
113
 
231
- ```bash
232
- openspec-stat --json
233
- ```
114
+ ## 语言
234
115
 
235
- ### CSV 格式
236
-
237
- ```bash
238
- openspec-stat --csv > stats.csv
239
- ```
240
-
241
- ### Markdown 格式
242
-
243
- ```bash
244
- openspec-stat --markdown > report.md
245
- ```
246
-
247
- ## 语言支持
248
-
249
- 工具支持英文和中文输出。您可以使用 `--lang` 选项指定语言:
250
-
251
- ```bash
252
- # 英文输出(默认)
253
- openspec-stat --lang en
254
-
255
- # 中文输出
256
- openspec-stat --lang zh-CN
257
- ```
258
-
259
- 工具还会自动检测您的系统语言。如果您的系统区域设置为中文,输出将默认使用中文。
116
+ `--lang en`(默认)或 `--lang zh-CN`,支持按系统语言自动选择。
260
117
 
261
118
  ## 开发
262
119
 
263
120
  ```bash
264
- # 安装依赖
265
121
  pnpm install
266
-
267
- # 开发模式
268
122
  pnpm dev
269
-
270
- # 构建
271
123
  pnpm build
272
-
273
- # 本地测试
274
124
  node dist/cjs/cli.js
275
125
  ```
276
126
 
277
- ## 贡献与发布流程
127
+ ## 贡献与发布
278
128
 
279
- - 请阅读 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解开发环境、分支策略和 PR 规范。
280
- - 请阅读 [RELEASE.md](./RELEASE.md) 了解基于 Changesets 的版本与发布流程。
129
+ - 参见 [CONTRIBUTING.md](./CONTRIBUTING.md) 获取开发与 PR 规范。
130
+ - 参见 [RELEASE.md](./RELEASE.md) 了解基于 Changesets 的发布流程。
281
131
 
282
132
  ## 许可证
283
133
 
package/dist/esm/cli.js CHANGED
@@ -4,7 +4,7 @@ import { runSingleRepoCommand } from "./commands/single.js";
4
4
  import { runMultiRepoCommand } from "./commands/multi.js";
5
5
  import { runInitCommand } from "./commands/init.js";
6
6
  const program = new Command();
7
- program.name('openspec-stat').description("Track team members' OpenSpec proposals and code changes in Git repositories").version("1.4.4").enablePositionalOptions().passThroughOptions();
7
+ program.name('openspec-stat').description("Track team members' OpenSpec proposals and code changes in Git repositories").version("1.4.5").enablePositionalOptions().passThroughOptions();
8
8
 
9
9
  // Default command for single-repository mode (for backward compatibility)
10
10
  program.argument('[repo]', 'Repository path', '.').option('-r, --repo <path>', 'Repository path (alternative)', '.').option('-b, --branches <branches>', 'Branch list, comma-separated').option('--no-interactive', 'Disable interactive branch selection').option('-s, --since <datetime>', 'Start time (default: yesterday 20:00)').option('-u, --until <datetime>', 'End time (default: today 20:00)').option('-a, --author <name>', 'Filter by specific author').option('--json', 'Output in JSON format').option('--csv', 'Output in CSV format').option('--markdown', 'Output in Markdown format').option('-c, --config <path>', 'Configuration file path').option('-v, --verbose', 'Verbose output mode').option('-l, --lang <language>', 'Language for output (en, zh-CN)', 'en').option('--no-fetch', 'Skip fetching remote branches').action(async (repo, options) => {
@@ -13,7 +13,7 @@ program.argument('[repo]', 'Repository path', '.').option('-r, --repo <path>', '
13
13
  repo: repo || options.repo || '.'
14
14
  });
15
15
  });
16
- program.command('multi').description('Multi-repository analysis mode (BETA)').option('-c, --config <path>', 'Configuration file path', '.openspec-stats.multi.json').option('-s, --since <datetime>', 'Override start time').option('-u, --until <datetime>', 'Override end time').option('-a, --author <name>', 'Filter by specific author').option('--json', 'Output in JSON format').option('--csv', 'Output in CSV format').option('--markdown', 'Output in Markdown format').option('-v, --verbose', 'Verbose output mode').option('-l, --lang <language>', 'Language (en, zh-CN)', 'en').option('--no-cleanup', 'Do not cleanup temporary directories').option('--show-contributors', 'Show detailed contributor statistics (default: only show summary)').option('--no-fetch', 'Skip fetching remote branches for local repositories').action(async options => {
16
+ program.command('multi').description('Multi-repository analysis mode (BETA)').option('-c, --config <path>', 'Configuration file path', '.openspec-stats.multi.json').option('-s, --since <datetime>', 'Override start time').option('-u, --until <datetime>', 'Override end time').option('-a, --author <name>', 'Filter by specific author').option('--json', 'Output in JSON format').option('--csv', 'Output in CSV format').option('--markdown', 'Output in Markdown format').option('-v, --verbose', 'Verbose output mode').option('-l, --lang <language>', 'Language (en, zh-CN)', 'en').option('--no-cleanup', 'Do not cleanup temporary directories').option('--cache-mode <mode>', 'Cache mode for remote repositories: persistent|temporary').option('--cache-max-age <ms>', 'Max cache age in milliseconds (optional)').option('--force-clone', 'Force fresh clone even if cache exists').option('--show-contributors', 'Show detailed contributor statistics (default: only show summary)').option('--no-fetch', 'Skip fetching remote branches for local repositories').action(async options => {
17
17
  await runMultiRepoCommand(options);
18
18
  });
19
19
  program.command('init').description('Initialize configuration file').option('--multi', 'Create multi-repository configuration (interactive)').option('--template <type>', 'Generate template (single|multi)').option('-o, --output <path>', 'Output file path').action(async options => {
@@ -12,114 +12,208 @@ export async function runMultiRepoCommand(options) {
12
12
  try {
13
13
  initI18n(options.lang || 'en');
14
14
  const isQuiet = Boolean(options.json || options.csv || options.markdown);
15
- const spinner = new SpinnerManager(isQuiet);
15
+ const rerunKey = 'r';
16
+ const quitKey = 'q';
16
17
  console.log(chalk.yellow.bold(t('multi.beta.warning')));
17
18
  console.log(chalk.gray(t('multi.beta.feedback')));
18
19
  console.log();
19
- if (isQuiet) {
20
- console.log(chalk.blue(t('multi.loading.config')));
21
- } else {
22
- spinner.start(t('multi.loading.config'));
23
- }
24
- const configPath = resolve(process.cwd(), options.config);
25
- if (!existsSync(configPath)) {
26
- throw new Error(`Configuration file not found: ${configPath}`);
27
- }
28
- const rawConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
29
- const config = validateAndFillDefaults(rawConfig);
30
- if (!isQuiet) {
31
- spinner.succeed();
32
- }
33
- if (options.verbose) {
34
- printConfigSummary(config);
35
- }
36
- let since;
37
- let until;
38
- if (options.since || options.until) {
39
- since = options.since ? parseDateTime(options.since) : getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours).since;
40
- until = options.until ? parseDateTime(options.until) : getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours).until;
41
- } else {
42
- const defaultRange = getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours);
43
- since = defaultRange.since;
44
- until = defaultRange.until;
45
- }
46
- console.log(chalk.blue(t('info.timeRange', {
47
- since: since.toLocaleString(),
48
- until: until.toLocaleString()
49
- })));
50
- const analyzer = new MultiRepoAnalyzer(config, {
51
- quiet: isQuiet
52
- });
53
- analyzer.registerCleanupHandlers();
54
- if (options.cleanup === false) {
55
- config.remoteCache = config.remoteCache || {
56
- dir: '/tmp/openspec-stat-cache',
57
- autoCleanup: false,
58
- cleanupOnComplete: false,
59
- cleanupOnError: false
20
+ const runAnalysis = async () => {
21
+ const spinner = new SpinnerManager(isQuiet);
22
+ if (isQuiet) {
23
+ console.log(chalk.blue(t('multi.loading.config')));
24
+ } else {
25
+ spinner.start(t('multi.loading.config'));
26
+ }
27
+ const configPath = resolve(process.cwd(), options.config);
28
+ if (!existsSync(configPath)) {
29
+ throw new Error(`Configuration file not found: ${configPath}`);
30
+ }
31
+ const rawConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
32
+ const config = validateAndFillDefaults(rawConfig);
33
+ const ensureCacheConfig = () => {
34
+ if (!config.remoteCache) {
35
+ config.remoteCache = {
36
+ autoCleanup: true,
37
+ cleanupOnComplete: false,
38
+ cleanupOnError: true,
39
+ mode: 'persistent'
40
+ };
41
+ }
42
+ return config.remoteCache;
60
43
  };
61
- config.remoteCache.cleanupOnComplete = false;
62
- }
44
+ if (options.cacheMode) {
45
+ const cacheCfg = ensureCacheConfig();
46
+ cacheCfg.mode = options.cacheMode;
47
+ if (options.cacheMode === 'temporary') {
48
+ cacheCfg.cleanupOnComplete = true;
49
+ }
50
+ }
51
+ if (options.cacheMaxAge !== undefined) {
52
+ const parsed = Number(options.cacheMaxAge);
53
+ if (!Number.isNaN(parsed)) {
54
+ const cacheCfg = ensureCacheConfig();
55
+ cacheCfg.maxAge = parsed;
56
+ }
57
+ }
58
+ if (!isQuiet) {
59
+ spinner.succeed();
60
+ }
61
+ if (options.verbose) {
62
+ printConfigSummary(config);
63
+ }
64
+ let since;
65
+ let until;
66
+ if (options.since || options.until) {
67
+ since = options.since ? parseDateTime(options.since) : getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours).since;
68
+ until = options.until ? parseDateTime(options.until) : getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours).until;
69
+ } else {
70
+ const defaultRange = getDefaultTimeRange(config.defaultSinceHours, config.defaultUntilHours);
71
+ since = defaultRange.since;
72
+ until = defaultRange.until;
73
+ }
74
+ console.log(chalk.blue(t('info.timeRange', {
75
+ since: since.toLocaleString(),
76
+ until: until.toLocaleString()
77
+ })));
78
+ if (options.cleanup === false) {
79
+ const cacheCfg = ensureCacheConfig();
80
+ cacheCfg.autoCleanup = false;
81
+ cacheCfg.cleanupOnComplete = false;
82
+ cacheCfg.cleanupOnError = false;
83
+ }
63
84
 
64
- // Handle --no-fetch option
65
- if (options.noFetch) {
66
- config.autoFetch = false;
67
- }
68
- const repoResults = await analyzer.analyzeAll(since, until);
69
- const successResults = repoResults.filter(r => r.success);
70
- const failedResults = repoResults.filter(r => !r.success);
71
- const summaryDivider = '-'.repeat(64);
72
- console.log(chalk.gray(summaryDivider));
73
- console.log(chalk.blue(t('multi.summary.title')));
74
- console.log(chalk.gray(summaryDivider));
75
- console.log(chalk.blue(t('multi.summary.repos', {
76
- total: String(repoResults.length),
77
- success: String(successResults.length),
78
- failed: String(failedResults.length)
79
- })));
80
- if (failedResults.length > 0) {
81
- console.log(chalk.yellow(`\n${t('multi.summary.failedTitle')}`));
82
- failedResults.forEach(r => {
83
- console.log(chalk.red(` - ${r.repository}: ${r.error}`));
85
+ // Handle --no-fetch option
86
+ if (options.noFetch) {
87
+ config.autoFetch = false;
88
+ }
89
+ const analyzer = new MultiRepoAnalyzer(config, {
90
+ quiet: isQuiet,
91
+ forceClone: options.forceClone
84
92
  });
85
- console.log();
86
- }
87
- const allAnalyses = successResults.flatMap(r => r.analyses);
88
- if (allAnalyses.length === 0) {
89
- console.log(chalk.yellow(t('warning.noQualifyingCommits')));
93
+ analyzer.registerCleanupHandlers();
94
+ const repoResults = await analyzer.analyzeAll(since, until);
95
+ const successResults = repoResults.filter(r => r.success);
96
+ const failedResults = repoResults.filter(r => !r.success);
97
+ const summaryDivider = '-'.repeat(64);
98
+ console.log(chalk.gray(summaryDivider));
99
+ console.log(chalk.blue(t('multi.summary.title')));
100
+ console.log(chalk.gray(summaryDivider));
101
+ console.log(chalk.blue(t('multi.summary.repos', {
102
+ total: String(repoResults.length),
103
+ success: String(successResults.length),
104
+ failed: String(failedResults.length)
105
+ })));
106
+ if (failedResults.length > 0) {
107
+ console.log(chalk.yellow(`\n${t('multi.summary.failedTitle')}`));
108
+ failedResults.forEach(r => {
109
+ console.log(chalk.red(` - ${r.repository}: ${r.error}`));
110
+ });
111
+ console.log();
112
+ }
113
+ const allAnalyses = successResults.flatMap(r => r.analyses);
114
+ if (allAnalyses.length === 0) {
115
+ console.log(chalk.yellow(t('warning.noQualifyingCommits')));
116
+ return;
117
+ }
118
+ console.log(chalk.blue(t('info.qualifyingCommits', {
119
+ count: String(allAnalyses.length)
120
+ })));
121
+ if (isQuiet) {
122
+ console.log(chalk.blue(t('loading.activeUsers')));
123
+ } else {
124
+ spinner.start(t('loading.activeUsers'));
125
+ }
126
+ const activeAuthors = await getActiveAuthorsFromMultiRepo(config, repoResults);
127
+ if (!isQuiet) {
128
+ spinner.succeed();
129
+ }
130
+ if (options.verbose && activeAuthors.size > 0) {
131
+ console.log(chalk.gray(t('info.activeUsers', {
132
+ weeks: String(config.activeUserWeeks || 2),
133
+ users: Array.from(activeAuthors).join(', ')
134
+ })));
135
+ }
136
+ const aggregator = new StatsAggregator(config, activeAuthors);
137
+ const allBranches = [...new Set(repoResults.flatMap(r => config.repositories?.find(repo => repo.name === r.repository)?.branches || []))];
138
+ const result = aggregator.aggregate(allAnalyses, since, until, allBranches, options.author);
139
+ const formatter = new OutputFormatter();
140
+ const showContributors = options.showContributors || false;
141
+ if (options.json) {
142
+ console.log(formatter.formatJSON(result, showContributors));
143
+ } else if (options.csv) {
144
+ console.log(formatter.formatCSV(result, showContributors));
145
+ } else if (options.markdown) {
146
+ console.log(formatter.formatMarkdown(result, showContributors));
147
+ } else {
148
+ console.log(formatter.formatTable(result, options.verbose, showContributors));
149
+ }
150
+ };
151
+ await runAnalysis();
152
+ if (isQuiet || !process.stdin.isTTY) {
90
153
  return;
91
154
  }
92
- console.log(chalk.blue(t('info.qualifyingCommits', {
93
- count: String(allAnalyses.length)
94
- })));
95
- if (isQuiet) {
96
- console.log(chalk.blue(t('loading.activeUsers')));
97
- } else {
98
- spinner.start(t('loading.activeUsers'));
99
- }
100
- const activeAuthors = await getActiveAuthorsFromMultiRepo(config, repoResults);
101
- if (!isQuiet) {
102
- spinner.succeed();
103
- }
104
- if (options.verbose && activeAuthors.size > 0) {
105
- console.log(chalk.gray(t('info.activeUsers', {
106
- weeks: String(config.activeUserWeeks || 2),
107
- users: Array.from(activeAuthors).join(', ')
155
+ const stdin = process.stdin;
156
+ stdin.setEncoding('utf8');
157
+ stdin.resume();
158
+ stdin.setRawMode?.(true);
159
+ const cleanupStdin = () => {
160
+ stdin.setRawMode?.(false);
161
+ stdin.pause();
162
+ };
163
+ const prompt = () => {
164
+ console.log(chalk.bgGreen.black(` ${t('multi.rerun.finished')} `));
165
+ const formattedRerunKey = chalk.bold(rerunKey);
166
+ const formattedQuitKey = chalk.bold(quitKey);
167
+ console.log(chalk.gray(t('multi.rerun.prompt', {
168
+ rerunKey: formattedRerunKey,
169
+ quitKey: formattedQuitKey
108
170
  })));
109
- }
110
- const aggregator = new StatsAggregator(config, activeAuthors);
111
- const allBranches = [...new Set(repoResults.flatMap(r => config.repositories?.find(repo => repo.name === r.repository)?.branches || []))];
112
- const result = aggregator.aggregate(allAnalyses, since, until, allBranches, options.author);
113
- const formatter = new OutputFormatter();
114
- const showContributors = options.showContributors || false;
115
- if (options.json) {
116
- console.log(formatter.formatJSON(result, showContributors));
117
- } else if (options.csv) {
118
- console.log(formatter.formatCSV(result, showContributors));
119
- } else if (options.markdown) {
120
- console.log(formatter.formatMarkdown(result, showContributors));
121
- } else {
122
- console.log(formatter.formatTable(result, options.verbose, showContributors));
171
+ };
172
+ const waitKeyPress = () => new Promise(resolve => {
173
+ const onData = data => {
174
+ stdin.off('data', onData);
175
+ resolve(data.toString());
176
+ };
177
+ stdin.on('data', onData);
178
+ });
179
+ console.log();
180
+ prompt();
181
+ let running = false;
182
+
183
+ // eslint-disable-next-line no-constant-condition
184
+ while (true) {
185
+ const keyRaw = await waitKeyPress();
186
+ const key = keyRaw[0];
187
+ if (!key) {
188
+ continue;
189
+ }
190
+ if (key === '\u0003') {
191
+ cleanupStdin();
192
+ process.exit(130);
193
+ }
194
+ const normalized = key.toLowerCase();
195
+ if (normalized === quitKey) {
196
+ cleanupStdin();
197
+ process.exit(130);
198
+ return;
199
+ }
200
+ if (normalized === rerunKey) {
201
+ if (running) {
202
+ continue;
203
+ }
204
+ running = true;
205
+ console.log(chalk.blue(t('multi.rerun.running')));
206
+ try {
207
+ await runAnalysis();
208
+ } catch (error) {
209
+ cleanupStdin();
210
+ throw error;
211
+ } finally {
212
+ running = false;
213
+ console.log();
214
+ prompt();
215
+ }
216
+ }
123
217
  }
124
218
  } catch (error) {
125
219
  console.error(chalk.red(t('error.prefix')), error);
@@ -87,6 +87,7 @@
87
87
  "multi.repo.header": " [{{current}}/{{total}}] {{repo}}{{typeSuffix}}",
88
88
  "multi.repo.type.remote": " (remote)",
89
89
  "multi.repo.cloning": " - Cloning {{repo}}...",
90
+ "multi.repo.updating": " - Updating cache for {{repo}}...",
90
91
  "multi.repo.cloned": " - Clone completed: {{repo}}",
91
92
  "multi.repo.cloneFailed": " - Clone failed: {{repo}} ({{error}})",
92
93
  "multi.repo.fetching": " - Fetching remote branches...",
@@ -102,6 +103,9 @@
102
103
  "multi.progress.batch": "Processing batch {{current}}/{{total}} ({{count}} repositories)...",
103
104
  "multi.table.repository": "Repository",
104
105
  "multi.table.type": "Type",
106
+ "multi.rerun.finished": "FINISHED",
107
+ "multi.rerun.prompt": "Press {{rerunKey}} to rerun stats, {{quitKey}} to quit",
108
+ "multi.rerun.running": "Re-running multi-repo statistics...",
105
109
 
106
110
  "init.welcome": "\nOpenSpec Configuration Wizard\n",
107
111
  "init.welcomeMulti": "\nOpenSpec Multi-Repository Configuration Wizard (BETA)\n",
@@ -87,6 +87,7 @@
87
87
  "multi.repo.header": " [{{current}}/{{total}}] {{repo}}{{typeSuffix}}",
88
88
  "multi.repo.type.remote": "(远程)",
89
89
  "multi.repo.cloning": " - 正在克隆 {{repo}}...",
90
+ "multi.repo.updating": " - 正在更新缓存 {{repo}}...",
90
91
  "multi.repo.cloned": " - 克隆完成:{{repo}}",
91
92
  "multi.repo.cloneFailed": " - 克隆失败:{{repo}}({{error}})",
92
93
  "multi.repo.fetching": " - 正在拉取远程分支...",
@@ -102,6 +103,9 @@
102
103
  "multi.progress.batch": "正在处理批次 {{current}}/{{total}}({{count}} 个仓库)...",
103
104
  "multi.table.repository": "仓库",
104
105
  "multi.table.type": "类型",
106
+ "multi.rerun.finished": "已完成",
107
+ "multi.rerun.prompt": "按 {{rerunKey}} 重新运行统计,按 {{quitKey}} 退出",
108
+ "multi.rerun.running": "正在重新运行多仓库统计...",
105
109
 
106
110
  "init.welcome": "\nOpenSpec 配置向导\n",
107
111
  "init.welcomeMulti": "\nOpenSpec 多仓库配置向导(测试版)\n",
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync } from 'fs';
4
+ export const GLOBAL_CACHE_DIR = join(homedir(), '.openspec-stat', 'cached', 'repos');
5
+ export function ensureGlobalCacheDir() {
6
+ if (!existsSync(GLOBAL_CACHE_DIR)) {
7
+ mkdirSync(GLOBAL_CACHE_DIR, {
8
+ recursive: true
9
+ });
10
+ }
11
+ return GLOBAL_CACHE_DIR;
12
+ }
@@ -1,3 +1,4 @@
1
+ import { GLOBAL_CACHE_DIR } from "./cache-path.js";
1
2
  import chalk from 'chalk';
2
3
  import { t } from "../i18n/index.js";
3
4
  const DEFAULT_MULTI_REPO_CONFIG = {
@@ -13,10 +14,10 @@ const DEFAULT_MULTI_REPO_CONFIG = {
13
14
  timeout: 600000
14
15
  },
15
16
  remoteCache: {
16
- dir: '/tmp/openspec-stat-cache',
17
17
  autoCleanup: true,
18
- cleanupOnComplete: true,
19
- cleanupOnError: true
18
+ cleanupOnComplete: false,
19
+ cleanupOnError: true,
20
+ mode: 'persistent'
20
21
  }
21
22
  };
22
23
  export function validateAndFillDefaults(config) {
@@ -99,7 +100,7 @@ export function printConfigSummary(config) {
99
100
  }));
100
101
  console.log(chalk.cyan(t('config.summary.remoteCache')));
101
102
  console.log(t('config.summary.cacheDir', {
102
- dir: config.remoteCache?.dir || '/tmp/openspec-stat-cache'
103
+ dir: GLOBAL_CACHE_DIR
103
104
  }));
104
105
  console.log(t('config.summary.autoCleanup', {
105
106
  enabled: config.remoteCache?.cleanupOnComplete ? 'Yes' : 'No'
@@ -1,21 +1,25 @@
1
1
  import simpleGit from 'simple-git';
2
- import { mkdtempSync, rmSync, existsSync, mkdirSync } from 'fs';
3
- import { tmpdir } from 'os';
2
+ import { mkdtempSync, rmSync, existsSync, statSync } from 'fs';
4
3
  import { join, resolve } from 'path';
4
+ import { createHash } from 'crypto';
5
5
  import chalk from 'chalk';
6
6
  import { GitAnalyzer } from "../git-analyzer.js";
7
7
  import { t } from "../i18n/index.js";
8
8
  import { SpinnerManager } from "../ui/spinner.js";
9
+ import { ensureGlobalCacheDir } from "./cache-path.js";
9
10
  export class MultiRepoAnalyzer {
10
11
  config;
11
12
  tempDirs = new Set();
12
13
  isQuiet;
14
+ forceClone;
15
+ shouldCleanupTemps = false;
13
16
  cloneOrder = new Map();
14
17
  totalCloneTargets = 0;
15
18
  nextCloneIndex = 1;
16
19
  constructor(config, options) {
17
20
  this.config = config;
18
21
  this.isQuiet = options?.quiet ?? false;
22
+ this.forceClone = options?.forceClone ?? false;
19
23
  }
20
24
  async analyzeAll(since, until) {
21
25
  const repos = this.config.repositories || [];
@@ -23,6 +27,7 @@ export class MultiRepoAnalyzer {
23
27
  this.cloneOrder.clear();
24
28
  this.nextCloneIndex = 1;
25
29
  this.totalCloneTargets = enabledRepos.filter(repo => repo.type === 'remote').length;
30
+ let hadError = false;
26
31
  try {
27
32
  const localRepos = enabledRepos.filter(repo => repo.type === 'local');
28
33
  const remoteRepos = enabledRepos.filter(repo => repo.type === 'remote');
@@ -30,8 +35,12 @@ export class MultiRepoAnalyzer {
30
35
  const localResults = await this.processInBatches(localRepos, (repo, context) => this.analyzeRepository(repo, since, until, context), maxConcurrent);
31
36
  const remoteResults = await this.processInBatches(remoteRepos, (repo, context) => this.analyzeRepository(repo, since, until, context), maxConcurrent);
32
37
  return [...localResults, ...remoteResults];
38
+ } catch (error) {
39
+ hadError = true;
40
+ throw error;
33
41
  } finally {
34
- if (this.config.remoteCache?.cleanupOnComplete) {
42
+ const shouldCleanup = Boolean(this.config.remoteCache?.cleanupOnComplete || this.config.remoteCache?.cleanupOnError && hadError || this.shouldCleanupTemps);
43
+ if (shouldCleanup) {
35
44
  await this.cleanupTempDirs();
36
45
  }
37
46
  }
@@ -115,14 +124,102 @@ export class MultiRepoAnalyzer {
115
124
  }
116
125
  }
117
126
  async cloneRemoteRepository(repo) {
118
- const cacheDir = this.config.remoteCache?.dir || join(tmpdir(), 'openspec-stat-cache');
119
- if (!existsSync(cacheDir)) {
120
- mkdirSync(cacheDir, {
121
- recursive: true
127
+ const cacheMode = repo.cacheMode || this.config.remoteCache?.mode || 'persistent';
128
+ if (cacheMode === 'temporary') {
129
+ this.shouldCleanupTemps = true;
130
+ return this.cloneToTempDirectory(repo);
131
+ }
132
+ const cachePath = this.getCacheRepoPath(repo);
133
+ const timeout = this.config.parallelism?.timeout || 600000;
134
+ const validCache = this.forceClone ? false : await this.isCacheValid(cachePath, repo.url);
135
+ if (validCache) {
136
+ console.log(chalk.cyan(t('multi.repo.updating', {
137
+ repo: repo.name
138
+ })));
139
+ await this.withTimeout(this.updateCachedRepository(cachePath, repo), timeout, `Update timeout for ${repo.name}`);
140
+ return cachePath;
141
+ }
142
+ if (existsSync(cachePath)) {
143
+ rmSync(cachePath, {
144
+ recursive: true,
145
+ force: true
122
146
  });
123
147
  }
124
- const tempDir = mkdtempSync(join(cacheDir, `${repo.name}-`));
148
+ return this.cloneToPath(cachePath, repo, false);
149
+ }
150
+ ensureCacheDir() {
151
+ return ensureGlobalCacheDir();
152
+ }
153
+ getCacheRepoPath(repo) {
154
+ const baseDir = this.ensureCacheDir();
155
+ const safeName = this.getSafeRepoName(repo);
156
+ const hash = createHash('sha256').update(repo.url || repo.name).digest('hex');
157
+ const id = hash.slice(0, 12);
158
+ return join(baseDir, `${safeName}-${id}`);
159
+ }
160
+ getSafeRepoName(repo) {
161
+ const sanitize = value => {
162
+ return value.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
163
+ };
164
+ const fromName = repo.name ? sanitize(repo.name) : '';
165
+ if (fromName) return fromName;
166
+ const rawUrl = repo.url || '';
167
+ const normalizedUrl = rawUrl.startsWith('git@') ? `ssh://${rawUrl.replace(':', '/')}` : rawUrl;
168
+ try {
169
+ const parsed = new URL(normalizedUrl);
170
+ const parts = parsed.pathname.split('/').filter(Boolean);
171
+ const repoPart = parts.pop();
172
+ const ownerPart = parts.pop();
173
+ const base = repoPart ? repoPart.replace(/\.git$/, '') : ownerPart;
174
+ const combined = ownerPart && base ? `${ownerPart}-${base}` : base;
175
+ const cleaned = combined ? sanitize(combined) : '';
176
+ if (cleaned) return cleaned;
177
+ } catch {
178
+ // ignore parsing errors and fallback
179
+ }
180
+ return 'repo';
181
+ }
182
+ async isCacheValid(cachePath, repoUrl) {
183
+ if (!existsSync(cachePath)) return false;
184
+ if (!existsSync(join(cachePath, '.git'))) return false;
185
+ const maxAge = this.config.remoteCache?.maxAge;
186
+ if (maxAge !== undefined) {
187
+ try {
188
+ const stats = statSync(cachePath);
189
+ if (Date.now() - stats.mtimeMs > maxAge) {
190
+ return false;
191
+ }
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+ try {
197
+ const git = simpleGit(cachePath);
198
+ const remotes = await git.getRemotes(true);
199
+ const originUrl = remotes.find(r => r.name === 'origin')?.refs?.fetch;
200
+ return originUrl === repoUrl;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+ buildCloneArgs(repo) {
206
+ const cloneArgs = ['--progress'];
207
+ if (repo.cloneOptions?.depth !== null && repo.cloneOptions?.depth !== undefined) {
208
+ cloneArgs.push(`--depth=${repo.cloneOptions.depth}`);
209
+ }
210
+ if (repo.cloneOptions?.singleBranch) {
211
+ cloneArgs.push('--single-branch');
212
+ }
213
+ return cloneArgs;
214
+ }
215
+ async cloneToTempDirectory(repo) {
216
+ const cacheDir = this.ensureCacheDir();
217
+ const safeName = this.getSafeRepoName(repo);
218
+ const tempDir = mkdtempSync(join(cacheDir, `${safeName}-tmp-`));
125
219
  this.tempDirs.add(tempDir);
220
+ return this.cloneToPath(tempDir, repo, true);
221
+ }
222
+ async cloneToPath(targetPath, repo, trackTemp) {
126
223
  const progressSuffix = this.getProgressSuffix(repo.name);
127
224
  const cloneSpinner = this.isQuiet ? undefined : new SpinnerManager(false);
128
225
  this.reportCloneStatus('start', repo.name, progressSuffix, cloneSpinner);
@@ -145,27 +242,35 @@ export class MultiRepoAnalyzer {
145
242
  const git = simpleGit({
146
243
  progress: progressReporter
147
244
  });
148
- const cloneArgs = [];
149
-
150
- // Enable git's progress output so simple-git can emit progress events
151
- cloneArgs.push('--progress');
152
- if (repo.cloneOptions?.depth !== null && repo.cloneOptions?.depth !== undefined) {
153
- cloneArgs.push(`--depth=${repo.cloneOptions.depth}`);
154
- }
155
- if (repo.cloneOptions?.singleBranch) {
156
- cloneArgs.push('--single-branch');
157
- }
245
+ const cloneArgs = this.buildCloneArgs(repo);
158
246
  const timeout = this.config.parallelism?.timeout || 600000;
159
247
  try {
160
- await this.withTimeout(git.clone(repo.url, tempDir, cloneArgs), timeout, `Clone timeout for ${repo.name}`);
248
+ await this.withTimeout(git.clone(repo.url, targetPath, cloneArgs), timeout, `Clone timeout for ${repo.name}`);
161
249
  this.reportCloneStatus('success', repo.name, progressSuffix, cloneSpinner);
162
- return tempDir;
250
+ if (trackTemp) {
251
+ this.tempDirs.add(targetPath);
252
+ }
253
+ return targetPath;
163
254
  } catch (error) {
164
255
  const errorMessage = error instanceof Error ? error.message : String(error);
165
256
  this.reportCloneStatus('fail', repo.name, progressSuffix, cloneSpinner, errorMessage);
166
257
  throw error;
167
258
  }
168
259
  }
260
+ async updateCachedRepository(cachePath, repo) {
261
+ const git = simpleGit(cachePath);
262
+ const timeout = this.config.parallelism?.timeout || 600000;
263
+ await this.withTimeout(git.fetch(['--all', '--prune']), timeout, `Fetch timeout for ${repo.name}`);
264
+ if (repo.branches.length > 0) {
265
+ const mainBranch = repo.branches[0].replace(/^origin\//, '');
266
+ try {
267
+ await git.checkout(mainBranch);
268
+ await git.reset(['--hard', `origin/${mainBranch}`]);
269
+ } catch {
270
+ // If branch checkout fails, continue with fetched state
271
+ }
272
+ }
273
+ }
169
274
  resolveLocalPath(path) {
170
275
  if (path.startsWith('/') || path.match(/^[A-Za-z]:\\/)) {
171
276
  return path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openspec-stat",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "Track team members' OpenSpec proposals and code changes in Git repositories",
5
5
  "type": "module",
6
6
  "main": "dist/esm/index.js",