@tertium/gith 0.1.1 → 0.3.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 +12 -1
  2. package/bin/gith.js +409 -87
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -35,10 +35,16 @@ gith create -r 1.2.0 # release/1.2.0 from develop
35
35
  gith create -h 1.2.1 # hotfix/1.2.1 from main
36
36
  gith create --release # release, version = minor after the latest tag
37
37
  gith create --hotfix # hotfix, version = patch after the latest tag
38
+ gith switch # list local and origin branches, and switch to the one you pick
38
39
  gith switch -b login-redirect # switch to the branch, creating it from develop if it is missing
39
40
  gith switch -commit 3f2a1b9 # detach HEAD at that commit, to look at old code
41
+ gith switch --release # switch to the newest release/* (local or origin); -r 1.9.0 for one version
42
+ gith switch --hotfix # switch to the newest hotfix/* (local or origin); -h 1.2.1 for one version
40
43
  gith update # merge origin/develop into this work branch
41
44
  gith merge # this branch into develop
45
+ gith finish --current-release # release the newest release/*: version, changelog, merge to main, tag, merge main to develop, then ask before deleting the branch
46
+ gith finish --current-hotfix # the same for the newest hotfix/*
47
+ gith merge # pick the branch to merge, then the target from the list
42
48
  gith merge -r 1.2.0 # this branch into release/1.2.0
43
49
  gith merge -h 1.2.1 # this branch into hotfix/1.2.1
44
50
  gith merge --current-release # this branch into the newest release/* (local or origin)
@@ -59,6 +65,7 @@ gith reset # discard uncommitted changes, then clean
59
65
  gith clean # remove untracked files and directories
60
66
 
61
67
  # Clean up branches (every removal shows what it will remove first)
68
+ gith remove # pick branches to delete from the list (here and on origin)
62
69
  gith remove -b login-redirect # delete the branch here and on origin (rm is an alias)
63
70
  gith remove -b login-redirect --local # delete only the local branch
64
71
  gith remove -b login-redirect --remote # delete only the origin branch
@@ -93,6 +100,8 @@ gith stat --last-week -limit 50 # the same for last week, looking at no more
93
100
  gith sync # fetch all remotes and tags, prune stale refs
94
101
  gith open # open origin in the browser
95
102
  gith doctor # check the repo is ready for the branch flow
103
+ gith changelog --dry-run # preview the CHANGELOG.md entry for package.json's version
104
+ gith changelog # write it as its own commit (docs(changelog): <version>); --no-commit only writes
96
105
 
97
106
  # Help
98
107
  gith help # full usage
@@ -139,7 +148,7 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
139
148
  - **`switch -b`** switches to a branch. If the branch exists neither locally nor on origin, it is created first, the same way as `create -b`, and then checked out.
140
149
  - **`switch -commit <sha>`** detaches HEAD at that commit so you can look at old code. It refuses a commit that does not exist, and it refuses while tracked files have changes (untracked files do not block it). It lists the files that block it, and points to `gith stash`. On a detached HEAD, `gith switch -b <name>` creates the branch at the current commit, so commits made there stay on it. It does not create or switch branches for other commits, so commits made on a detached HEAD are easy to lose: name a branch before you leave.
141
150
  - **`remove -b <branch>`** removes the branch on both sides: the local branch first (with `git branch -d`, so git refuses to delete an unmerged branch), then the origin branch. A side that does not exist is skipped, so a branch that lives only here is still removed. A name that exists in neither place is an error. `--local` touches only this clone; `--remote` touches only origin. Containers (`develop`, `main`, `release/*`, `hotfix/*`) are refused.
142
- - **`remove --branch`** with no name lists the branches and asks which to remove. By default it lists branch pairs, meaning branches that exist both here and on origin. `--local` lists local branches and `--remote` lists origin branches. Each choice is removed on the same sides as above. `-limit <n>` shows only the first `n` (all by default), and the picker numbers only those. Answer with numbers or ranges, such as `1,3-4`; an empty answer cancels. The list needs a terminal, and that is checked before anything is listed. The checked-out branch and containers are never listed. Choosing items is the confirmation, so nothing else is asked. If a delete fails (for example, an unmerged branch), the removal stops there and earlier picks stay deleted.
151
+ - **`remove`** with no name opens the list of branches and asks which to remove, the same as `remove --branch`. By default it lists branch pairs, meaning branches that exist both here and on origin. `--local` lists local branches and `--remote` lists origin branches. Each choice is removed on the same sides as above. `-limit <n>` shows only the first `n` (all by default), and the picker numbers only those. Pick with the arrow keys: up and down move, space checks an item, enter confirms the checked items, and esc cancels. `switch` takes one item with enter. The list needs a terminal, and that is checked before anything is listed. The checked-out branch and containers are never listed. Choosing items is the confirmation, so nothing else is asked. If a delete fails (for example, an unmerged branch), the removal stops there and earlier picks stay deleted.
143
152
  - **`status`** prints the branch and how far it is ahead of or behind its origin branch, then counts staged, unstaged, untracked and conflicting files. It changes nothing.
144
153
  - **`update`** fetches `origin/develop` and merges it into the current work branch. It needs a clean working tree and refuses containers. If the merge conflicts, it aborts the merge and says so. It does not push.
145
154
  - **`undo`** runs `git reset --soft HEAD~1`: the last commit is removed and its changes stay staged. It refuses when HEAD is already on origin, because undoing a pushed commit needs a force-push.
@@ -174,6 +183,8 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
174
183
  - **`create -r` / `create -h`** creates a container from its base (`develop` for release, `main` for hotfix), refusing names that already exist on origin. Like `create -b`, it does not switch.
175
184
  - **With `--release` or `--hotfix` (no value)**, the version is worked out from the highest `vX.Y.Z` tag across local tags and origin: `-r` takes the next minor (`1.2.1` → `1.3.0`), `-h` takes the next patch (`1.2.1` → `1.2.2`). With no tags yet, the first release is `0.1.0` and the first hotfix `0.0.1`. The version is printed before the branch is created.
176
185
  - **`merge --current-release`** and **`--current-hotfix`** pick the newest `release/*` or `hotfix/*` branch by version (numbers compare as numbers, so `1.10.0` beats `1.9.0`), checking both local branches and origin, and merge into it. The target must exist on origin, because the merge pushes it; a newest branch that only exists here stops the command with a message.
186
+ - **`finish --current-release`** (and `--current-hotfix`) releases the newest `release/*` (or `hotfix/*`), named by its version X.Y.Z. It makes `package.json`'s version match the name (committed as `X.Y.Z`, only when they differ), adds the changelog entry as its own commit (when it is missing), merges the branch into `main` with `--no-ff`, tags `vX.Y.Z` and pushes, merges `main` into `develop` when `develop` exists on origin, and then asks before deleting the branch. A conflict aborts the merge and stops. Projects without a `package.json` keep their version by hand.
187
+ - **`merge`** with no flags asks which branch to merge and which target to merge it into (develop, release/*, hotfix/*), with the arrow keys. After a merge into a target, the work branch is deleted here and on origin, after a Y/n question. Without a terminal, or on a no, it is kept. Release and hotfix branches are never deleted this way.
177
188
  - **`merge`** pushes the work branch, merges the target into it first (so conflicts happen on the work branch and are aborted cleanly), then merges it into the target with `--no-ff`, pushes, and checks that both branches match origin.
178
189
 
179
190
  Containers (`develop`, `main`, `release/*`, `hotfix/*`) can't be merged from, and only `develop`, `release/*` and `hotfix/*` can be merged into.
package/bin/gith.js CHANGED
@@ -28,9 +28,11 @@ var SPECS = new Map([
28
28
  ["stashes", "switch"],
29
29
  ["current-release", "switch"],
30
30
  ["current-hotfix", "switch"],
31
+ ["develop", "switch"],
31
32
  ["pockets", "switch"],
32
33
  ["detached", "switch"],
33
34
  ["all", "switch"],
35
+ ["no-commit", "switch"],
34
36
  ["overall", "switch"],
35
37
  ["current-year", "switch"],
36
38
  ["current-month", "switch"],
@@ -119,6 +121,10 @@ function onlyOne(flags, names) {
119
121
  return present[0];
120
122
  }
121
123
 
124
+ // src/components/changelog/changelog.ts
125
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
126
+ import path from "node:path";
127
+
122
128
  // src/components/git/git.ts
123
129
  import { execFileSync } from "node:child_process";
124
130
  var verbose = false;
@@ -216,6 +222,92 @@ function act(args, options = {}) {
216
222
  execFileSync("git", args, { stdio: "inherit", ...options });
217
223
  }
218
224
 
225
+ // src/components/changelog/changelog.constants.ts
226
+ var CHANGELOG_FILE_NAME = "CHANGELOG.md";
227
+ var CHANGELOG_TITLE = "# Changelog";
228
+ var SEMVER_ONLY_PATTERN = /^\d+\.\d+\.\d+$/;
229
+
230
+ // src/components/changelog/changelog.utils.ts
231
+ function filterCommitSubjects(rawLog) {
232
+ if (!rawLog.trim())
233
+ return [];
234
+ return rawLog.split(`
235
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && !SEMVER_ONLY_PATTERN.test(line));
236
+ }
237
+ function buildEntry(notes) {
238
+ const bullets = notes.subjects.length > 0 ? notes.subjects.map((subject) => `- ${subject}`).join(`
239
+ `) : "- (no changes recorded)";
240
+ return `## ${notes.version} - ${notes.date}
241
+
242
+ ${bullets}
243
+ `;
244
+ }
245
+ function mergeChangelogContent(existing, entry) {
246
+ const heading = `${CHANGELOG_TITLE}
247
+ `;
248
+ if (existing === null)
249
+ return `${heading}
250
+ ${entry}
251
+ `;
252
+ const body = existing.startsWith(heading) ? existing.slice(heading.length).replace(/^\n+/, "") : existing;
253
+ return `${heading}
254
+ ${entry}
255
+ ${body}`;
256
+ }
257
+ function parsePackageVersion(rawPackageJson) {
258
+ const pkg = JSON.parse(rawPackageJson);
259
+ if (!pkg.version)
260
+ throw new Error('No "version" field found in package.json');
261
+ return pkg.version;
262
+ }
263
+ function todayIso(date = new Date) {
264
+ return date.toISOString().slice(0, 10);
265
+ }
266
+
267
+ // src/components/changelog/changelog.ts
268
+ function previousTag() {
269
+ try {
270
+ return captureArgs(["describe", "--tags", "--abbrev=0"]);
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ function commitSubjectsSince(tag) {
276
+ const range = tag ? `${tag}..HEAD` : "HEAD";
277
+ return filterCommitSubjects(captureArgs(["log", range, "--no-merges", "--pretty=format:%s"]));
278
+ }
279
+ function changelog(flags) {
280
+ const noCommit = flags.has("no-commit");
281
+ if (flags.size > (noCommit ? 1 : 0))
282
+ throw new Error("usage: gith changelog [--dry-run] [--no-commit]");
283
+ const root = process.cwd();
284
+ const version = parsePackageVersion(readFileSync(path.join(root, "package.json"), "utf8"));
285
+ const entry = buildEntry({
286
+ version,
287
+ date: todayIso(),
288
+ subjects: commitSubjectsSince(previousTag())
289
+ });
290
+ if (isDryRun()) {
291
+ console.log(entry);
292
+ console.log(`dry run: would update ${CHANGELOG_FILE_NAME}`);
293
+ return;
294
+ }
295
+ const file = path.join(root, CHANGELOG_FILE_NAME);
296
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : null;
297
+ writeFileSync(file, mergeChangelogContent(existing, entry));
298
+ console.log(`✅ ${CHANGELOG_FILE_NAME} updated for ${version}`);
299
+ if (noCommit)
300
+ return;
301
+ gitArgs(["add", "--", CHANGELOG_FILE_NAME]);
302
+ gitArgs([
303
+ "commit",
304
+ "-m",
305
+ `docs(changelog): ${version}`,
306
+ "--",
307
+ CHANGELOG_FILE_NAME
308
+ ]);
309
+ }
310
+
219
311
  // src/components/doctor/doctor.utils.ts
220
312
  function check(name, test, failure = "fail") {
221
313
  return {
@@ -336,6 +428,124 @@ function find(flags) {
336
428
  `));
337
429
  }
338
430
 
431
+ // src/components/flow/flow.ts
432
+ import { execSync } from "node:child_process";
433
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
434
+
435
+ // src/components/prompt/prompt.ts
436
+ import { emitKeypressEvents } from "node:readline";
437
+ import { createInterface } from "node:readline/promises";
438
+
439
+ // src/components/prompt/prompt.utils.ts
440
+ function moveIndex(index, delta, length) {
441
+ if (length <= 0)
442
+ return 0;
443
+ return ((index + delta) % length + length) % length;
444
+ }
445
+ function toggled(checked, index) {
446
+ const next = new Set(checked);
447
+ if (next.has(index))
448
+ next.delete(index);
449
+ else
450
+ next.add(index);
451
+ return next;
452
+ }
453
+
454
+ // src/components/prompt/prompt.ts
455
+ var assumeYes = false;
456
+ function setAssumeYes(on) {
457
+ assumeYes = on;
458
+ }
459
+ async function confirm(question) {
460
+ if (isDryRun()) {
461
+ console.log(`dry run: would ask: ${question}`);
462
+ return true;
463
+ }
464
+ if (assumeYes)
465
+ return true;
466
+ if (!process.stdin.isTTY)
467
+ throw new Error("this needs a terminal to confirm; pass --yes to skip the question.");
468
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
469
+ try {
470
+ const answer = (await rl.question(question)).trim().toLowerCase();
471
+ return answer === "" || answer === "y" || answer === "yes";
472
+ } finally {
473
+ rl.close();
474
+ }
475
+ }
476
+ async function ask(question) {
477
+ if (isDryRun()) {
478
+ console.log(`dry run: would ask: ${question}`);
479
+ return "<message>";
480
+ }
481
+ if (!process.stdin.isTTY)
482
+ throw new Error("needs a terminal to ask; pass -m <message>.");
483
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
484
+ try {
485
+ return (await rl.question(question)).trim();
486
+ } finally {
487
+ rl.close();
488
+ }
489
+ }
490
+ async function select(question, options, multi) {
491
+ if (!process.stdin.isTTY)
492
+ throw new Error("this needs a terminal to pick.");
493
+ return new Promise((resolve) => {
494
+ const stdin = process.stdin;
495
+ let index = 0;
496
+ let checked = new Set;
497
+ let drawnLines = 0;
498
+ const draw = () => {
499
+ if (drawnLines > 0)
500
+ process.stdout.write(`\x1B[${drawnLines}A\x1B[0J`);
501
+ const hint = multi ? "space checks, enter confirms, esc cancels" : "enter selects, esc cancels";
502
+ const lines = [`${question} (${hint})`];
503
+ options.forEach((option, i) => {
504
+ const cursor = i === index ? ">" : " ";
505
+ const mark = multi ? checked.has(i) ? "[x]" : "[ ]" : "";
506
+ lines.push(`${cursor} ${mark} ${option.label}`.replace(/ +$/, ""));
507
+ });
508
+ process.stdout.write(`${lines.join(`
509
+ `)}
510
+ `);
511
+ drawnLines = lines.length;
512
+ };
513
+ const finish = (picked) => {
514
+ stdin.off("keypress", onKey);
515
+ stdin.setRawMode(false);
516
+ stdin.pause();
517
+ process.stdout.write(`
518
+ `);
519
+ resolve(picked);
520
+ };
521
+ const onKey = (_text, key) => {
522
+ if (!key)
523
+ return;
524
+ if (key.ctrl && key.name === "c" || key.name === "escape") {
525
+ finish([]);
526
+ return;
527
+ }
528
+ if (key.name === "return") {
529
+ const picked = multi ? [...checked].sort((a, b) => a - b).map((i) => options[i]?.name ?? "") : [options[index]?.name ?? ""];
530
+ finish(picked.filter(Boolean));
531
+ return;
532
+ }
533
+ if (key.name === "up")
534
+ index = moveIndex(index, -1, options.length);
535
+ else if (key.name === "down")
536
+ index = moveIndex(index, 1, options.length);
537
+ else if (key.name === "space" && multi)
538
+ checked = toggled(checked, index);
539
+ draw();
540
+ };
541
+ emitKeypressEvents(stdin);
542
+ stdin.setRawMode(true);
543
+ stdin.resume();
544
+ stdin.on("keypress", onKey);
545
+ draw();
546
+ });
547
+ }
548
+
339
549
  // src/components/rules/rules.constants.ts
340
550
  var CONTAINER = /^(develop|main|release\/.+|hotfix\/.+)$/;
341
551
  var TARGET = /^(develop|release\/.+|hotfix\/.+)$/;
@@ -443,15 +653,20 @@ function create(flags) {
443
653
  }
444
654
  open(kind, value || nextContainerVersion(kind));
445
655
  }
446
- function merge(flags) {
656
+ async function merge(flags) {
657
+ if (flags.size === 0)
658
+ return mergePick();
447
659
  const kind = onlyOne(flags, [
660
+ "develop",
448
661
  "release",
449
662
  "hotfix",
450
663
  "current-release",
451
664
  "current-hotfix"
452
665
  ]);
453
666
  let target = "develop";
454
- if (kind === "current-release")
667
+ if (kind === "develop")
668
+ target = "develop";
669
+ else if (kind === "current-release")
455
670
  target = newestContainer("release");
456
671
  else if (kind === "current-hotfix")
457
672
  target = newestContainer("hotfix");
@@ -462,7 +677,7 @@ function merge(flags) {
462
677
  }
463
678
  target = containerName(kind, version);
464
679
  }
465
- mergeInto(target);
680
+ await mergeInto(target);
466
681
  }
467
682
  function createBranch(goal, from) {
468
683
  if (isContainer(goal))
@@ -477,10 +692,11 @@ function createBranch(goal, from) {
477
692
  }
478
693
  gitArgs(["push", "-u", "origin", goal]);
479
694
  }
480
- function mergeInto(target) {
695
+ async function mergeInto(target, from) {
481
696
  if (!isTarget(target))
482
697
  throw new Error(`'${target}' is not a valid target.`);
483
- const source = capture("rev-parse --abbrev-ref HEAD");
698
+ const current = capture("rev-parse --abbrev-ref HEAD");
699
+ const source = from ?? current;
484
700
  if (isContainer(source))
485
701
  throw new Error(`'${source}' is a container and cannot be merged from.`);
486
702
  if (source === target)
@@ -488,6 +704,8 @@ function mergeInto(target) {
488
704
  if (!hasOnOrigin(target))
489
705
  throw new Error(`'${target}' does not exist on origin.`);
490
706
  assertCleanTree();
707
+ if (source !== current)
708
+ gitArgs(["switch", "--", source]);
491
709
  if (hasOnOrigin(source))
492
710
  git(`pull origin ${source}`);
493
711
  git(`push -u origin ${source}`);
@@ -507,44 +725,116 @@ function mergeInto(target) {
507
725
  assertSynced([target, source]);
508
726
  console.log(`
509
727
  ✅ '${source}' merged into '${target}', both in sync with origin.`);
728
+ await deleteMergedBranch(source);
510
729
  }
511
-
512
- // src/components/prompt/prompt.ts
513
- import { createInterface } from "node:readline/promises";
514
- var assumeYes = false;
515
- function setAssumeYes(on) {
516
- assumeYes = on;
517
- }
518
- async function confirm(question) {
519
- if (isDryRun()) {
520
- console.log(`dry run: would ask: ${question}`);
521
- return true;
522
- }
523
- if (assumeYes)
524
- return true;
525
- if (!process.stdin.isTTY)
526
- throw new Error("this needs a terminal to confirm; pass --yes to skip the question.");
527
- const rl = createInterface({ input: process.stdin, output: process.stdout });
730
+ async function deleteMergedBranch(source) {
731
+ const local = hasLocal(source);
732
+ const origin = hasOnOrigin(source);
733
+ let yes;
528
734
  try {
529
- const answer = (await rl.question(question)).trim().toLowerCase();
530
- return answer === "" || answer === "y" || answer === "yes";
531
- } finally {
532
- rl.close();
735
+ yes = await confirm(`Delete '${source}' here and on origin, it is merged? [Y/n] `);
736
+ } catch {
737
+ yes = false;
738
+ }
739
+ if (!yes) {
740
+ console.log(`'${source}' kept.`);
741
+ return;
533
742
  }
743
+ if (local)
744
+ gitArgs(["branch", "-d", "--", source]);
745
+ if (origin)
746
+ gitArgs(["push", "origin", "--delete", source]);
534
747
  }
535
- async function ask(question) {
748
+ async function mergePick() {
749
+ if (!process.stdin.isTTY) {
750
+ throw new Error("pass -r <version> or -h <version> instead; the pickers need a terminal");
751
+ }
752
+ const current = capture("rev-parse --abbrev-ref HEAD");
753
+ const local = captureArgs([
754
+ "for-each-ref",
755
+ "--format=%(refname:short)",
756
+ "refs/heads"
757
+ ]).split(`
758
+ `).filter(Boolean);
759
+ const sources = local.filter((name) => !isContainer(name)).sort((a, b) => a.localeCompare(b)).map((name) => ({
760
+ name,
761
+ label: name === current ? `${name} (current)` : name
762
+ }));
763
+ if (sources.length === 0)
764
+ throw new Error("no work branch to merge.");
765
+ const [source] = await select("Which branch to merge?", sources, false);
766
+ if (source === undefined)
767
+ return;
768
+ const origin = captureArgs(["ls-remote", "--heads", "origin"]).split(`
769
+ `).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/heads\//, "")).filter(Boolean);
770
+ const targets = [...new Set([...local, ...origin])].filter((name) => isTarget(name) && name !== source).sort((a, b) => a.localeCompare(b)).map((name) => ({
771
+ name,
772
+ label: local.includes(name) ? name : `${name} (origin)`
773
+ }));
774
+ if (targets.length === 0)
775
+ throw new Error("no develop, release or hotfix branch to merge into.");
776
+ const [target] = await select(`Merge '${source}' into which?`, targets, false);
777
+ if (target === undefined)
778
+ return;
779
+ await mergeInto(target, source);
780
+ }
781
+ function alignVersion(version) {
782
+ if (!existsSync2("package.json"))
783
+ return;
784
+ const current = parsePackageVersion(readFileSync2("package.json", "utf8"));
785
+ if (current === version)
786
+ return;
536
787
  if (isDryRun()) {
537
- console.log(`dry run: would ask: ${question}`);
538
- return "<message>";
788
+ console.log(`> npm version ${version} --no-git-tag-version --ignore-scripts`);
789
+ } else {
790
+ execSync(`npm version ${version} --no-git-tag-version --ignore-scripts`, {
791
+ stdio: "inherit"
792
+ });
539
793
  }
540
- if (!process.stdin.isTTY)
541
- throw new Error("needs a terminal to ask; pass -m <message>.");
542
- const rl = createInterface({ input: process.stdin, output: process.stdout });
794
+ gitArgs(["add", "--", "package.json"]);
795
+ gitArgs(["commit", "-m", version, "--", "package.json"]);
796
+ }
797
+ function addChangelogEntry(version) {
798
+ if (!existsSync2("package.json"))
799
+ return;
800
+ const file = "CHANGELOG.md";
801
+ if (existsSync2(file) && readFileSync2(file, "utf8").includes(`## ${version} - `))
802
+ return;
803
+ changelog(new Map);
804
+ }
805
+ async function finish(flags) {
806
+ const kind = onlyOne(flags, ["current-release", "current-hotfix"]);
807
+ if (!kind || flags.size > 1)
808
+ throw new Error("usage: gith finish --current-release | --current-hotfix");
809
+ const containerKind = kind === "current-release" ? "release" : "hotfix";
810
+ const branch = newestContainer(containerKind);
811
+ const version = branch.slice(containerKind.length + 1);
812
+ if (parseSemver(version) === undefined)
813
+ throw new Error(`'${branch}' is not named with a version (X.Y.Z).`);
814
+ assertCleanTree();
815
+ gitArgs(["switch", "--", branch]);
816
+ gitArgs(["pull", "--ff-only", "origin", branch]);
817
+ alignVersion(version);
818
+ addChangelogEntry(version);
819
+ gitArgs(["switch", "--", "main"]);
820
+ gitArgs(["pull", "--ff-only", "origin", "main"]);
543
821
  try {
544
- return (await rl.question(question)).trim();
545
- } finally {
546
- rl.close();
822
+ gitArgs(["merge", "--no-ff", branch, "-m", `Merge '${branch}' into main`]);
823
+ } catch {
824
+ gitArgs(["merge", "--abort"]);
825
+ throw new Error(`merging '${branch}' into main conflicts; resolve it by hand, then retry.`);
826
+ }
827
+ gitArgs(["tag", "-a", `v${version}`, "-m", `v${version}`]);
828
+ gitArgs(["push", "origin", "main"]);
829
+ gitArgs(["push", "origin", `v${version}`]);
830
+ if (hasOnOrigin("develop")) {
831
+ gitArgs(["switch", "--", "develop"]);
832
+ gitArgs(["pull", "--ff-only", "origin", "develop"]);
833
+ gitArgs(["merge", "--no-edit", "main"]);
834
+ gitArgs(["push", "origin", "develop"]);
547
835
  }
836
+ gitArgs(["switch", "--", "main"]);
837
+ await deleteMergedBranch(branch);
548
838
  }
549
839
 
550
840
  // src/components/history/history.utils.ts
@@ -771,7 +1061,7 @@ function stashes(limit, withGorlum) {
771
1061
  ]).split(`
772
1062
  `).filter(Boolean);
773
1063
  if (rows.length === 0) {
774
- console.log("No stashes.");
1064
+ console.log(withGorlum ? "There is nothing in my pockets!" : "No stashes.");
775
1065
  return;
776
1066
  }
777
1067
  const shown = rows.slice(0, limit);
@@ -802,9 +1092,6 @@ function originTags() {
802
1092
  `).filter(Boolean).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/tags\//, "")).filter((ref) => ref && !ref.endsWith("^{}"));
803
1093
  }
804
1094
 
805
- // src/components/maintenance/maintenance.ts
806
- import { createInterface as createInterface2 } from "node:readline/promises";
807
-
808
1095
  // src/components/maintenance/maintenance.constants.ts
809
1096
  var DEFAULT_DAYS = 90;
810
1097
 
@@ -853,7 +1140,21 @@ function parsePositive(flags, name) {
853
1140
  }
854
1141
 
855
1142
  // src/components/maintenance/maintenance.ts
856
- function switchBranch(flags) {
1143
+ async function switchBranch(flags) {
1144
+ if (flags.size === 0)
1145
+ return switchPick();
1146
+ const kind = onlyOne(flags, ["release", "hotfix"]);
1147
+ if (kind !== undefined) {
1148
+ if (flags.size > 1)
1149
+ throw new Error(`usage: gith switch --${kind} | -${kind === "release" ? "r" : "h"} <version>`);
1150
+ const version = flags.get(kind) ?? "";
1151
+ const name = version === "" ? newestContainer(kind) : containerName(kind, version);
1152
+ if (!hasLocal(name) && !hasOnOrigin(name)) {
1153
+ throw new Error(`'${name}' does not exist, locally or on origin.`);
1154
+ }
1155
+ gitArgs(["switch", "--", name]);
1156
+ return;
1157
+ }
857
1158
  const commit = flags.get("commit");
858
1159
  if (commit !== undefined) {
859
1160
  if (flags.has("branch") || flags.size > 1)
@@ -878,20 +1179,6 @@ function candidates(scope, local, remote, current) {
878
1179
  }
879
1180
  return local.filter((name) => notContainer(name) && notCurrent(name) && remote.includes(name)).map((name) => ({ name, label: `${name} (local + origin)` }));
880
1181
  }
881
- function parseSelection(answer, count) {
882
- const indexes = new Set;
883
- for (const part of answer.split(",").map((s) => s.trim()).filter(Boolean)) {
884
- const range = /^(\d+)-(\d+)$/.exec(part);
885
- const from = Number(range ? range[1] : part);
886
- const to = Number(range ? range[2] : part);
887
- if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to > count || from > to) {
888
- throw new Error(`'${part}' is not a choice from 1 to ${count}.`);
889
- }
890
- for (let n = from;n <= to; n++)
891
- indexes.add(n - 1);
892
- }
893
- return [...indexes];
894
- }
895
1182
  function branchTimes(output) {
896
1183
  const times = new Map;
897
1184
  for (const line of output.split(`
@@ -937,7 +1224,7 @@ function detachedOptions(times, current) {
937
1224
  return options.sort((x, y) => x.name.localeCompare(y.name));
938
1225
  }
939
1226
  async function remove(flags) {
940
- const name = flags.get("branch");
1227
+ const name = flags.get("branch") ?? "";
941
1228
  const side = onlyOne(flags, ["local", "remote"]);
942
1229
  const scope = side ?? "pair";
943
1230
  const obsoleteOn = flags.has("obsolete");
@@ -950,14 +1237,12 @@ async function remove(flags) {
950
1237
  throw new Error("--all only applies to --detached.");
951
1238
  if (obsoleteOn || detachedOn) {
952
1239
  const usage = detachedOn ? "usage: gith remove --detached [--all | -l <n>]" : "usage: gith remove --obsolete [--local] [-days <n>] [-l <n>]";
953
- if (name !== undefined || obsoleteOn && side === "remote" || detachedOn && side)
1240
+ if (flags.has("branch") || obsoleteOn && side === "remote" || detachedOn && side)
954
1241
  throw new Error(usage);
955
1242
  if (detachedOn)
956
1243
  return detached(flags);
957
1244
  return obsolete(flags, side === "local" ? "local" : "pair");
958
1245
  }
959
- if (name === undefined)
960
- throw new Error("usage: gith remove -b <branch> [--local | --remote], or gith remove --branch to pick");
961
1246
  if (name) {
962
1247
  if (flags.has("limit"))
963
1248
  throw new Error("-limit only applies to listings: gith remove --branch.");
@@ -1012,25 +1297,49 @@ async function clean(flags) {
1012
1297
  }
1013
1298
  gitArgs(["clean", "-fd"]);
1014
1299
  }
1015
- async function choose(options, limit) {
1300
+ var REMOVE_ACTION = {
1301
+ empty: "No branches to remove.",
1302
+ prompt: "Remove which?",
1303
+ multi: true
1304
+ };
1305
+ async function choose(options, limit, action = REMOVE_ACTION) {
1016
1306
  if (options.length === 0) {
1017
- console.log("No branches to remove.");
1307
+ console.log(action.empty);
1018
1308
  return [];
1019
1309
  }
1020
1310
  const shown = limit === undefined ? options : options.slice(0, limit);
1021
- for (const [i, option] of shown.entries()) {
1022
- console.log(` ${i + 1}. ${option.label}`);
1023
- }
1024
1311
  if (shown.length < options.length) {
1025
1312
  console.log(`showing ${shown.length} of ${options.length}; -limit <n> shows more`);
1026
1313
  }
1027
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
1028
- try {
1029
- const answer = await rl.question("Remove which? Numbers or ranges, e.g. 1,3-4 (empty cancels): ");
1030
- return parseSelection(answer, shown.length).map((i) => shown[i]?.name ?? "");
1031
- } finally {
1032
- rl.close();
1314
+ return select(action.prompt, shown, action.multi);
1315
+ }
1316
+ function switchOptions() {
1317
+ const local = localBranches();
1318
+ const origin = remoteBranches();
1319
+ const current = currentBranch2();
1320
+ const names = [...new Set([...local, ...origin])].sort((a, b) => a.localeCompare(b));
1321
+ return names.map((name) => {
1322
+ const where = local.includes(name) && origin.includes(name) ? "local + origin" : local.includes(name) ? "local" : "origin";
1323
+ return {
1324
+ name,
1325
+ label: `${name} (${where}${name === current ? ", current" : ""})`
1326
+ };
1327
+ });
1328
+ }
1329
+ async function switchPick() {
1330
+ if (!process.stdin.isTTY) {
1331
+ throw new Error("pass a branch name with -b, e.g. gith switch -b <branch>; the list needs a terminal");
1033
1332
  }
1333
+ const picked = await choose(switchOptions(), undefined, {
1334
+ empty: "No branches.",
1335
+ prompt: "Switch to which?",
1336
+ multi: false
1337
+ });
1338
+ if (picked.length === 0)
1339
+ return;
1340
+ if (picked.length > 1)
1341
+ throw new Error("pick exactly one branch to switch to.");
1342
+ gitArgs(["switch", "--", picked[0] ?? ""]);
1034
1343
  }
1035
1344
  function requireTerminal() {
1036
1345
  if (!process.stdin.isTTY) {
@@ -1056,14 +1365,14 @@ async function removeBranches(scope, names, ask = true) {
1056
1365
  });
1057
1366
  if (plan.length > 0)
1058
1367
  console.log("Will remove:");
1059
- if (ask && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1060
- console.log("Nothing removed.");
1061
- return;
1062
- }
1063
1368
  for (const { name, local, origin } of plan) {
1064
1369
  const sides = [local && "local", origin && "origin"].filter(Boolean).join(" + ");
1065
1370
  console.log(` ${name} (${sides})`);
1066
1371
  }
1372
+ if (ask && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1373
+ console.log("Nothing removed.");
1374
+ return;
1375
+ }
1067
1376
  for (const { name, local, origin } of plan) {
1068
1377
  if (local)
1069
1378
  gitArgs(["branch", "-d", "--", name]);
@@ -1173,12 +1482,12 @@ function openerFor(platform, url) {
1173
1482
  var KINDS = ["patch", "minor", "major"];
1174
1483
 
1175
1484
  // src/components/release/release.utils.ts
1176
- import { execSync } from "node:child_process";
1485
+ import { execSync as execSync2 } from "node:child_process";
1177
1486
  function npmVersion(kind) {
1178
1487
  console.log(`> npm version ${kind}`);
1179
1488
  if (isDryRun())
1180
1489
  return;
1181
- execSync(`npm version ${kind}`, { stdio: "inherit" });
1490
+ execSync2(`npm version ${kind}`, { stdio: "inherit" });
1182
1491
  }
1183
1492
 
1184
1493
  // src/components/release/release.ts
@@ -1569,7 +1878,7 @@ function tree(flags) {
1569
1878
  }
1570
1879
 
1571
1880
  // src/components/version/version.ts
1572
- import { readFileSync as readFileSync2 } from "node:fs";
1881
+ import { readFileSync as readFileSync4 } from "node:fs";
1573
1882
 
1574
1883
  // src/components/version/version.constants.ts
1575
1884
  var MASCOT = [
@@ -1584,19 +1893,19 @@ var MASCOT = [
1584
1893
  ];
1585
1894
 
1586
1895
  // src/components/version/version.utils.ts
1587
- import { existsSync, readFileSync } from "node:fs";
1588
- import path from "node:path";
1896
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1897
+ import path2 from "node:path";
1589
1898
  import { fileURLToPath } from "node:url";
1590
1899
  function packageJsonPath() {
1591
- let dir = path.dirname(fileURLToPath(import.meta.url));
1900
+ let dir = path2.dirname(fileURLToPath(import.meta.url));
1592
1901
  for (;; ) {
1593
- const candidate = path.join(dir, "package.json");
1594
- if (existsSync(candidate)) {
1595
- const pkg = JSON.parse(readFileSync(candidate, "utf8"));
1902
+ const candidate = path2.join(dir, "package.json");
1903
+ if (existsSync3(candidate)) {
1904
+ const pkg = JSON.parse(readFileSync3(candidate, "utf8"));
1596
1905
  if (pkg.name === "@tertium/gith")
1597
1906
  return candidate;
1598
1907
  }
1599
- const parent = path.dirname(dir);
1908
+ const parent = path2.dirname(dir);
1600
1909
  if (parent === dir)
1601
1910
  throw new Error("gith package.json not found");
1602
1911
  dir = parent;
@@ -1605,7 +1914,7 @@ function packageJsonPath() {
1605
1914
 
1606
1915
  // src/components/version/version.ts
1607
1916
  function readVersion() {
1608
- const pkg = JSON.parse(readFileSync2(packageJsonPath(), "utf8"));
1917
+ const pkg = JSON.parse(readFileSync4(packageJsonPath(), "utf8"));
1609
1918
  return pkg.version ?? "unknown";
1610
1919
  }
1611
1920
  function versionText() {
@@ -1623,9 +1932,14 @@ Start and finish work
1623
1932
  gith create -r <version> release/<version> from develop
1624
1933
  gith create -h <version> hotfix/<version> from main
1625
1934
  gith create --release | --hotfix version from the latest tag: minor for release, patch for hotfix
1935
+ gith switch list local and origin branches, and switch to the one picked
1626
1936
  gith switch -b <branch> switch to a branch, creating it from develop if missing (on a detached HEAD: from that commit)
1627
1937
  gith switch -commit <sha> detach HEAD at a commit, to look at it (tracked files must be clean)
1938
+ gith switch --release | --hotfix switch to the newest release/* or hotfix/* (local or origin); -r <v> / -h <v> for one version
1628
1939
  gith update merge origin/develop into this work branch
1940
+ gith finish --current-release | --current-hotfix release the newest release/* or hotfix/*: version, changelog, merge to main, tag, back to develop
1941
+ gith merge --develop merge this branch into develop
1942
+ gith merge pick the branch to merge, then the target (develop, release/*, hotfix/*)
1629
1943
  gith merge [-r <v> | -h <v>] merge this branch into develop, release/<v> or hotfix/<v>
1630
1944
  gith merge --current-release merge this branch into the newest release (local or origin)
1631
1945
  gith merge --current-hotfix merge this branch into the newest hotfix (local or origin)
@@ -1646,7 +1960,8 @@ Clean up branches (every removal shows what it will remove first)
1646
1960
  gith remove -b <branch> delete the branch here and on origin (either side may be missing)
1647
1961
  gith remove -b <branch> --local delete only the local branch
1648
1962
  gith remove -b <branch> --remote delete only the origin branch
1649
- gith remove --branch [-l <n>] pick from branch pairs (here and on origin)
1963
+ gith remove pick branches to delete from a list (here and on origin)
1964
+ gith remove --branch [-l <n>] same list, for branch pairs (here and on origin)
1650
1965
  gith remove --branch --local pick from local branches
1651
1966
  gith remove --branch --remote pick from origin branches
1652
1967
  gith remove --obsolete [--local] [-days <n>] remove branches untouched for 90 days, after Y/n
@@ -1670,6 +1985,7 @@ Remote and health
1670
1985
  gith sync fetch all remotes and tags, prune stale refs
1671
1986
  gith open open origin in the browser
1672
1987
  gith doctor check the repo is ready for the branch flow (exit 1 on failures)
1988
+ gith changelog [--dry-run] add an entry for package.json's version to CHANGELOG.md (commits since the last tag)
1673
1989
 
1674
1990
  Help
1675
1991
  gith help show this help
@@ -1702,11 +2018,14 @@ try {
1702
2018
  case "create":
1703
2019
  create(parseFlags(rest));
1704
2020
  break;
2021
+ case "finish":
2022
+ await finish(parseFlags(rest));
2023
+ break;
1705
2024
  case "merge":
1706
- merge(parseFlags(rest));
2025
+ await merge(parseFlags(rest));
1707
2026
  break;
1708
2027
  case "switch":
1709
- switchBranch(parseFlags(rest));
2028
+ await switchBranch(parseFlags(rest));
1710
2029
  break;
1711
2030
  case "remove":
1712
2031
  case "rm":
@@ -1753,6 +2072,9 @@ try {
1753
2072
  case "open":
1754
2073
  open2(parseFlags(rest));
1755
2074
  break;
2075
+ case "changelog":
2076
+ changelog(parseFlags(rest));
2077
+ break;
1756
2078
  case "doctor":
1757
2079
  if (!doctor(parseFlags(rest)))
1758
2080
  exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertium/gith",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": {
@@ -18,7 +18,8 @@
18
18
  "typecheck": "tsc --noEmit",
19
19
  "release:patch": "node bin/gith.js release --patch",
20
20
  "release:minor": "node bin/gith.js release --minor",
21
- "release:major": "node bin/gith.js release --major"
21
+ "release:major": "node bin/gith.js release --major",
22
+ "version": "bun run build && node bin/gith.js changelog"
22
23
  },
23
24
  "license": "ISC",
24
25
  "author": "Vitalii Balabanov",