@tertium/gith 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 +4 -0
  2. package/bin/gith.js +150 -41
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -70,6 +70,7 @@ gith remove --obsolete # remove branches untouched for 90 days, on
70
70
  gith remove --obsolete --local # the same, here only
71
71
  gith remove --obsolete -days 30 # untouched for 30 days instead
72
72
  gith remove --detached # pick local branches that have no origin copy
73
+ gith remove --detached --all # remove all of them: lists them, then asks Y/n
73
74
 
74
75
  # Look at the repo
75
76
  gith status # branch vs origin, and staged, unstaged and untracked counts
@@ -92,6 +93,8 @@ gith stat --last-week -limit 50 # the same for last week, looking at no more
92
93
  gith sync # fetch all remotes and tags, prune stale refs
93
94
  gith open # open origin in the browser
94
95
  gith doctor # check the repo is ready for the branch flow
96
+ gith changelog --dry-run # preview the CHANGELOG.md entry for package.json's version
97
+ gith changelog # write it as its own commit (docs(changelog): <version>); --no-commit only writes
95
98
 
96
99
  # Help
97
100
  gith help # full usage
@@ -155,6 +158,7 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
155
158
  - **`stat`** reads the history of the current branch and prints a table of facts: commit count, top author, the biggest commit (lines added and removed), average size, the most changed file, the busiest day, weekday and hour, the longest streak of days with a commit, night owl and weekend commits, and the conventional commit types. The period is one switch: `--overall` (the default), `--current-year`, `--current-month`, `--current-week` (weeks start on Monday), or the matching `--last-` switch for the previous year, month or week. `-limit <n>` looks only at the last `n` commits. Stat changes nothing.
156
159
  - **`unstash`** runs `git stash pop`. If it conflicts, git keeps the stash in the list.
157
160
  - **`remove --obsolete`** fetches and prunes origin, then lists the branches untouched for `-days` (default 90): no commit newer than the cutoff on either side. Each line shows the sides and the last commit date, and the command asks `Remove these N branches here and on origin? [Y/n]`. Enter means yes. `--local` looks at and removes only local branches. `-limit` shows only the first `n`, and the prompt covers only those. `main`, `develop`, `release/*` and `hotfix/*` are never removed, and neither is the checked-out branch. Each branch is deleted locally first (`git branch -d`), so a branch that git does not see as merged stops the run before its origin copy is touched.
161
+ - **`remove --detached --all`** fetches and prunes origin, lists every local branch with no origin copy, and asks `Remove these N branches? [Y/n]` once. Nothing is deleted without the yes, and `--all` can't be combined with `-limit`.
158
162
  - **`remove --detached`** fetches and prunes origin, then lists local branches that have no origin copy and lets you pick from them, the same as `remove --branch --local` but for branches with no origin copy only.
159
163
  - **Every removal shows what it will remove** before deleting anything, including the direct `remove -b` form. The picker and the prompt both show it first.
160
164
  - **`open`** opens origin in the browser, turning an `ssh` or `https` remote into a web address. GitHub and GitLab style addresses work.
package/bin/gith.js CHANGED
@@ -30,6 +30,8 @@ var SPECS = new Map([
30
30
  ["current-hotfix", "switch"],
31
31
  ["pockets", "switch"],
32
32
  ["detached", "switch"],
33
+ ["all", "switch"],
34
+ ["no-commit", "switch"],
33
35
  ["overall", "switch"],
34
36
  ["current-year", "switch"],
35
37
  ["current-month", "switch"],
@@ -118,6 +120,10 @@ function onlyOne(flags, names) {
118
120
  return present[0];
119
121
  }
120
122
 
123
+ // src/components/changelog/changelog.ts
124
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
125
+ import path from "node:path";
126
+
121
127
  // src/components/git/git.ts
122
128
  import { execFileSync } from "node:child_process";
123
129
  var verbose = false;
@@ -215,6 +221,92 @@ function act(args, options = {}) {
215
221
  execFileSync("git", args, { stdio: "inherit", ...options });
216
222
  }
217
223
 
224
+ // src/components/changelog/changelog.constants.ts
225
+ var CHANGELOG_FILE_NAME = "CHANGELOG.md";
226
+ var CHANGELOG_TITLE = "# Changelog";
227
+ var SEMVER_ONLY_PATTERN = /^\d+\.\d+\.\d+$/;
228
+
229
+ // src/components/changelog/changelog.utils.ts
230
+ function filterCommitSubjects(rawLog) {
231
+ if (!rawLog.trim())
232
+ return [];
233
+ return rawLog.split(`
234
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && !SEMVER_ONLY_PATTERN.test(line));
235
+ }
236
+ function buildEntry(notes) {
237
+ const bullets = notes.subjects.length > 0 ? notes.subjects.map((subject) => `- ${subject}`).join(`
238
+ `) : "- (no changes recorded)";
239
+ return `## ${notes.version} - ${notes.date}
240
+
241
+ ${bullets}
242
+ `;
243
+ }
244
+ function mergeChangelogContent(existing, entry) {
245
+ const heading = `${CHANGELOG_TITLE}
246
+ `;
247
+ if (existing === null)
248
+ return `${heading}
249
+ ${entry}
250
+ `;
251
+ const body = existing.startsWith(heading) ? existing.slice(heading.length).replace(/^\n+/, "") : existing;
252
+ return `${heading}
253
+ ${entry}
254
+ ${body}`;
255
+ }
256
+ function parsePackageVersion(rawPackageJson) {
257
+ const pkg = JSON.parse(rawPackageJson);
258
+ if (!pkg.version)
259
+ throw new Error('No "version" field found in package.json');
260
+ return pkg.version;
261
+ }
262
+ function todayIso(date = new Date) {
263
+ return date.toISOString().slice(0, 10);
264
+ }
265
+
266
+ // src/components/changelog/changelog.ts
267
+ function previousTag() {
268
+ try {
269
+ return captureArgs(["describe", "--tags", "--abbrev=0"]);
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ function commitSubjectsSince(tag) {
275
+ const range = tag ? `${tag}..HEAD` : "HEAD";
276
+ return filterCommitSubjects(captureArgs(["log", range, "--no-merges", "--pretty=format:%s"]));
277
+ }
278
+ function changelog(flags) {
279
+ const noCommit = flags.has("no-commit");
280
+ if (flags.size > (noCommit ? 1 : 0))
281
+ throw new Error("usage: gith changelog [--dry-run] [--no-commit]");
282
+ const root = process.cwd();
283
+ const version = parsePackageVersion(readFileSync(path.join(root, "package.json"), "utf8"));
284
+ const entry = buildEntry({
285
+ version,
286
+ date: todayIso(),
287
+ subjects: commitSubjectsSince(previousTag())
288
+ });
289
+ if (isDryRun()) {
290
+ console.log(entry);
291
+ console.log(`dry run: would update ${CHANGELOG_FILE_NAME}`);
292
+ return;
293
+ }
294
+ const file = path.join(root, CHANGELOG_FILE_NAME);
295
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : null;
296
+ writeFileSync(file, mergeChangelogContent(existing, entry));
297
+ console.log(`✅ ${CHANGELOG_FILE_NAME} updated for ${version}`);
298
+ if (noCommit)
299
+ return;
300
+ gitArgs(["add", "--", CHANGELOG_FILE_NAME]);
301
+ gitArgs([
302
+ "commit",
303
+ "-m",
304
+ `docs(changelog): ${version}`,
305
+ "--",
306
+ CHANGELOG_FILE_NAME
307
+ ]);
308
+ }
309
+
218
310
  // src/components/doctor/doctor.utils.ts
219
311
  function check(name, test, failure = "fail") {
220
312
  return {
@@ -945,8 +1037,10 @@ async function remove(flags) {
945
1037
  throw new Error("use only one of: --obsolete, --detached.");
946
1038
  if (flags.has("days") && !obsoleteOn)
947
1039
  throw new Error("-days only applies to --obsolete.");
1040
+ if (flags.has("all") && !detachedOn)
1041
+ throw new Error("--all only applies to --detached.");
948
1042
  if (obsoleteOn || detachedOn) {
949
- const usage = detachedOn ? "usage: gith remove --detached [-l <n>]" : "usage: gith remove --obsolete [--local] [-days <n>] [-l <n>]";
1043
+ const usage = detachedOn ? "usage: gith remove --detached [--all | -l <n>]" : "usage: gith remove --obsolete [--local] [-days <n>] [-l <n>]";
950
1044
  if (name !== undefined || obsoleteOn && side === "remote" || detachedOn && side)
951
1045
  throw new Error(usage);
952
1046
  if (detachedOn)
@@ -1038,7 +1132,7 @@ function pick(scope, limit) {
1038
1132
  const remote = scope === "local" ? [] : remoteBranches();
1039
1133
  return choose(candidates(scope, localBranches(), remote, currentBranch2()), limit);
1040
1134
  }
1041
- async function removeBranches(scope, names, ask2 = true) {
1135
+ async function removeBranches(scope, names, ask = true) {
1042
1136
  const plan = names.map((name) => {
1043
1137
  if (scope === "remote")
1044
1138
  return { name, local: false, origin: true };
@@ -1053,7 +1147,7 @@ async function removeBranches(scope, names, ask2 = true) {
1053
1147
  });
1054
1148
  if (plan.length > 0)
1055
1149
  console.log("Will remove:");
1056
- if (ask2 && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1150
+ if (ask && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1057
1151
  console.log("Nothing removed.");
1058
1152
  return;
1059
1153
  }
@@ -1103,9 +1197,19 @@ async function obsolete(flags, scope) {
1103
1197
  await removeBranches(scope, shown.map((option) => option.name), false);
1104
1198
  }
1105
1199
  async function detached(flags) {
1106
- requireTerminal();
1107
1200
  gitArgs(["fetch", "--prune", "origin"]);
1108
1201
  const options = detachedOptions(readTimes(), currentBranch2());
1202
+ if (flags.has("all")) {
1203
+ if (flags.has("limit"))
1204
+ throw new Error("use only one of: --all, -limit.");
1205
+ if (options.length === 0) {
1206
+ console.log("No detached branches to remove.");
1207
+ return;
1208
+ }
1209
+ await removeBranches("local", options.map((option) => option.name));
1210
+ return;
1211
+ }
1212
+ requireTerminal();
1109
1213
  await removeBranches("local", await choose(options, parseLimit(flags)));
1110
1214
  }
1111
1215
  function switchToCommit(ref) {
@@ -1264,8 +1368,8 @@ function startOfWeek(date) {
1264
1368
  }
1265
1369
  function dayKey(date) {
1266
1370
  const month = String(date.getMonth() + 1).padStart(2, "0");
1267
- const day2 = String(date.getDate()).padStart(2, "0");
1268
- return `${date.getFullYear()}-${month}-${day2}`;
1371
+ const day = String(date.getDate()).padStart(2, "0");
1372
+ return `${date.getFullYear()}-${month}-${day}`;
1269
1373
  }
1270
1374
  function top(counts) {
1271
1375
  let best;
@@ -1347,57 +1451,57 @@ function parseLog(output) {
1347
1451
  function longestStreak(dates) {
1348
1452
  const days = [...new Set(dates.map(dayKey))].sort();
1349
1453
  let best = 0;
1350
- let run2 = 0;
1454
+ let run = 0;
1351
1455
  let previous = -Infinity;
1352
1456
  for (const key of days) {
1353
1457
  const [y = 0, m = 1, d = 1] = key.split("-").map(Number);
1354
1458
  const ms = Date.UTC(y, m - 1, d) / 86400000;
1355
- run2 = ms - previous === 1 ? run2 + 1 : 1;
1356
- best = Math.max(best, run2);
1459
+ run = ms - previous === 1 ? run + 1 : 1;
1460
+ best = Math.max(best, run);
1357
1461
  previous = ms;
1358
1462
  }
1359
1463
  return best;
1360
1464
  }
1361
- function summarize(commits2) {
1362
- if (commits2.length === 0)
1465
+ function summarize(commits) {
1466
+ if (commits.length === 0)
1363
1467
  return [];
1364
1468
  const rows = [];
1365
1469
  const add = (stat, value, detail = "") => rows.push({ stat, value, detail });
1366
- const authors = count(commits2.map((c) => c.author));
1470
+ const authors = count(commits.map((c) => c.author));
1367
1471
  const topAuthor = top(authors);
1368
- const oldest = commits2.reduce((a, b) => a.date <= b.date ? a : b);
1369
- const newest = commits2.reduce((a, b) => a.date >= b.date ? a : b);
1370
- const added = commits2.reduce((sum, c) => sum + c.added, 0);
1371
- const deleted = commits2.reduce((sum, c) => sum + c.deleted, 0);
1372
- add("Commits", String(commits2.length), `${dayKey(oldest.date)} to ${dayKey(newest.date)}`);
1472
+ const oldest = commits.reduce((a, b) => a.date <= b.date ? a : b);
1473
+ const newest = commits.reduce((a, b) => a.date >= b.date ? a : b);
1474
+ const added = commits.reduce((sum, c) => sum + c.added, 0);
1475
+ const deleted = commits.reduce((sum, c) => sum + c.deleted, 0);
1476
+ add("Commits", String(commits.length), `${dayKey(oldest.date)} to ${dayKey(newest.date)}`);
1373
1477
  add("Authors", String(authors.size));
1374
1478
  if (topAuthor)
1375
1479
  add("Top author", topAuthor[0], `${topAuthor[1]} commits`);
1376
- const biggest = commits2.reduce((a, b) => b.added + b.deleted > a.added + a.deleted ? b : a);
1480
+ const biggest = commits.reduce((a, b) => b.added + b.deleted > a.added + a.deleted ? b : a);
1377
1481
  add("Biggest commit", `+${biggest.added} -${biggest.deleted}`, `${biggest.hash.slice(0, 7)} ${biggest.subject} (${biggest.author})`);
1378
- add("Average size", `${Math.round((added + deleted) / commits2.length)} lines`, `per commit; +${added} -${deleted} in total`);
1379
- const files = count(commits2.flatMap((c) => c.files));
1482
+ add("Average size", `${Math.round((added + deleted) / commits.length)} lines`, `per commit; +${added} -${deleted} in total`);
1483
+ const files = count(commits.flatMap((c) => c.files));
1380
1484
  const topFile = top(files);
1381
1485
  add("Files touched", String(files.size), topFile ? `most changed: ${topFile[0]} (${topFile[1]} commits)` : "");
1382
- const busiestDay = top(count(commits2.map((c) => dayKey(c.date))));
1486
+ const busiestDay = top(count(commits.map((c) => dayKey(c.date))));
1383
1487
  if (busiestDay)
1384
1488
  add("Busiest day", busiestDay[0], `${busiestDay[1]} commits`);
1385
- const weekday = top(count(commits2.map((c) => DAYS[c.date.getDay()] ?? "")));
1489
+ const weekday = top(count(commits.map((c) => DAYS[c.date.getDay()] ?? "")));
1386
1490
  if (weekday)
1387
1491
  add("Busiest weekday", weekday[0], `${weekday[1]} commits`);
1388
- const hour = top(count(commits2.map((c) => String(c.date.getHours()).padStart(2, "0"))));
1492
+ const hour = top(count(commits.map((c) => String(c.date.getHours()).padStart(2, "0"))));
1389
1493
  if (hour)
1390
1494
  add("Peak hour", `${hour[0]}:00`, `${hour[1]} commits`);
1391
- const streak = longestStreak(commits2.map((c) => c.date));
1495
+ const streak = longestStreak(commits.map((c) => c.date));
1392
1496
  add("Longest streak", `${streak} ${streak === 1 ? "day" : "days"}`, "in a row with a commit");
1393
- const nightOwl = commits2.filter((c) => c.date.getHours() >= 22 || c.date.getHours() < 5).length;
1394
- const weekend = commits2.filter((c) => c.date.getDay() === 0 || c.date.getDay() === 6).length;
1497
+ const nightOwl = commits.filter((c) => c.date.getHours() >= 22 || c.date.getHours() < 5).length;
1498
+ const weekend = commits.filter((c) => c.date.getDay() === 0 || c.date.getDay() === 6).length;
1395
1499
  add("Night owl", `${nightOwl} commits`, "between 22:00 and 05:00");
1396
1500
  add("Weekend", `${weekend} commits`, "on Saturday or Sunday");
1397
- const types = count(commits2.map((c) => /^(\w+)(\([^)]*\))?!?:/.exec(c.subject)?.[1]?.toLowerCase() ?? "").filter(Boolean));
1501
+ const types = count(commits.map((c) => /^(\w+)(\([^)]*\))?!?:/.exec(c.subject)?.[1]?.toLowerCase() ?? "").filter(Boolean));
1398
1502
  if (types.size > 0) {
1399
- const list2 = [...types.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4).map(([type, n]) => `${type} ${n}`).join(", ");
1400
- add("Commit types", list2, "from conventional prefixes");
1503
+ const list = [...types.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4).map(([type, n]) => `${type} ${n}`).join(", ");
1504
+ add("Commit types", list, "from conventional prefixes");
1401
1505
  }
1402
1506
  return rows;
1403
1507
  }
@@ -1432,13 +1536,13 @@ function stat(flags) {
1432
1536
  args.push(`--until=${range.until}`);
1433
1537
  if (limit !== undefined)
1434
1538
  args.push(`-n${limit}`);
1435
- const commits2 = parseLog(captureArgs(args));
1436
- if (commits2.length === 0) {
1539
+ const commits = parseLog(captureArgs(args));
1540
+ if (commits.length === 0) {
1437
1541
  console.log(`No commits ${range.label === "overall" ? "yet" : `in ${range.label}`}.`);
1438
1542
  return;
1439
1543
  }
1440
1544
  console.log(`gith stat, ${range.label}${limit === undefined ? "" : `, last ${limit} commits`}`);
1441
- console.log(table(summarize(commits2)));
1545
+ console.log(table(summarize(commits)));
1442
1546
  }
1443
1547
 
1444
1548
  // src/components/status/status.ts
@@ -1556,7 +1660,7 @@ function tree(flags) {
1556
1660
  }
1557
1661
 
1558
1662
  // src/components/version/version.ts
1559
- import { readFileSync as readFileSync2 } from "node:fs";
1663
+ import { readFileSync as readFileSync3 } from "node:fs";
1560
1664
 
1561
1665
  // src/components/version/version.constants.ts
1562
1666
  var MASCOT = [
@@ -1571,19 +1675,19 @@ var MASCOT = [
1571
1675
  ];
1572
1676
 
1573
1677
  // src/components/version/version.utils.ts
1574
- import { existsSync, readFileSync } from "node:fs";
1575
- import path from "node:path";
1678
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1679
+ import path2 from "node:path";
1576
1680
  import { fileURLToPath } from "node:url";
1577
1681
  function packageJsonPath() {
1578
- let dir = path.dirname(fileURLToPath(import.meta.url));
1682
+ let dir = path2.dirname(fileURLToPath(import.meta.url));
1579
1683
  for (;; ) {
1580
- const candidate = path.join(dir, "package.json");
1581
- if (existsSync(candidate)) {
1582
- const pkg = JSON.parse(readFileSync(candidate, "utf8"));
1684
+ const candidate = path2.join(dir, "package.json");
1685
+ if (existsSync2(candidate)) {
1686
+ const pkg = JSON.parse(readFileSync2(candidate, "utf8"));
1583
1687
  if (pkg.name === "@tertium/gith")
1584
1688
  return candidate;
1585
1689
  }
1586
- const parent = path.dirname(dir);
1690
+ const parent = path2.dirname(dir);
1587
1691
  if (parent === dir)
1588
1692
  throw new Error("gith package.json not found");
1589
1693
  dir = parent;
@@ -1592,7 +1696,7 @@ function packageJsonPath() {
1592
1696
 
1593
1697
  // src/components/version/version.ts
1594
1698
  function readVersion() {
1595
- const pkg = JSON.parse(readFileSync2(packageJsonPath(), "utf8"));
1699
+ const pkg = JSON.parse(readFileSync3(packageJsonPath(), "utf8"));
1596
1700
  return pkg.version ?? "unknown";
1597
1701
  }
1598
1702
  function versionText() {
@@ -1638,6 +1742,7 @@ Clean up branches (every removal shows what it will remove first)
1638
1742
  gith remove --branch --remote pick from origin branches
1639
1743
  gith remove --obsolete [--local] [-days <n>] remove branches untouched for 90 days, after Y/n
1640
1744
  gith remove --detached [-l <n>] pick local branches that have no origin copy
1745
+ gith remove --detached --all remove all of them, after listing them and a Y/n question
1641
1746
  gith rm alias of remove
1642
1747
 
1643
1748
  Look at the repo
@@ -1656,6 +1761,7 @@ Remote and health
1656
1761
  gith sync fetch all remotes and tags, prune stale refs
1657
1762
  gith open open origin in the browser
1658
1763
  gith doctor check the repo is ready for the branch flow (exit 1 on failures)
1764
+ gith changelog [--dry-run] add an entry for package.json's version to CHANGELOG.md (commits since the last tag)
1659
1765
 
1660
1766
  Help
1661
1767
  gith help show this help
@@ -1739,6 +1845,9 @@ try {
1739
1845
  case "open":
1740
1846
  open2(parseFlags(rest));
1741
1847
  break;
1848
+ case "changelog":
1849
+ changelog(parseFlags(rest));
1850
+ break;
1742
1851
  case "doctor":
1743
1852
  if (!doctor(parseFlags(rest)))
1744
1853
  exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertium/gith",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git helper: branch flow with goal branches, release and hotfix containers, and everyday git commands.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,11 @@
15
15
  "test": "bun test --no-coverage",
16
16
  "lint": "biome check src",
17
17
  "lint:fix": "biome check --write src",
18
- "typecheck": "tsc --noEmit"
18
+ "typecheck": "tsc --noEmit",
19
+ "release:patch": "node bin/gith.js release --patch",
20
+ "release:minor": "node bin/gith.js release --minor",
21
+ "release:major": "node bin/gith.js release --major",
22
+ "version": "bun run build && node bin/gith.js changelog"
19
23
  },
20
24
  "license": "ISC",
21
25
  "author": "Vitalii Balabanov",