@drazenbebic/wdid 0.1.0 → 0.2.0
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 +79 -15
- package/dist/index.js +435 -37
- package/package.json +19 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> What did I do? — a small CLI that summarizes your git activity as a tidy table, so you can fill in your timesheet without trying to remember Tuesday.
|
|
4
4
|
|
|
5
|
-
`wdid` reads `git log` for your author across one or more repos and renders the output as a colorized table with **Date
|
|
5
|
+
`wdid` reads `git log` for your author across one or more repos and renders the output as a colorized table with **Date** (with commit time, shown in your local timezone), **Ticket** (JIRA-style `ABC-123` by default, parsed from the commit subject), and **Description**.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -28,26 +28,90 @@ By default `wdid` uses `git config user.name` as the author and the current work
|
|
|
28
28
|
### Example output
|
|
29
29
|
|
|
30
30
|
```
|
|
31
|
-
|
|
32
|
-
│ Date
|
|
33
|
-
|
|
34
|
-
│ 2026-05-27 │ ABC-123 │ feat(ABC-123): add login flow │
|
|
35
|
-
│ 2026-05-27 │ — │ chore: bump deps │
|
|
36
|
-
│ 2026-05-26 │ ABC-119 │ fix(ABC-119): handle empty payload │
|
|
37
|
-
|
|
31
|
+
┌──────────────────┬──────────────┬──────────────────────────────────────────────────┐
|
|
32
|
+
│ Date │ Ticket │ Description │
|
|
33
|
+
├──────────────────┼──────────────┼──────────────────────────────────────────────────┤
|
|
34
|
+
│ 2026-05-27 16:42 │ ABC-123 │ feat(ABC-123): add login flow │
|
|
35
|
+
│ 2026-05-27 11:08 │ — │ chore: bump deps │
|
|
36
|
+
│ 2026-05-26 17:53 │ ABC-119 │ fix(ABC-119): handle empty payload │
|
|
37
|
+
└──────────────────┴──────────────┴──────────────────────────────────────────────────┘
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
The time is rendered dimmed and shown in your local timezone (parsed from the committer's full ISO timestamp).
|
|
41
|
+
|
|
40
42
|
If a commit doesn't reference a ticket, the Ticket column is left blank (rendered as `—`).
|
|
41
43
|
|
|
44
|
+
With `--group-by-day`, the date moves into a section heading instead of repeating per row, making longer outputs feel more like a journal:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
┌───────┬──────────────┬─────────────────────────────────────┐
|
|
48
|
+
│ Time │ Ticket │ Description │
|
|
49
|
+
├───────┴──────────────┴─────────────────────────────────────┤
|
|
50
|
+
│ 2026-05-27 │
|
|
51
|
+
├───────┬──────────────┬─────────────────────────────────────┤
|
|
52
|
+
│ 16:42 │ ABC-123 │ feat(ABC-123): add login flow │
|
|
53
|
+
│ 11:08 │ — │ chore: bump deps │
|
|
54
|
+
├───────┴──────────────┴─────────────────────────────────────┤
|
|
55
|
+
│ 2026-05-26 │
|
|
56
|
+
├───────┬──────────────┬─────────────────────────────────────┤
|
|
57
|
+
│ 17:53 │ ABC-119 │ fix(ABC-119): handle empty payload │
|
|
58
|
+
└───────┴──────────────┴─────────────────────────────────────┘
|
|
59
|
+
```
|
|
60
|
+
|
|
42
61
|
## Options
|
|
43
62
|
|
|
44
|
-
| Option
|
|
45
|
-
|
|
|
46
|
-
| `[date]`
|
|
47
|
-
| `--from <date>`
|
|
48
|
-
| `--to <date>`
|
|
49
|
-
| `--author <name>`
|
|
50
|
-
| `--repo <path...>`
|
|
63
|
+
| Option | Description |
|
|
64
|
+
| -------------------------- | -------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `[date]` | A `YYYY-MM-DD` date or the literal `today`. Omit to show all history. |
|
|
66
|
+
| `--from <date>` | Start date (inclusive). |
|
|
67
|
+
| `--to <date>` | End date (inclusive). |
|
|
68
|
+
| `--author <name>` | Override the git author. Defaults to `git config user.name` (or `defaultAuthor` in config). |
|
|
69
|
+
| `--repo <path...>` | One or more repo paths to query. Defaults to `defaultRepos` in config, then the current dir. |
|
|
70
|
+
| `--format <preset>` | Ticket format: `jira`, `github`, `conventional`, or `custom`. Defaults to `jira`. |
|
|
71
|
+
| `--ticket-pattern <regex>` | Custom regex for ticket extraction. Implies `--format custom`; overrides `--format`. |
|
|
72
|
+
| `--no-color` | Disable colored output. Also honored via the `NO_COLOR` env var. |
|
|
73
|
+
| `--limit <N>` | Cap the table to the most recent `N` rows. Positive integer. |
|
|
74
|
+
| `--group-by-day` | Group rows under a bold date heading per day; the row only shows the time. |
|
|
75
|
+
| `--json` | Emit a JSON array of commit entries to stdout instead of the table. Empty result is `[]`. |
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
`wdid` looks for a config file in this order:
|
|
80
|
+
|
|
81
|
+
1. **Repo-level** (current directory and walking up): `wdid.config.{js,cjs,mjs,ts,json,yaml,yml}`, `.wdidrc{,.json,.yaml,.yml,.js,.cjs}`, or a `"wdid"` field in `package.json`.
|
|
82
|
+
2. **Global**: `~/.config/wdid/config.json` (honors `XDG_CONFIG_HOME`).
|
|
83
|
+
|
|
84
|
+
CLI flags always win. The first match in this list is used in full (configs do not merge across levels).
|
|
85
|
+
|
|
86
|
+
### Schema
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"format": "jira",
|
|
91
|
+
"customPattern": "^\\[([A-Z]+-\\d+)\\]",
|
|
92
|
+
"defaultAuthor": "Jane Doe",
|
|
93
|
+
"defaultRepos": ["~/work/api", "~/work/web"]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| Field | Type | Description |
|
|
98
|
+
| ------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
99
|
+
| `format` | `"jira" \| "github" \| "conventional" \| "custom"` | Ticket extraction preset. Default `jira`. |
|
|
100
|
+
| `customPattern` | `string` | Regex used when `format` is `"custom"`. First capture group wins, else full match. |
|
|
101
|
+
| `defaultAuthor` | `string` | Used when `--author` is not passed and you want to skip the `git config` lookup. |
|
|
102
|
+
| `defaultRepos` | `string[]` | Paths to query when no `--repo` is given. `~` is expanded. |
|
|
103
|
+
| `ticketColumnLabel` | `string` | Override the auto-picked column header (see below). |
|
|
104
|
+
|
|
105
|
+
### Format presets
|
|
106
|
+
|
|
107
|
+
| Preset | Matches | Example commit → match | Column header |
|
|
108
|
+
| -------------- | ------------------------------------------------ | -------------------------------------- | ------------- |
|
|
109
|
+
| `jira` | `ABC-123` style (uppercase project key + digits) | `feat(ABC-123): add login` → `ABC-123` | `Ticket` |
|
|
110
|
+
| `github` | `#123` style | `Closes #42` → `42` | `Issue` |
|
|
111
|
+
| `conventional` | Conventional Commit `type(scope)!` | `feat(auth)!: ...` → `feat(auth)!` | `Type` |
|
|
112
|
+
| `custom` | Your `customPattern` regex | depends on the regex | `Match` |
|
|
113
|
+
|
|
114
|
+
The column header is picked automatically based on the active format. To override it for a specific preset (e.g. call them "Tasks" instead of "Ticket"), set `ticketColumnLabel` in your config.
|
|
51
115
|
|
|
52
116
|
## Development
|
|
53
117
|
|
package/dist/index.js
CHANGED
|
@@ -1,73 +1,264 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import chalk2 from "chalk";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/git.ts
|
|
7
|
-
import { execFile } from "child_process";
|
|
8
|
+
import { execFile, spawn } from "child_process";
|
|
8
9
|
import { promisify } from "util";
|
|
9
10
|
var execFileAsync = promisify(execFile);
|
|
10
11
|
var FIELD_SEP = "";
|
|
11
12
|
var RECORD_SEP = "";
|
|
12
|
-
var
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
var TRUNK_BRANCHES = /* @__PURE__ */ new Set(["main", "master"]);
|
|
14
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
15
|
+
function formatLocalDateTime(iso) {
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
if (Number.isNaN(d.getTime())) {
|
|
18
|
+
return { date: "", time: "" };
|
|
19
|
+
}
|
|
20
|
+
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
21
|
+
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
22
|
+
return { date, time };
|
|
16
23
|
}
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
24
|
+
function extractTicket(message, pattern) {
|
|
25
|
+
const match = message.match(pattern);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return match[1] ?? match[0];
|
|
30
|
+
}
|
|
31
|
+
function normalizeBranchName(rawName) {
|
|
32
|
+
const clean = rawName.replace(/[~^].*$/, "").trim();
|
|
33
|
+
if (!clean || clean === "undefined" || TRUNK_BRANCHES.has(clean)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return clean;
|
|
37
|
+
}
|
|
38
|
+
async function runGitWithStdin(cwd, args, input) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const proc = spawn("git", args, { cwd });
|
|
41
|
+
let stdout = "";
|
|
42
|
+
let stderr = "";
|
|
43
|
+
proc.stdout.on("data", (chunk) => {
|
|
44
|
+
stdout += chunk.toString();
|
|
45
|
+
});
|
|
46
|
+
proc.stderr.on("data", (chunk) => {
|
|
47
|
+
stderr += chunk.toString();
|
|
48
|
+
});
|
|
49
|
+
proc.on("error", reject);
|
|
50
|
+
proc.on("close", (code) => {
|
|
51
|
+
if (code !== 0) {
|
|
52
|
+
reject(new Error(`git ${args.join(" ")} failed: ${stderr.trim()}`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
resolve(stdout);
|
|
56
|
+
});
|
|
57
|
+
proc.stdin.write(input);
|
|
58
|
+
proc.stdin.end();
|
|
20
59
|
});
|
|
21
|
-
|
|
60
|
+
}
|
|
61
|
+
async function getBranchMap(cwd, shas) {
|
|
62
|
+
const map = /* @__PURE__ */ new Map();
|
|
63
|
+
if (shas.length === 0) {
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
let stdout;
|
|
67
|
+
try {
|
|
68
|
+
stdout = await runGitWithStdin(
|
|
69
|
+
cwd,
|
|
70
|
+
["name-rev", "--stdin", "--refs=refs/heads/*"],
|
|
71
|
+
shas.join("\n") + "\n"
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
for (const line of stdout.split("\n")) {
|
|
77
|
+
const m = line.match(/^([0-9a-fA-F]+)\s+\((.+)\)\s*$/);
|
|
78
|
+
if (!m) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const sha = m[1];
|
|
82
|
+
const rawName = m[2];
|
|
83
|
+
if (!sha || !rawName) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
map.set(sha, normalizeBranchName(rawName));
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
async function assertGitRepo(cwd) {
|
|
91
|
+
try {
|
|
92
|
+
await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
93
|
+
cwd
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err.code === "ENOENT") {
|
|
97
|
+
throw new Error("git is not installed or not on PATH", { cause: err });
|
|
98
|
+
}
|
|
99
|
+
throw new Error(
|
|
100
|
+
`not inside a git repository: ${cwd} \u2014 cd into a repo or pass --repo <path>`,
|
|
101
|
+
{ cause: err }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function getGitUserName(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync("git", ["config", "user.name"], {
|
|
108
|
+
cwd
|
|
109
|
+
});
|
|
110
|
+
const name = stdout.trim();
|
|
111
|
+
if (!name) {
|
|
112
|
+
throw new Error("empty");
|
|
113
|
+
}
|
|
114
|
+
return name;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err.code === "ENOENT") {
|
|
117
|
+
throw new Error("git is not installed or not on PATH", { cause: err });
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author',
|
|
121
|
+
{ cause: err }
|
|
122
|
+
);
|
|
123
|
+
}
|
|
22
124
|
}
|
|
23
125
|
async function getCommits(opts) {
|
|
126
|
+
await assertGitRepo(opts.cwd);
|
|
24
127
|
const args = [
|
|
25
128
|
"log",
|
|
26
129
|
"--all",
|
|
27
130
|
"--author-date-order",
|
|
28
131
|
"--regexp-ignore-case",
|
|
29
132
|
`--author=${opts.author}`,
|
|
30
|
-
`--pretty=format:%
|
|
133
|
+
`--pretty=format:%H${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`
|
|
31
134
|
];
|
|
32
|
-
if (opts.from)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
135
|
+
if (opts.from) {
|
|
136
|
+
args.push(`--after=${opts.from} 00:00`);
|
|
137
|
+
}
|
|
138
|
+
if (opts.to) {
|
|
139
|
+
args.push(`--before=${opts.to} 23:59`);
|
|
140
|
+
}
|
|
141
|
+
if (opts.limit !== void 0) {
|
|
142
|
+
args.push(`--max-count=${opts.limit}`);
|
|
143
|
+
}
|
|
144
|
+
let stdout;
|
|
145
|
+
try {
|
|
146
|
+
const result = await execFileAsync("git", args, {
|
|
147
|
+
cwd: opts.cwd,
|
|
148
|
+
maxBuffer: 32 * 1024 * 1024
|
|
149
|
+
});
|
|
150
|
+
stdout = result.stdout;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const stderr = String(err.stderr ?? "");
|
|
153
|
+
if (stderr.includes("does not have any commits") || stderr.includes("bad default revision") || stderr.includes("unknown revision")) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
const parsed = stdout.split(RECORD_SEP).map((r) => r.trim()).filter((r) => r.length > 0).map((record) => {
|
|
159
|
+
const [sha = "", iso = "", subject = ""] = record.split(FIELD_SEP);
|
|
160
|
+
const { date, time } = formatLocalDateTime(iso);
|
|
40
161
|
return {
|
|
162
|
+
sha,
|
|
41
163
|
date,
|
|
42
|
-
|
|
164
|
+
time,
|
|
165
|
+
ticket: extractTicket(subject, opts.pattern),
|
|
43
166
|
description: subject
|
|
44
167
|
};
|
|
45
168
|
});
|
|
169
|
+
const branchMap = await getBranchMap(
|
|
170
|
+
opts.cwd,
|
|
171
|
+
parsed.map((p) => p.sha).filter((s) => s.length > 0)
|
|
172
|
+
);
|
|
173
|
+
return parsed.map((p) => ({
|
|
174
|
+
date: p.date,
|
|
175
|
+
time: p.time,
|
|
176
|
+
ticket: p.ticket,
|
|
177
|
+
description: p.description,
|
|
178
|
+
branch: branchMap.get(p.sha) ?? null
|
|
179
|
+
}));
|
|
46
180
|
}
|
|
47
181
|
|
|
48
182
|
// src/format.ts
|
|
49
183
|
import Table from "cli-table3";
|
|
50
184
|
import chalk from "chalk";
|
|
51
|
-
function
|
|
185
|
+
function renderDateCell(entry) {
|
|
186
|
+
if (!entry.time) {
|
|
187
|
+
return entry.date;
|
|
188
|
+
}
|
|
189
|
+
return `${entry.date} ${chalk.dim(entry.time)}`;
|
|
190
|
+
}
|
|
191
|
+
function renderDescriptionCell(entry) {
|
|
192
|
+
if (!entry.branch) {
|
|
193
|
+
return entry.description;
|
|
194
|
+
}
|
|
195
|
+
return `${entry.description} ${chalk.magenta(`[${entry.branch}]`)}`;
|
|
196
|
+
}
|
|
197
|
+
function renderTable(entries, ticketColumnLabel = "Ticket") {
|
|
52
198
|
const table = new Table({
|
|
53
199
|
head: [
|
|
54
200
|
chalk.bold.cyan("Date"),
|
|
55
|
-
chalk.bold.cyan(
|
|
201
|
+
chalk.bold.cyan(ticketColumnLabel),
|
|
202
|
+
chalk.bold.cyan("Description")
|
|
203
|
+
],
|
|
204
|
+
style: { head: [], border: [] },
|
|
205
|
+
wordWrap: true,
|
|
206
|
+
colWidths: [18, 14, 80]
|
|
207
|
+
});
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
table.push([
|
|
210
|
+
renderDateCell(entry),
|
|
211
|
+
entry.ticket ? chalk.yellow(entry.ticket) : chalk.gray("\u2014"),
|
|
212
|
+
renderDescriptionCell(entry)
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
return table.toString();
|
|
216
|
+
}
|
|
217
|
+
function renderTableGroupedByDay(entries, ticketColumnLabel = "Ticket") {
|
|
218
|
+
const table = new Table({
|
|
219
|
+
head: [
|
|
220
|
+
chalk.bold.cyan("Time"),
|
|
221
|
+
chalk.bold.cyan(ticketColumnLabel),
|
|
56
222
|
chalk.bold.cyan("Description")
|
|
57
223
|
],
|
|
58
224
|
style: { head: [], border: [] },
|
|
59
225
|
wordWrap: true,
|
|
60
|
-
colWidths: [
|
|
226
|
+
colWidths: [8, 14, 90]
|
|
61
227
|
});
|
|
228
|
+
let currentDate = "";
|
|
62
229
|
for (const entry of entries) {
|
|
230
|
+
if (entry.date && entry.date !== currentDate) {
|
|
231
|
+
table.push([
|
|
232
|
+
{
|
|
233
|
+
content: chalk.bold(entry.date),
|
|
234
|
+
colSpan: 3,
|
|
235
|
+
hAlign: "left"
|
|
236
|
+
}
|
|
237
|
+
]);
|
|
238
|
+
currentDate = entry.date;
|
|
239
|
+
}
|
|
63
240
|
table.push([
|
|
64
|
-
chalk.dim(entry.
|
|
241
|
+
entry.time ? chalk.dim(entry.time) : chalk.gray("\u2014"),
|
|
65
242
|
entry.ticket ? chalk.yellow(entry.ticket) : chalk.gray("\u2014"),
|
|
66
|
-
entry
|
|
243
|
+
renderDescriptionCell(entry)
|
|
67
244
|
]);
|
|
68
245
|
}
|
|
69
246
|
return table.toString();
|
|
70
247
|
}
|
|
248
|
+
function toJsonKey(label) {
|
|
249
|
+
return label.toLowerCase().replace(/\s+/g, "_");
|
|
250
|
+
}
|
|
251
|
+
function renderJson(entries, ticketColumnLabel = "Ticket") {
|
|
252
|
+
const key = toJsonKey(ticketColumnLabel);
|
|
253
|
+
const transformed = entries.map((e) => ({
|
|
254
|
+
date: e.date,
|
|
255
|
+
time: e.time,
|
|
256
|
+
[key]: e.ticket,
|
|
257
|
+
description: e.description,
|
|
258
|
+
branch: e.branch
|
|
259
|
+
}));
|
|
260
|
+
return JSON.stringify(transformed, null, 2);
|
|
261
|
+
}
|
|
71
262
|
function renderEmpty() {
|
|
72
263
|
return chalk.gray("No commits found for the given filters.");
|
|
73
264
|
}
|
|
@@ -75,7 +266,165 @@ function renderError(message) {
|
|
|
75
266
|
return chalk.red(`error: ${message}`);
|
|
76
267
|
}
|
|
77
268
|
|
|
269
|
+
// src/config.ts
|
|
270
|
+
import { homedir } from "os";
|
|
271
|
+
import { readFile } from "fs/promises";
|
|
272
|
+
import { join } from "path";
|
|
273
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
274
|
+
var DEFAULT_COLUMN_LABELS = {
|
|
275
|
+
jira: "Ticket",
|
|
276
|
+
github: "Issue",
|
|
277
|
+
conventional: "Type",
|
|
278
|
+
custom: "Match"
|
|
279
|
+
};
|
|
280
|
+
function getColumnLabel(format, override) {
|
|
281
|
+
return override ?? DEFAULT_COLUMN_LABELS[format];
|
|
282
|
+
}
|
|
283
|
+
var PRESET_PATTERNS = {
|
|
284
|
+
jira: /\b([A-Z][A-Z0-9]+-\d+)\b/,
|
|
285
|
+
github: /#(\d+)/,
|
|
286
|
+
conventional: /^(\w+(?:\([^)]+\))?!?):/
|
|
287
|
+
};
|
|
288
|
+
var VALID_FORMATS = [
|
|
289
|
+
"jira",
|
|
290
|
+
"github",
|
|
291
|
+
"conventional",
|
|
292
|
+
"custom"
|
|
293
|
+
];
|
|
294
|
+
var MAX_CUSTOM_PATTERN_LENGTH = 500;
|
|
295
|
+
function compileUserRegex(pattern) {
|
|
296
|
+
if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`customPattern is ${pattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
return new RegExp(pattern);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`customPattern is not a valid regex: ${err.message}`,
|
|
306
|
+
{ cause: err }
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function getTicketPattern(format, customPattern) {
|
|
311
|
+
if (format === "custom") {
|
|
312
|
+
if (!customPattern) {
|
|
313
|
+
throw new Error('format "custom" requires customPattern to be set');
|
|
314
|
+
}
|
|
315
|
+
return compileUserRegex(customPattern);
|
|
316
|
+
}
|
|
317
|
+
return PRESET_PATTERNS[format];
|
|
318
|
+
}
|
|
319
|
+
function expandPath(p) {
|
|
320
|
+
if (p === "~") {
|
|
321
|
+
return homedir();
|
|
322
|
+
}
|
|
323
|
+
if (p.startsWith("~/")) {
|
|
324
|
+
return join(homedir(), p.slice(2));
|
|
325
|
+
}
|
|
326
|
+
return p;
|
|
327
|
+
}
|
|
328
|
+
function validateConfig(raw) {
|
|
329
|
+
if (typeof raw !== "object" || raw === null) {
|
|
330
|
+
throw new Error("config must be an object");
|
|
331
|
+
}
|
|
332
|
+
const obj = raw;
|
|
333
|
+
const cfg = {};
|
|
334
|
+
if ("format" in obj) {
|
|
335
|
+
if (typeof obj.format !== "string" || !VALID_FORMATS.includes(obj.format)) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`invalid format "${String(obj.format)}" \u2014 must be one of ${VALID_FORMATS.join(", ")}`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
cfg.format = obj.format;
|
|
341
|
+
}
|
|
342
|
+
if ("customPattern" in obj) {
|
|
343
|
+
if (typeof obj.customPattern !== "string") {
|
|
344
|
+
throw new Error("customPattern must be a string");
|
|
345
|
+
}
|
|
346
|
+
if (obj.customPattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`customPattern is ${obj.customPattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
cfg.customPattern = obj.customPattern;
|
|
352
|
+
}
|
|
353
|
+
if (cfg.format === "custom" && !cfg.customPattern) {
|
|
354
|
+
throw new Error('format "custom" requires customPattern to be set');
|
|
355
|
+
}
|
|
356
|
+
if ("defaultAuthor" in obj) {
|
|
357
|
+
if (typeof obj.defaultAuthor !== "string") {
|
|
358
|
+
throw new Error("defaultAuthor must be a string");
|
|
359
|
+
}
|
|
360
|
+
cfg.defaultAuthor = obj.defaultAuthor;
|
|
361
|
+
}
|
|
362
|
+
if ("defaultRepos" in obj) {
|
|
363
|
+
if (!Array.isArray(obj.defaultRepos) || !obj.defaultRepos.every((r) => typeof r === "string")) {
|
|
364
|
+
throw new Error("defaultRepos must be an array of strings");
|
|
365
|
+
}
|
|
366
|
+
cfg.defaultRepos = obj.defaultRepos;
|
|
367
|
+
}
|
|
368
|
+
if ("ticketColumnLabel" in obj) {
|
|
369
|
+
if (typeof obj.ticketColumnLabel !== "string") {
|
|
370
|
+
throw new Error("ticketColumnLabel must be a string");
|
|
371
|
+
}
|
|
372
|
+
cfg.ticketColumnLabel = obj.ticketColumnLabel;
|
|
373
|
+
}
|
|
374
|
+
return cfg;
|
|
375
|
+
}
|
|
376
|
+
function globalConfigPath() {
|
|
377
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
378
|
+
return join(xdgConfigHome, "wdid", "config.json");
|
|
379
|
+
}
|
|
380
|
+
async function loadGlobalConfig() {
|
|
381
|
+
try {
|
|
382
|
+
const content = await readFile(globalConfigPath(), "utf-8");
|
|
383
|
+
return validateConfig(JSON.parse(content));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err.code === "ENOENT") {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function loadConfig(cwd) {
|
|
392
|
+
const explorer = cosmiconfig("wdid");
|
|
393
|
+
const local = await explorer.search(cwd);
|
|
394
|
+
if (local?.config) {
|
|
395
|
+
return validateConfig(local.config);
|
|
396
|
+
}
|
|
397
|
+
const global = await loadGlobalConfig();
|
|
398
|
+
if (global) {
|
|
399
|
+
return global;
|
|
400
|
+
}
|
|
401
|
+
return {};
|
|
402
|
+
}
|
|
403
|
+
|
|
78
404
|
// src/index.ts
|
|
405
|
+
function parseLimit(raw) {
|
|
406
|
+
if (raw === void 0) {
|
|
407
|
+
return void 0;
|
|
408
|
+
}
|
|
409
|
+
const n = Number.parseInt(raw, 10);
|
|
410
|
+
if (!Number.isInteger(n) || n < 1 || String(n) !== raw.trim()) {
|
|
411
|
+
throw new Error(`invalid --limit "${raw}" \u2014 must be a positive integer`);
|
|
412
|
+
}
|
|
413
|
+
return n;
|
|
414
|
+
}
|
|
415
|
+
function shouldDisableColor(options) {
|
|
416
|
+
if (options.color === false) {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
const noColor = process.env.NO_COLOR ?? "";
|
|
420
|
+
return noColor.length > 0;
|
|
421
|
+
}
|
|
422
|
+
var VALID_PRESETS = [
|
|
423
|
+
"jira",
|
|
424
|
+
"github",
|
|
425
|
+
"conventional",
|
|
426
|
+
"custom"
|
|
427
|
+
];
|
|
79
428
|
function isIsoDate(value) {
|
|
80
429
|
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
81
430
|
}
|
|
@@ -89,7 +438,27 @@ function resolveDate(input) {
|
|
|
89
438
|
return input;
|
|
90
439
|
}
|
|
91
440
|
async function run(dateArg, options) {
|
|
92
|
-
const
|
|
441
|
+
const config = await loadConfig(process.cwd());
|
|
442
|
+
let format;
|
|
443
|
+
let customPattern;
|
|
444
|
+
if (options.ticketPattern) {
|
|
445
|
+
format = "custom";
|
|
446
|
+
customPattern = options.ticketPattern;
|
|
447
|
+
} else if (options.format) {
|
|
448
|
+
if (!VALID_PRESETS.includes(options.format)) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`invalid --format "${options.format}" \u2014 must be one of ${VALID_PRESETS.join(", ")}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
format = options.format;
|
|
454
|
+
customPattern = config.customPattern;
|
|
455
|
+
} else {
|
|
456
|
+
format = config.format ?? "jira";
|
|
457
|
+
customPattern = config.customPattern;
|
|
458
|
+
}
|
|
459
|
+
const pattern = getTicketPattern(format, customPattern);
|
|
460
|
+
const configRepos = config.defaultRepos?.map(expandPath) ?? [];
|
|
461
|
+
const repos = options.repo && options.repo.length > 0 ? options.repo : configRepos.length > 0 ? configRepos : [process.cwd()];
|
|
93
462
|
let from = options.from ? resolveDate(options.from) : void 0;
|
|
94
463
|
let to = options.to ? resolveDate(options.to) : void 0;
|
|
95
464
|
if (dateArg) {
|
|
@@ -97,27 +466,56 @@ async function run(dateArg, options) {
|
|
|
97
466
|
from = day;
|
|
98
467
|
to = day;
|
|
99
468
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
469
|
+
const limit = parseLimit(options.limit);
|
|
470
|
+
const perRepoEntries = await Promise.all(
|
|
471
|
+
repos.map(async (cwd) => {
|
|
472
|
+
const author = options.author ?? config.defaultAuthor ?? await getGitUserName(cwd);
|
|
473
|
+
return getCommits({ author, from, to, cwd, pattern, limit });
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
const allEntries = perRepoEntries.flat();
|
|
477
|
+
allEntries.sort(
|
|
478
|
+
(a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
|
|
479
|
+
);
|
|
480
|
+
const display = limit !== void 0 ? allEntries.slice(0, limit) : allEntries;
|
|
481
|
+
const ticketColumnLabel = getColumnLabel(format, config.ticketColumnLabel);
|
|
482
|
+
if (options.json) {
|
|
483
|
+
process.stdout.write(renderJson(display, ticketColumnLabel) + "\n");
|
|
484
|
+
return;
|
|
105
485
|
}
|
|
106
|
-
|
|
107
|
-
if (allEntries.length === 0) {
|
|
486
|
+
if (display.length === 0) {
|
|
108
487
|
process.stdout.write(renderEmpty() + "\n");
|
|
109
488
|
return;
|
|
110
489
|
}
|
|
111
|
-
|
|
490
|
+
const rendered = options.groupByDay ? renderTableGroupedByDay(display, ticketColumnLabel) : renderTable(display, ticketColumnLabel);
|
|
491
|
+
process.stdout.write(rendered + "\n");
|
|
112
492
|
}
|
|
113
493
|
var program = new Command();
|
|
114
|
-
program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").argument("[date]", 'a YYYY-MM-DD date or "today"; omit to show all history').option("--from <date>", 'start date (YYYY-MM-DD or "today")').option("--to <date>", 'end date (YYYY-MM-DD or "today")').option(
|
|
494
|
+
program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.2.0", "-V, --version", "output the version number").argument("[date]", 'a YYYY-MM-DD date or "today"; omit to show all history').option("--from <date>", 'start date (YYYY-MM-DD or "today")').option("--to <date>", 'end date (YYYY-MM-DD or "today")').option(
|
|
115
495
|
"--author <name>",
|
|
116
|
-
"override the git author (defaults to git config user.name)"
|
|
496
|
+
"override the git author (defaults to git config user.name, then defaultAuthor in config)"
|
|
117
497
|
).option(
|
|
118
498
|
"--repo <path...>",
|
|
119
|
-
"one or more repo paths to query (defaults to current directory)"
|
|
120
|
-
).
|
|
499
|
+
"one or more repo paths to query (overrides defaultRepos in config; defaults to current directory)"
|
|
500
|
+
).option(
|
|
501
|
+
"--format <preset>",
|
|
502
|
+
"ticket format: jira | github | conventional | custom (default: jira, or config.format)"
|
|
503
|
+
).option(
|
|
504
|
+
"--ticket-pattern <regex>",
|
|
505
|
+
"custom regex for ticket extraction (implies --format custom; overrides --format)"
|
|
506
|
+
).option(
|
|
507
|
+
"--no-color",
|
|
508
|
+
"disable colored output (also honored via the NO_COLOR env var)"
|
|
509
|
+
).option(
|
|
510
|
+
"--limit <N>",
|
|
511
|
+
"cap the table to the most recent N rows (positive integer)"
|
|
512
|
+
).option(
|
|
513
|
+
"--group-by-day",
|
|
514
|
+
"group rows under a bold date heading per day (time-only in row)"
|
|
515
|
+
).option("--json", "emit a JSON array of commit entries instead of the table").action(async (dateArg, options) => {
|
|
516
|
+
if (shouldDisableColor(options)) {
|
|
517
|
+
chalk2.level = 0;
|
|
518
|
+
}
|
|
121
519
|
try {
|
|
122
520
|
await run(dateArg, options);
|
|
123
521
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drazenbebic/wdid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "What did I do? — summarize your git activity per day as a tidy table, grouped by JIRA ticket.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"author": "Your Name <you@example.com>",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/
|
|
18
|
+
"url": "git+https://github.com/drazenbebic/wdid.git"
|
|
19
19
|
},
|
|
20
20
|
"bugs": {
|
|
21
|
-
"url": "https://github.com/
|
|
21
|
+
"url": "https://github.com/drazenbebic/wdid/issues"
|
|
22
22
|
},
|
|
23
|
-
"homepage": "https://github.com/
|
|
23
|
+
"homepage": "https://github.com/drazenbebic/wdid#readme",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"main": "dist/index.js",
|
|
26
26
|
"bin": {
|
|
@@ -46,22 +46,36 @@
|
|
|
46
46
|
"format:check": "prettier --check .",
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:watch": "vitest",
|
|
49
|
+
"prepare": "husky",
|
|
49
50
|
"prepublishOnly": "pnpm run build"
|
|
50
51
|
},
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|
|
53
54
|
},
|
|
55
|
+
"lint-staged": {
|
|
56
|
+
"*.{ts,tsx,js,cjs,mjs}": [
|
|
57
|
+
"eslint --fix",
|
|
58
|
+
"prettier --write"
|
|
59
|
+
],
|
|
60
|
+
"*.{json,md,yml,yaml}": [
|
|
61
|
+
"prettier --write"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
54
64
|
"dependencies": {
|
|
55
65
|
"chalk": "^5.6.2",
|
|
56
66
|
"cli-table3": "^0.6.5",
|
|
57
|
-
"commander": "^14.0.3"
|
|
67
|
+
"commander": "^14.0.3",
|
|
68
|
+
"cosmiconfig": "^9.0.1"
|
|
58
69
|
},
|
|
59
70
|
"devDependencies": {
|
|
60
71
|
"@eslint/js": "^10.0.1",
|
|
72
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
61
73
|
"@types/node": "^20.19.41",
|
|
62
74
|
"eslint": "^10.4.0",
|
|
63
75
|
"eslint-config-prettier": "^10.1.8",
|
|
64
76
|
"globals": "^17.6.0",
|
|
77
|
+
"husky": "^9.1.7",
|
|
78
|
+
"lint-staged": "^17.0.5",
|
|
65
79
|
"prettier": "^3.8.3",
|
|
66
80
|
"tsup": "^8.5.1",
|
|
67
81
|
"typescript": "^6.0.3",
|