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 +66 -218
- package/README.zh-CN.md +66 -216
- package/dist/esm/cli.js +2 -2
- package/dist/esm/commands/multi.js +193 -99
- package/dist/esm/i18n/locales/en.json +4 -0
- package/dist/esm/i18n/locales/zh-CN.json +4 -0
- package/dist/esm/multi/cache-path.js +12 -0
- package/dist/esm/multi/config-validator.js +5 -4
- package/dist/esm/multi/multi-repo-analyzer.js +125 -20
- package/package.json +1 -1
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
45
|
-
|
|
46
|
-
### Basic Usage
|
|
22
|
+
## Quick start
|
|
47
23
|
|
|
48
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
## Common flags (full list: `openspec-stat --help`)
|
|
82
47
|
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
Analyze multiple local/remote repositories in one run.
|
|
105
60
|
|
|
106
61
|
```bash
|
|
107
|
-
|
|
108
|
-
openspec-stat
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
132
|
-
openspec-stat --lang zh-CN
|
|
67
|
+
See [Multi-Repository Guide](./MULTI_REPO_GUIDE.md) for full details.
|
|
133
68
|
|
|
134
|
-
|
|
135
|
-
openspec-stat
|
|
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
|
|
73
|
+
## Configuration (short)
|
|
139
74
|
|
|
140
|
-
Create `.openspec-stats.json` or `openspec-stats.config.json` in the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
Time
|
|
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:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
│ Proposal │ Commits │
|
|
216
|
-
|
|
217
|
-
│ feature-123 │ 5 │
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
│
|
|
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
|
-
|
|
114
|
+
Use `--markdown`, `--json`, or `--csv` for other formats.
|
|
250
115
|
|
|
251
|
-
|
|
116
|
+
## Language
|
|
252
117
|
|
|
253
|
-
|
|
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
|
|
284
|
-
- See [RELEASE.md](./RELEASE.md) for the Changesets-driven
|
|
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
|
-
##
|
|
134
|
+
## License
|
|
287
135
|
|
|
288
136
|
MIT
|
package/README.zh-CN.md
CHANGED
|
@@ -4,280 +4,130 @@
|
|
|
4
4
|
[](https://npmjs.com/package/openspec-stat)
|
|
5
5
|
[](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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
- 按时间窗口与分支过滤追踪 Git 提交
|
|
38
|
+
- 识别同时包含 OpenSpec 提案与代码变更的提交
|
|
39
|
+
- 提案维度聚合,避免 merge commit 统计偏差
|
|
40
|
+
- 作者分组与名称映射(合并多个 Git 身份)
|
|
41
|
+
- 多分支通配与 **多仓库模式(BETA)**
|
|
42
|
+
- 输出:表格、JSON、CSV、Markdown;语言:en / zh-CN
|
|
78
43
|
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
2. **作者汇总**:按贡献者分组统计,显示各个作者的个人贡献情况
|
|
200
|
-
|
|
201
|
-
## 输出格式
|
|
202
|
-
|
|
203
|
-
### 表格格式(默认)
|
|
89
|
+
## 输出示例
|
|
204
90
|
|
|
205
91
|
```
|
|
206
|
-
📊 OpenSpec
|
|
207
|
-
|
|
92
|
+
📊 OpenSpec 统计
|
|
93
|
+
时间:2024-01-01 00:00:00 ~ 2024-01-31 23:59:59
|
|
208
94
|
分支:origin/master
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
│ 提案 │ 提交数 │
|
|
214
|
-
|
|
215
|
-
│ feature-123 │ 5 │
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
112
|
+
更多格式:`--markdown`、`--json`、`--csv`。
|
|
230
113
|
|
|
231
|
-
|
|
232
|
-
openspec-stat --json
|
|
233
|
-
```
|
|
114
|
+
## 语言
|
|
234
115
|
|
|
235
|
-
|
|
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
|
-
-
|
|
280
|
-
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|