@tertium/gith 0.2.0 → 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 +10 -1
  2. package/bin/gith.js +310 -83
  3. package/package.json +1 -1
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
@@ -141,7 +148,7 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
141
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.
142
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.
143
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.
144
- - **`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.
145
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.
146
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.
147
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.
@@ -176,6 +183,8 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
176
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.
177
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.
178
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.
179
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.
180
189
 
181
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,6 +28,7 @@ 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"],
@@ -427,6 +428,124 @@ function find(flags) {
427
428
  `));
428
429
  }
429
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
+
430
549
  // src/components/rules/rules.constants.ts
431
550
  var CONTAINER = /^(develop|main|release\/.+|hotfix\/.+)$/;
432
551
  var TARGET = /^(develop|release\/.+|hotfix\/.+)$/;
@@ -534,15 +653,20 @@ function create(flags) {
534
653
  }
535
654
  open(kind, value || nextContainerVersion(kind));
536
655
  }
537
- function merge(flags) {
656
+ async function merge(flags) {
657
+ if (flags.size === 0)
658
+ return mergePick();
538
659
  const kind = onlyOne(flags, [
660
+ "develop",
539
661
  "release",
540
662
  "hotfix",
541
663
  "current-release",
542
664
  "current-hotfix"
543
665
  ]);
544
666
  let target = "develop";
545
- if (kind === "current-release")
667
+ if (kind === "develop")
668
+ target = "develop";
669
+ else if (kind === "current-release")
546
670
  target = newestContainer("release");
547
671
  else if (kind === "current-hotfix")
548
672
  target = newestContainer("hotfix");
@@ -553,7 +677,7 @@ function merge(flags) {
553
677
  }
554
678
  target = containerName(kind, version);
555
679
  }
556
- mergeInto(target);
680
+ await mergeInto(target);
557
681
  }
558
682
  function createBranch(goal, from) {
559
683
  if (isContainer(goal))
@@ -568,10 +692,11 @@ function createBranch(goal, from) {
568
692
  }
569
693
  gitArgs(["push", "-u", "origin", goal]);
570
694
  }
571
- function mergeInto(target) {
695
+ async function mergeInto(target, from) {
572
696
  if (!isTarget(target))
573
697
  throw new Error(`'${target}' is not a valid target.`);
574
- const source = capture("rev-parse --abbrev-ref HEAD");
698
+ const current = capture("rev-parse --abbrev-ref HEAD");
699
+ const source = from ?? current;
575
700
  if (isContainer(source))
576
701
  throw new Error(`'${source}' is a container and cannot be merged from.`);
577
702
  if (source === target)
@@ -579,6 +704,8 @@ function mergeInto(target) {
579
704
  if (!hasOnOrigin(target))
580
705
  throw new Error(`'${target}' does not exist on origin.`);
581
706
  assertCleanTree();
707
+ if (source !== current)
708
+ gitArgs(["switch", "--", source]);
582
709
  if (hasOnOrigin(source))
583
710
  git(`pull origin ${source}`);
584
711
  git(`push -u origin ${source}`);
@@ -598,44 +725,116 @@ function mergeInto(target) {
598
725
  assertSynced([target, source]);
599
726
  console.log(`
600
727
  ✅ '${source}' merged into '${target}', both in sync with origin.`);
728
+ await deleteMergedBranch(source);
601
729
  }
602
-
603
- // src/components/prompt/prompt.ts
604
- import { createInterface } from "node:readline/promises";
605
- var assumeYes = false;
606
- function setAssumeYes(on) {
607
- assumeYes = on;
608
- }
609
- async function confirm(question) {
610
- if (isDryRun()) {
611
- console.log(`dry run: would ask: ${question}`);
612
- return true;
613
- }
614
- if (assumeYes)
615
- return true;
616
- if (!process.stdin.isTTY)
617
- throw new Error("this needs a terminal to confirm; pass --yes to skip the question.");
618
- 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;
619
734
  try {
620
- const answer = (await rl.question(question)).trim().toLowerCase();
621
- return answer === "" || answer === "y" || answer === "yes";
622
- } finally {
623
- rl.close();
735
+ yes = await confirm(`Delete '${source}' here and on origin, it is merged? [Y/n] `);
736
+ } catch {
737
+ yes = false;
624
738
  }
739
+ if (!yes) {
740
+ console.log(`'${source}' kept.`);
741
+ return;
742
+ }
743
+ if (local)
744
+ gitArgs(["branch", "-d", "--", source]);
745
+ if (origin)
746
+ gitArgs(["push", "origin", "--delete", source]);
625
747
  }
626
- 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;
627
787
  if (isDryRun()) {
628
- console.log(`dry run: would ask: ${question}`);
629
- 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
+ });
630
793
  }
631
- if (!process.stdin.isTTY)
632
- throw new Error("needs a terminal to ask; pass -m <message>.");
633
- 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"]);
634
821
  try {
635
- return (await rl.question(question)).trim();
636
- } finally {
637
- 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"]);
638
835
  }
836
+ gitArgs(["switch", "--", "main"]);
837
+ await deleteMergedBranch(branch);
639
838
  }
640
839
 
641
840
  // src/components/history/history.utils.ts
@@ -862,7 +1061,7 @@ function stashes(limit, withGorlum) {
862
1061
  ]).split(`
863
1062
  `).filter(Boolean);
864
1063
  if (rows.length === 0) {
865
- console.log("No stashes.");
1064
+ console.log(withGorlum ? "There is nothing in my pockets!" : "No stashes.");
866
1065
  return;
867
1066
  }
868
1067
  const shown = rows.slice(0, limit);
@@ -893,9 +1092,6 @@ function originTags() {
893
1092
  `).filter(Boolean).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/tags\//, "")).filter((ref) => ref && !ref.endsWith("^{}"));
894
1093
  }
895
1094
 
896
- // src/components/maintenance/maintenance.ts
897
- import { createInterface as createInterface2 } from "node:readline/promises";
898
-
899
1095
  // src/components/maintenance/maintenance.constants.ts
900
1096
  var DEFAULT_DAYS = 90;
901
1097
 
@@ -944,7 +1140,21 @@ function parsePositive(flags, name) {
944
1140
  }
945
1141
 
946
1142
  // src/components/maintenance/maintenance.ts
947
- 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
+ }
948
1158
  const commit = flags.get("commit");
949
1159
  if (commit !== undefined) {
950
1160
  if (flags.has("branch") || flags.size > 1)
@@ -969,20 +1179,6 @@ function candidates(scope, local, remote, current) {
969
1179
  }
970
1180
  return local.filter((name) => notContainer(name) && notCurrent(name) && remote.includes(name)).map((name) => ({ name, label: `${name} (local + origin)` }));
971
1181
  }
972
- function parseSelection(answer, count) {
973
- const indexes = new Set;
974
- for (const part of answer.split(",").map((s) => s.trim()).filter(Boolean)) {
975
- const range = /^(\d+)-(\d+)$/.exec(part);
976
- const from = Number(range ? range[1] : part);
977
- const to = Number(range ? range[2] : part);
978
- if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to > count || from > to) {
979
- throw new Error(`'${part}' is not a choice from 1 to ${count}.`);
980
- }
981
- for (let n = from;n <= to; n++)
982
- indexes.add(n - 1);
983
- }
984
- return [...indexes];
985
- }
986
1182
  function branchTimes(output) {
987
1183
  const times = new Map;
988
1184
  for (const line of output.split(`
@@ -1028,7 +1224,7 @@ function detachedOptions(times, current) {
1028
1224
  return options.sort((x, y) => x.name.localeCompare(y.name));
1029
1225
  }
1030
1226
  async function remove(flags) {
1031
- const name = flags.get("branch");
1227
+ const name = flags.get("branch") ?? "";
1032
1228
  const side = onlyOne(flags, ["local", "remote"]);
1033
1229
  const scope = side ?? "pair";
1034
1230
  const obsoleteOn = flags.has("obsolete");
@@ -1041,14 +1237,12 @@ async function remove(flags) {
1041
1237
  throw new Error("--all only applies to --detached.");
1042
1238
  if (obsoleteOn || detachedOn) {
1043
1239
  const usage = detachedOn ? "usage: gith remove --detached [--all | -l <n>]" : "usage: gith remove --obsolete [--local] [-days <n>] [-l <n>]";
1044
- if (name !== undefined || obsoleteOn && side === "remote" || detachedOn && side)
1240
+ if (flags.has("branch") || obsoleteOn && side === "remote" || detachedOn && side)
1045
1241
  throw new Error(usage);
1046
1242
  if (detachedOn)
1047
1243
  return detached(flags);
1048
1244
  return obsolete(flags, side === "local" ? "local" : "pair");
1049
1245
  }
1050
- if (name === undefined)
1051
- throw new Error("usage: gith remove -b <branch> [--local | --remote], or gith remove --branch to pick");
1052
1246
  if (name) {
1053
1247
  if (flags.has("limit"))
1054
1248
  throw new Error("-limit only applies to listings: gith remove --branch.");
@@ -1103,25 +1297,49 @@ async function clean(flags) {
1103
1297
  }
1104
1298
  gitArgs(["clean", "-fd"]);
1105
1299
  }
1106
- 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) {
1107
1306
  if (options.length === 0) {
1108
- console.log("No branches to remove.");
1307
+ console.log(action.empty);
1109
1308
  return [];
1110
1309
  }
1111
1310
  const shown = limit === undefined ? options : options.slice(0, limit);
1112
- for (const [i, option] of shown.entries()) {
1113
- console.log(` ${i + 1}. ${option.label}`);
1114
- }
1115
1311
  if (shown.length < options.length) {
1116
1312
  console.log(`showing ${shown.length} of ${options.length}; -limit <n> shows more`);
1117
1313
  }
1118
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
1119
- try {
1120
- const answer = await rl.question("Remove which? Numbers or ranges, e.g. 1,3-4 (empty cancels): ");
1121
- return parseSelection(answer, shown.length).map((i) => shown[i]?.name ?? "");
1122
- } finally {
1123
- 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");
1124
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] ?? ""]);
1125
1343
  }
1126
1344
  function requireTerminal() {
1127
1345
  if (!process.stdin.isTTY) {
@@ -1147,14 +1365,14 @@ async function removeBranches(scope, names, ask = true) {
1147
1365
  });
1148
1366
  if (plan.length > 0)
1149
1367
  console.log("Will remove:");
1150
- if (ask && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1151
- console.log("Nothing removed.");
1152
- return;
1153
- }
1154
1368
  for (const { name, local, origin } of plan) {
1155
1369
  const sides = [local && "local", origin && "origin"].filter(Boolean).join(" + ");
1156
1370
  console.log(` ${name} (${sides})`);
1157
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
+ }
1158
1376
  for (const { name, local, origin } of plan) {
1159
1377
  if (local)
1160
1378
  gitArgs(["branch", "-d", "--", name]);
@@ -1264,12 +1482,12 @@ function openerFor(platform, url) {
1264
1482
  var KINDS = ["patch", "minor", "major"];
1265
1483
 
1266
1484
  // src/components/release/release.utils.ts
1267
- import { execSync } from "node:child_process";
1485
+ import { execSync as execSync2 } from "node:child_process";
1268
1486
  function npmVersion(kind) {
1269
1487
  console.log(`> npm version ${kind}`);
1270
1488
  if (isDryRun())
1271
1489
  return;
1272
- execSync(`npm version ${kind}`, { stdio: "inherit" });
1490
+ execSync2(`npm version ${kind}`, { stdio: "inherit" });
1273
1491
  }
1274
1492
 
1275
1493
  // src/components/release/release.ts
@@ -1660,7 +1878,7 @@ function tree(flags) {
1660
1878
  }
1661
1879
 
1662
1880
  // src/components/version/version.ts
1663
- import { readFileSync as readFileSync3 } from "node:fs";
1881
+ import { readFileSync as readFileSync4 } from "node:fs";
1664
1882
 
1665
1883
  // src/components/version/version.constants.ts
1666
1884
  var MASCOT = [
@@ -1675,15 +1893,15 @@ var MASCOT = [
1675
1893
  ];
1676
1894
 
1677
1895
  // src/components/version/version.utils.ts
1678
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1896
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1679
1897
  import path2 from "node:path";
1680
1898
  import { fileURLToPath } from "node:url";
1681
1899
  function packageJsonPath() {
1682
1900
  let dir = path2.dirname(fileURLToPath(import.meta.url));
1683
1901
  for (;; ) {
1684
1902
  const candidate = path2.join(dir, "package.json");
1685
- if (existsSync2(candidate)) {
1686
- const pkg = JSON.parse(readFileSync2(candidate, "utf8"));
1903
+ if (existsSync3(candidate)) {
1904
+ const pkg = JSON.parse(readFileSync3(candidate, "utf8"));
1687
1905
  if (pkg.name === "@tertium/gith")
1688
1906
  return candidate;
1689
1907
  }
@@ -1696,7 +1914,7 @@ function packageJsonPath() {
1696
1914
 
1697
1915
  // src/components/version/version.ts
1698
1916
  function readVersion() {
1699
- const pkg = JSON.parse(readFileSync3(packageJsonPath(), "utf8"));
1917
+ const pkg = JSON.parse(readFileSync4(packageJsonPath(), "utf8"));
1700
1918
  return pkg.version ?? "unknown";
1701
1919
  }
1702
1920
  function versionText() {
@@ -1714,9 +1932,14 @@ Start and finish work
1714
1932
  gith create -r <version> release/<version> from develop
1715
1933
  gith create -h <version> hotfix/<version> from main
1716
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
1717
1936
  gith switch -b <branch> switch to a branch, creating it from develop if missing (on a detached HEAD: from that commit)
1718
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
1719
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/*)
1720
1943
  gith merge [-r <v> | -h <v>] merge this branch into develop, release/<v> or hotfix/<v>
1721
1944
  gith merge --current-release merge this branch into the newest release (local or origin)
1722
1945
  gith merge --current-hotfix merge this branch into the newest hotfix (local or origin)
@@ -1737,7 +1960,8 @@ Clean up branches (every removal shows what it will remove first)
1737
1960
  gith remove -b <branch> delete the branch here and on origin (either side may be missing)
1738
1961
  gith remove -b <branch> --local delete only the local branch
1739
1962
  gith remove -b <branch> --remote delete only the origin branch
1740
- 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)
1741
1965
  gith remove --branch --local pick from local branches
1742
1966
  gith remove --branch --remote pick from origin branches
1743
1967
  gith remove --obsolete [--local] [-days <n>] remove branches untouched for 90 days, after Y/n
@@ -1794,11 +2018,14 @@ try {
1794
2018
  case "create":
1795
2019
  create(parseFlags(rest));
1796
2020
  break;
2021
+ case "finish":
2022
+ await finish(parseFlags(rest));
2023
+ break;
1797
2024
  case "merge":
1798
- merge(parseFlags(rest));
2025
+ await merge(parseFlags(rest));
1799
2026
  break;
1800
2027
  case "switch":
1801
- switchBranch(parseFlags(rest));
2028
+ await switchBranch(parseFlags(rest));
1802
2029
  break;
1803
2030
  case "remove":
1804
2031
  case "rm":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertium/gith",
3
- "version": "0.2.0",
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": {