@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.
Files changed (3) hide show
  1. package/README.md +79 -15
  2. package/dist/index.js +435 -37
  3. 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**, **Ticket** (JIRA-style `ABC-123`, parsed from the commit subject), and **Description**.
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 │ Ticket │ Description │
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 | Description |
45
- | ------------------ | --------------------------------------------------------------------- |
46
- | `[date]` | A `YYYY-MM-DD` date or the literal `today`. Omit to show all history. |
47
- | `--from <date>` | Start date (inclusive). |
48
- | `--to <date>` | End date (inclusive). |
49
- | `--author <name>` | Override the git author. Defaults to `git config user.name`. |
50
- | `--repo <path...>` | One or more repo paths to query. Defaults to the current directory. |
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 JIRA_TICKET_RE = /\b([A-Z][A-Z0-9]+-\d+)\b/;
13
- function extractTicket(message) {
14
- const match = message.match(JIRA_TICKET_RE);
15
- return match ? match[1] : null;
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
- async function getGitUserName(cwd) {
18
- const { stdout } = await execFileAsync("git", ["config", "user.name"], {
19
- cwd
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
- return stdout.trim();
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:%cs${FIELD_SEP}%s${RECORD_SEP}`
133
+ `--pretty=format:%H${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`
31
134
  ];
32
- if (opts.from) args.push(`--after=${opts.from} 00:00`);
33
- if (opts.to) args.push(`--before=${opts.to} 23:59`);
34
- const { stdout } = await execFileAsync("git", args, {
35
- cwd: opts.cwd,
36
- maxBuffer: 32 * 1024 * 1024
37
- });
38
- return stdout.split(RECORD_SEP).map((r) => r.trim()).filter((r) => r.length > 0).map((record) => {
39
- const [date = "", subject = ""] = record.split(FIELD_SEP);
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
- ticket: extractTicket(subject),
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 renderTable(entries) {
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("Ticket"),
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: [12, 14, 80]
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.date),
241
+ entry.time ? chalk.dim(entry.time) : chalk.gray("\u2014"),
65
242
  entry.ticket ? chalk.yellow(entry.ticket) : chalk.gray("\u2014"),
66
- entry.description
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 repos = options.repo && options.repo.length > 0 ? options.repo : [process.cwd()];
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 allEntries = [];
101
- for (const cwd of repos) {
102
- const author = options.author ?? await getGitUserName(cwd);
103
- const entries = await getCommits({ author, from, to, cwd });
104
- allEntries.push(...entries);
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
- allEntries.sort((a, b) => b.date.localeCompare(a.date));
107
- if (allEntries.length === 0) {
486
+ if (display.length === 0) {
108
487
  process.stdout.write(renderEmpty() + "\n");
109
488
  return;
110
489
  }
111
- process.stdout.write(renderTable(allEntries) + "\n");
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
- ).action(async (dateArg, options) => {
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.1.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/your-org/wdid.git"
18
+ "url": "git+https://github.com/drazenbebic/wdid.git"
19
19
  },
20
20
  "bugs": {
21
- "url": "https://github.com/your-org/wdid/issues"
21
+ "url": "https://github.com/drazenbebic/wdid/issues"
22
22
  },
23
- "homepage": "https://github.com/your-org/wdid#readme",
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",