@tertium/gith 0.2.0 → 0.3.1

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 +23 -8
  2. package/bin/gith.js +375 -112
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  d b
12
12
  ```
13
13
 
14
- Git helper: the branch flow from [git-convention](https://github.com/Tertiumnon/git-convention) (goal-named work branches, `release/*` and `hotfix/*` containers, merges that sync both sides with origin), plus everyday git commands: status, switch, update, undo, save, squash, stash, remove, list, tree, sync, clean, reset, open, doctor and stat. Any command takes `--dry-run` to show the git commands it would run. No dependencies beyond git and [Bun](https://bun.com).
14
+ Git helper: the branch flow from [git-convention](https://github.com/Tertiumnon/git-convention) (work branches named develop--<goal>, `release/*` and `hotfix/*` containers, merges that sync both sides with origin), plus everyday git commands: status, switch, update, undo, save, squash, stash, remove, list, tree, sync, clean, reset, open, doctor and stat. Any command takes `--dry-run` to show the git commands it would run. No dependencies beyond git and [Bun](https://bun.com).
15
15
 
16
16
  > **Very early state (0.1.0).** Commands and flags may change without notice, and the tool has only been run against throwaway test repositories. Check the result with `git log` and `git status` before you push.
17
17
 
@@ -26,20 +26,29 @@ gith --version
26
26
 
27
27
  From a clone of this repository, build the CLI and link it: `bun install`, `bun run build`, `npm link`.
28
28
 
29
+ `bun install` also points git at `.githooks/`, so each commit runs `bun run typecheck`, `bun run format:check` and `bun run lint` first, and a failing check stops it. `bun run format` rewrites files to the formatter's style.
30
+
29
31
  ## Usage
30
32
 
31
33
  ```bash
32
34
  # Start and finish work
33
- gith create -b login-redirect # work branch from develop, named by its goal; stays on the current branch
35
+ gith create -b login-redirect # work branch develop--login-redirect from develop; stays on the current branch
34
36
  gith create -r 1.2.0 # release/1.2.0 from develop
35
37
  gith create -h 1.2.1 # hotfix/1.2.1 from main
36
38
  gith create --release # release, version = minor after the latest tag
37
39
  gith create --hotfix # hotfix, version = patch after the latest tag
38
- gith switch -b login-redirect # switch to the branch, creating it from develop if it is missing
40
+ gith switch # list local and origin branches, and switch to the one you pick
41
+ gith switch -b login-redirect # switch to develop--login-redirect, creating it from develop if it is missing
39
42
  gith switch -commit 3f2a1b9 # detach HEAD at that commit, to look at old code
43
+ gith switch --release # switch to the newest release/* (local or origin); -r 1.9.0 for one version
44
+ gith switch --hotfix # switch to the newest hotfix/* (local or origin); -h 1.2.1 for one version
40
45
  gith update # merge origin/develop into this work branch
41
46
  gith merge # this branch into develop
42
- gith merge -r 1.2.0 # this branch into release/1.2.0
47
+ gith finish --current-release # release the newest release/*: version, changelog, merge to main, tag, merge main to develop, then ask before deleting the branch
48
+ gith finish --current-hotfix # the same for the newest hotfix/*
49
+ gith merge # pick the branch to merge, then the target from the list
50
+ gith merge --main # this branch into main
51
+ gith merge -r 1.2.0 # this branch into release/1.2.0 (-r alone: the only release, or pick from the list)
43
52
  gith merge -h 1.2.1 # this branch into hotfix/1.2.1
44
53
  gith merge --current-release # this branch into the newest release/* (local or origin)
45
54
  gith merge --current-hotfix # this branch into the newest hotfix/* (local or origin)
@@ -49,7 +58,7 @@ gith release --major # npm version major, from develop into main
49
58
 
50
59
  # Fix history
51
60
  gith undo # undo the last commit, keep its changes staged (not if pushed)
52
- gith save [-m "msg"] # fold staged changes into the last commit, or make a new commit with them if the branch has none yet (asks for a message); pushes if origin differs
61
+ gith save [-m "msg"] [--all] # --all stages every change first; fold staged changes into the last commit, or make a new commit with them if the branch has none yet (asks for a message); pushes if origin differs
53
62
  gith squash -limit 3 [-m "msg"] # combine the last 3 commits into one (keeps the oldest message)
54
63
 
55
64
  # Save and reset work
@@ -59,6 +68,7 @@ gith reset # discard uncommitted changes, then clean
59
68
  gith clean # remove untracked files and directories
60
69
 
61
70
  # Clean up branches (every removal shows what it will remove first)
71
+ gith remove # pick branches to delete from the list (here and on origin)
62
72
  gith remove -b login-redirect # delete the branch here and on origin (rm is an alias)
63
73
  gith remove -b login-redirect --local # delete only the local branch
64
74
  gith remove -b login-redirect --remote # delete only the origin branch
@@ -113,9 +123,12 @@ One rule applies to every command: **a single dash always takes a value, and a d
113
123
  | `-b <name>`, `-branch <name>` | value | `create`, `switch`, `remove` |
114
124
  | `--branch` | alone: open the picker | `remove` |
115
125
  | `-r <version>`, `-release <version>` | value | `create`, `merge` |
126
+ | `-r` alone | the only release, or a pick from the list | `merge` |
116
127
  | `--release` | alone: version from the latest tag | `create` |
128
+ | `--main` | alone: merge into main | `merge` |
117
129
  | `--current-release`, `--current-hotfix` | switch: merge into the newest release or hotfix | `merge` |
118
130
  | `-h <version>`, `-hotfix <version>` | value | `create`, `merge` |
131
+ | `-h` alone | the only hotfix, or a pick from the list | `merge` |
119
132
  | `--hotfix` | alone: version from the latest tag | `create` |
120
133
  | `-limit <n>` | value | `squash`: how many commits to combine |
121
134
  | `-m <msg>`, `-message <msg>` | value | `squash` |
@@ -138,10 +151,10 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
138
151
 
139
152
  ## Git commands
140
153
 
141
- - **`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.
154
+ - **`switch -b`** switches to a branch. If a branch with that name exists (locally or on origin), it is checked out. Otherwise, if `develop--<name>` exists, that one is checked out. If neither exists, `develop--<name>` is created from `origin/develop`, the same way as `create -b`, and checked out.
142
155
  - **`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
156
  - **`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.
157
+ - **`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
158
  - **`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
159
  - **`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
160
  - **`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.
@@ -172,10 +185,12 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
172
185
 
173
186
  ## What it does
174
187
 
175
- - **`create -b`** creates the branch from `origin/develop`, pushes it with upstream tracking, and leaves the current branch checked out. It does not touch the working tree, so it works with uncommitted changes.
188
+ - **`create -b`** creates `develop--<name>` from `origin/develop`, pushes it with upstream tracking, and leaves the current branch checked out. The prefix shows the source branch; `create -b develop` and `create -b main` are refused. It does not touch the working tree, so it works with uncommitted changes.
176
189
  - **`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
190
  - **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
191
  - **`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.
192
+ - **`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.
193
+ - **`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
194
  - **`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
195
 
181
196
  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
@@ -6,8 +6,8 @@ import { exit } from "node:process";
6
6
  // src/components/args/args.constants.ts
7
7
  var SPECS = new Map([
8
8
  ["branch", "auto"],
9
- ["release", "auto"],
10
- ["hotfix", "auto"],
9
+ ["release", "optional"],
10
+ ["hotfix", "optional"],
11
11
  ["limit", "value"],
12
12
  ["message", "value"],
13
13
  ["name", "value"],
@@ -28,6 +28,8 @@ var SPECS = new Map([
28
28
  ["stashes", "switch"],
29
29
  ["current-release", "switch"],
30
30
  ["current-hotfix", "switch"],
31
+ ["develop", "switch"],
32
+ ["main", "switch"],
31
33
  ["pockets", "switch"],
32
34
  ["detached", "switch"],
33
35
  ["all", "switch"],
@@ -88,10 +90,14 @@ function parseFlags(argv) {
88
90
  if (style === "single") {
89
91
  if (kind === "switch")
90
92
  throw new Error(`${token} is a switch; write --${name}.`);
91
- if (!hasNext)
93
+ if (hasNext) {
94
+ flags.set(name, next);
95
+ i++;
96
+ } else if (kind === "optional") {
97
+ flags.set(name, "");
98
+ } else {
92
99
  throw new Error(`${token} needs a value.`);
93
- flags.set(name, next);
94
- i++;
100
+ }
95
101
  } else if (kind === "switch") {
96
102
  flags.set(name, "true");
97
103
  } else if (kind === "value") {
@@ -427,9 +433,127 @@ function find(flags) {
427
433
  `));
428
434
  }
429
435
 
436
+ // src/components/flow/flow.ts
437
+ import { execSync } from "node:child_process";
438
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
439
+
440
+ // src/components/prompt/prompt.ts
441
+ import { emitKeypressEvents } from "node:readline";
442
+ import { createInterface } from "node:readline/promises";
443
+
444
+ // src/components/prompt/prompt.utils.ts
445
+ function moveIndex(index, delta, length) {
446
+ if (length <= 0)
447
+ return 0;
448
+ return ((index + delta) % length + length) % length;
449
+ }
450
+ function toggled(checked, index) {
451
+ const next = new Set(checked);
452
+ if (next.has(index))
453
+ next.delete(index);
454
+ else
455
+ next.add(index);
456
+ return next;
457
+ }
458
+
459
+ // src/components/prompt/prompt.ts
460
+ var assumeYes = false;
461
+ function setAssumeYes(on) {
462
+ assumeYes = on;
463
+ }
464
+ async function confirm(question) {
465
+ if (isDryRun()) {
466
+ console.log(`dry run: would ask: ${question}`);
467
+ return true;
468
+ }
469
+ if (assumeYes)
470
+ return true;
471
+ if (!process.stdin.isTTY)
472
+ throw new Error("this needs a terminal to confirm; pass --yes to skip the question.");
473
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
474
+ try {
475
+ const answer = (await rl.question(question)).trim().toLowerCase();
476
+ return answer === "" || answer === "y" || answer === "yes";
477
+ } finally {
478
+ rl.close();
479
+ }
480
+ }
481
+ async function ask(question) {
482
+ if (isDryRun()) {
483
+ console.log(`dry run: would ask: ${question}`);
484
+ return "<message>";
485
+ }
486
+ if (!process.stdin.isTTY)
487
+ throw new Error("needs a terminal to ask; pass -m <message>.");
488
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
489
+ try {
490
+ return (await rl.question(question)).trim();
491
+ } finally {
492
+ rl.close();
493
+ }
494
+ }
495
+ async function select(question, options, multi) {
496
+ if (!process.stdin.isTTY)
497
+ throw new Error("this needs a terminal to pick.");
498
+ return new Promise((resolve) => {
499
+ const stdin = process.stdin;
500
+ let index = 0;
501
+ let checked = new Set;
502
+ let drawnLines = 0;
503
+ const draw = () => {
504
+ if (drawnLines > 0)
505
+ process.stdout.write(`\x1B[${drawnLines}A\x1B[0J`);
506
+ const hint = multi ? "space checks, enter confirms, esc cancels" : "enter selects, esc cancels";
507
+ const lines = [`${question} (${hint})`];
508
+ options.forEach((option, i) => {
509
+ const cursor = i === index ? ">" : " ";
510
+ const mark = multi ? checked.has(i) ? "[x]" : "[ ]" : "";
511
+ lines.push(`${cursor} ${mark} ${option.label}`.replace(/ +$/, ""));
512
+ });
513
+ process.stdout.write(`${lines.join(`
514
+ `)}
515
+ `);
516
+ drawnLines = lines.length;
517
+ };
518
+ const finish = (picked) => {
519
+ stdin.off("keypress", onKey);
520
+ stdin.setRawMode(false);
521
+ stdin.pause();
522
+ process.stdout.write(`
523
+ `);
524
+ resolve(picked);
525
+ };
526
+ const onKey = (_text, key) => {
527
+ if (!key)
528
+ return;
529
+ if (key.ctrl && key.name === "c" || key.name === "escape") {
530
+ finish([]);
531
+ return;
532
+ }
533
+ if (key.name === "return") {
534
+ const picked = multi ? [...checked].sort((a, b) => a - b).map((i) => options[i]?.name ?? "") : [options[index]?.name ?? ""];
535
+ finish(picked.filter(Boolean));
536
+ return;
537
+ }
538
+ if (key.name === "up")
539
+ index = moveIndex(index, -1, options.length);
540
+ else if (key.name === "down")
541
+ index = moveIndex(index, 1, options.length);
542
+ else if (key.name === "space" && multi)
543
+ checked = toggled(checked, index);
544
+ draw();
545
+ };
546
+ emitKeypressEvents(stdin);
547
+ stdin.setRawMode(true);
548
+ stdin.resume();
549
+ stdin.on("keypress", onKey);
550
+ draw();
551
+ });
552
+ }
553
+
430
554
  // src/components/rules/rules.constants.ts
431
555
  var CONTAINER = /^(develop|main|release\/.+|hotfix\/.+)$/;
432
- var TARGET = /^(develop|release\/.+|hotfix\/.+)$/;
556
+ var TARGET = /^(main|develop|release\/.+|hotfix\/.+)$/;
433
557
 
434
558
  // src/components/rules/rules.ts
435
559
  function isContainer(name) {
@@ -478,7 +602,7 @@ function nextVersion(tags, bump) {
478
602
  }
479
603
 
480
604
  // src/components/flow/flow.utils.ts
481
- function newestContainer(kind) {
605
+ function containers(kind) {
482
606
  const prefix = `${kind}/`;
483
607
  const local = captureArgs([
484
608
  "for-each-ref",
@@ -489,15 +613,19 @@ function newestContainer(kind) {
489
613
  const origin = captureArgs(["ls-remote", "--heads", "origin"]).split(`
490
614
  `).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/heads\//, ""));
491
615
  const candidates = new Set([...local, ...origin].filter((name) => name.startsWith(prefix)));
492
- let newest;
616
+ const versioned = [];
493
617
  for (const name of candidates) {
494
618
  const version = parseSemver(name.slice(prefix.length));
495
- if (version && (!newest || compare(version, newest.version) > 0))
496
- newest = { name, version };
619
+ if (version)
620
+ versioned.push({ name, version });
497
621
  }
622
+ return versioned.sort((a, b) => compare(b.version, a.version)).map((item) => item.name);
623
+ }
624
+ function newestContainer(kind) {
625
+ const [newest] = containers(kind);
498
626
  if (!newest)
499
627
  throw new Error(`no ${kind} branch exists, locally or on origin.`);
500
- return newest.name;
628
+ return newest;
501
629
  }
502
630
  function nextContainerVersion(kind) {
503
631
  const bump = kind === "release" ? "minor" : "patch";
@@ -534,44 +662,70 @@ function create(flags) {
534
662
  }
535
663
  open(kind, value || nextContainerVersion(kind));
536
664
  }
537
- function merge(flags) {
665
+ async function merge(flags) {
666
+ if (flags.size === 0)
667
+ return mergePick();
538
668
  const kind = onlyOne(flags, [
669
+ "develop",
670
+ "main",
539
671
  "release",
540
672
  "hotfix",
541
673
  "current-release",
542
674
  "current-hotfix"
543
675
  ]);
544
676
  let target = "develop";
545
- if (kind === "current-release")
677
+ if (kind === "develop")
678
+ target = "develop";
679
+ else if (kind === "main")
680
+ target = "main";
681
+ else if (kind === "current-release")
546
682
  target = newestContainer("release");
547
683
  else if (kind === "current-hotfix")
548
684
  target = newestContainer("hotfix");
549
685
  else if (kind) {
550
686
  const version = flags.get(kind) ?? "";
551
- if (!version) {
552
- throw new Error(`usage: gith merge -${kind === "release" ? "r" : "h"} <version>`);
553
- }
554
- target = containerName(kind, version);
687
+ const picked = version === "" ? await pickContainer(kind) : containerName(kind, version);
688
+ if (picked === undefined)
689
+ return;
690
+ target = picked;
555
691
  }
556
- mergeInto(target);
692
+ await mergeInto(target);
693
+ }
694
+ async function pickContainer(kind) {
695
+ const names = containers(kind);
696
+ if (names.length === 0)
697
+ throw new Error(`no ${kind} branch exists, locally or on origin.`);
698
+ if (names.length === 1)
699
+ return names[0];
700
+ if (!process.stdin.isTTY) {
701
+ throw new Error(`several ${kind} branches exist; pass -${kind === "release" ? "r" : "h"} <version> instead; the list needs a terminal`);
702
+ }
703
+ const [picked] = await select(`Merge into which ${kind}?`, names.map((name) => ({ name, label: name })), false);
704
+ return picked;
705
+ }
706
+ function workBranchName(goal, from) {
707
+ return from === undefined ? `develop--${goal}` : goal;
557
708
  }
558
709
  function createBranch(goal, from) {
559
710
  if (isContainer(goal))
560
711
  throw new Error(`'${goal}' is a container name; pick a goal.`);
561
- if (hasOnOrigin(goal))
562
- throw new Error(`'${goal}' already exists on origin.`);
712
+ const name = workBranchName(goal, from);
713
+ if (hasOnOrigin(name))
714
+ throw new Error(`'${name}' already exists on origin.`);
563
715
  if (from === undefined) {
564
716
  gitArgs(["fetch", "origin", "develop"]);
565
- gitArgs(["branch", goal, "origin/develop"]);
717
+ gitArgs(["branch", name, "origin/develop"]);
566
718
  } else {
567
- gitArgs(["branch", goal, from]);
719
+ gitArgs(["branch", name, from]);
568
720
  }
569
- gitArgs(["push", "-u", "origin", goal]);
721
+ gitArgs(["push", "-u", "origin", name]);
722
+ return name;
570
723
  }
571
- function mergeInto(target) {
724
+ async function mergeInto(target, from) {
572
725
  if (!isTarget(target))
573
726
  throw new Error(`'${target}' is not a valid target.`);
574
- const source = capture("rev-parse --abbrev-ref HEAD");
727
+ const current = capture("rev-parse --abbrev-ref HEAD");
728
+ const source = from ?? current;
575
729
  if (isContainer(source))
576
730
  throw new Error(`'${source}' is a container and cannot be merged from.`);
577
731
  if (source === target)
@@ -579,6 +733,8 @@ function mergeInto(target) {
579
733
  if (!hasOnOrigin(target))
580
734
  throw new Error(`'${target}' does not exist on origin.`);
581
735
  assertCleanTree();
736
+ if (source !== current)
737
+ gitArgs(["switch", "--", source]);
582
738
  if (hasOnOrigin(source))
583
739
  git(`pull origin ${source}`);
584
740
  git(`push -u origin ${source}`);
@@ -598,44 +754,116 @@ function mergeInto(target) {
598
754
  assertSynced([target, source]);
599
755
  console.log(`
600
756
  ✅ '${source}' merged into '${target}', both in sync with origin.`);
757
+ await deleteMergedBranch(source);
601
758
  }
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 });
759
+ async function deleteMergedBranch(source) {
760
+ const local = hasLocal(source);
761
+ const origin = hasOnOrigin(source);
762
+ let yes;
619
763
  try {
620
- const answer = (await rl.question(question)).trim().toLowerCase();
621
- return answer === "" || answer === "y" || answer === "yes";
622
- } finally {
623
- rl.close();
764
+ yes = await confirm(`Delete '${source}' here and on origin, it is merged? [Y/n] `);
765
+ } catch {
766
+ yes = false;
767
+ }
768
+ if (!yes) {
769
+ console.log(`'${source}' kept.`);
770
+ return;
624
771
  }
772
+ if (local)
773
+ gitArgs(["branch", "-d", "--", source]);
774
+ if (origin)
775
+ gitArgs(["push", "origin", "--delete", source]);
625
776
  }
626
- async function ask(question) {
777
+ async function mergePick() {
778
+ if (!process.stdin.isTTY) {
779
+ throw new Error("pass -r <version> or -h <version> instead; the pickers need a terminal");
780
+ }
781
+ const current = capture("rev-parse --abbrev-ref HEAD");
782
+ const local = captureArgs([
783
+ "for-each-ref",
784
+ "--format=%(refname:short)",
785
+ "refs/heads"
786
+ ]).split(`
787
+ `).filter(Boolean);
788
+ const sources = local.filter((name) => !isContainer(name)).sort((a, b) => a.localeCompare(b)).map((name) => ({
789
+ name,
790
+ label: name === current ? `${name} (current)` : name
791
+ }));
792
+ if (sources.length === 0)
793
+ throw new Error("no work branch to merge.");
794
+ const [source] = await select("Which branch to merge?", sources, false);
795
+ if (source === undefined)
796
+ return;
797
+ const origin = captureArgs(["ls-remote", "--heads", "origin"]).split(`
798
+ `).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/heads\//, "")).filter(Boolean);
799
+ const targets = [...new Set([...local, ...origin])].filter((name) => isTarget(name) && name !== source).sort((a, b) => a.localeCompare(b)).map((name) => ({
800
+ name,
801
+ label: local.includes(name) ? name : `${name} (origin)`
802
+ }));
803
+ if (targets.length === 0)
804
+ throw new Error("no develop, release or hotfix branch to merge into.");
805
+ const [target] = await select(`Merge '${source}' into which?`, targets, false);
806
+ if (target === undefined)
807
+ return;
808
+ await mergeInto(target, source);
809
+ }
810
+ function alignVersion(version) {
811
+ if (!existsSync2("package.json"))
812
+ return;
813
+ const current = parsePackageVersion(readFileSync2("package.json", "utf8"));
814
+ if (current === version)
815
+ return;
627
816
  if (isDryRun()) {
628
- console.log(`dry run: would ask: ${question}`);
629
- return "<message>";
817
+ console.log(`> npm version ${version} --no-git-tag-version --ignore-scripts`);
818
+ } else {
819
+ execSync(`npm version ${version} --no-git-tag-version --ignore-scripts`, {
820
+ stdio: "inherit"
821
+ });
630
822
  }
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 });
823
+ gitArgs(["add", "--", "package.json"]);
824
+ gitArgs(["commit", "-m", version, "--", "package.json"]);
825
+ }
826
+ function addChangelogEntry(version) {
827
+ if (!existsSync2("package.json"))
828
+ return;
829
+ const file = "CHANGELOG.md";
830
+ if (existsSync2(file) && readFileSync2(file, "utf8").includes(`## ${version} - `))
831
+ return;
832
+ changelog(new Map);
833
+ }
834
+ async function finish(flags) {
835
+ const kind = onlyOne(flags, ["current-release", "current-hotfix"]);
836
+ if (!kind || flags.size > 1)
837
+ throw new Error("usage: gith finish --current-release | --current-hotfix");
838
+ const containerKind = kind === "current-release" ? "release" : "hotfix";
839
+ const branch = newestContainer(containerKind);
840
+ const version = branch.slice(containerKind.length + 1);
841
+ if (parseSemver(version) === undefined)
842
+ throw new Error(`'${branch}' is not named with a version (X.Y.Z).`);
843
+ assertCleanTree();
844
+ gitArgs(["switch", "--", branch]);
845
+ gitArgs(["pull", "--ff-only", "origin", branch]);
846
+ alignVersion(version);
847
+ addChangelogEntry(version);
848
+ gitArgs(["switch", "--", "main"]);
849
+ gitArgs(["pull", "--ff-only", "origin", "main"]);
634
850
  try {
635
- return (await rl.question(question)).trim();
636
- } finally {
637
- rl.close();
851
+ gitArgs(["merge", "--no-ff", branch, "-m", `Merge '${branch}' into main`]);
852
+ } catch {
853
+ gitArgs(["merge", "--abort"]);
854
+ throw new Error(`merging '${branch}' into main conflicts; resolve it by hand, then retry.`);
855
+ }
856
+ gitArgs(["tag", "-a", `v${version}`, "-m", `v${version}`]);
857
+ gitArgs(["push", "origin", "main"]);
858
+ gitArgs(["push", "origin", `v${version}`]);
859
+ if (hasOnOrigin("develop")) {
860
+ gitArgs(["switch", "--", "develop"]);
861
+ gitArgs(["pull", "--ff-only", "origin", "develop"]);
862
+ gitArgs(["merge", "--no-edit", "main"]);
863
+ gitArgs(["push", "origin", "develop"]);
638
864
  }
865
+ gitArgs(["switch", "--", "main"]);
866
+ await deleteMergedBranch(branch);
639
867
  }
640
868
 
641
869
  // src/components/history/history.utils.ts
@@ -690,10 +918,12 @@ function undo(flags) {
690
918
  }
691
919
  async function save(flags) {
692
920
  const message = flags.get("message");
693
- const extra = [...flags.keys()].filter((key) => key !== "message");
921
+ const extra = [...flags.keys()].filter((key) => key !== "message" && key !== "all");
694
922
  if (extra.length > 0)
695
- throw new Error("usage: gith save [-m <message>]");
923
+ throw new Error("usage: gith save [-m <message>] [--all]");
696
924
  const branch = currentBranch();
925
+ if (flags.has("all"))
926
+ gitArgs(["add", "-A"]);
697
927
  if (captureArgs(["diff", "--cached", "--name-only"]) === "") {
698
928
  throw new Error("nothing staged; stage the changes to add to the last commit.");
699
929
  }
@@ -862,7 +1092,7 @@ function stashes(limit, withGorlum) {
862
1092
  ]).split(`
863
1093
  `).filter(Boolean);
864
1094
  if (rows.length === 0) {
865
- console.log("No stashes.");
1095
+ console.log(withGorlum ? "There is nothing in my pockets!" : "No stashes.");
866
1096
  return;
867
1097
  }
868
1098
  const shown = rows.slice(0, limit);
@@ -893,9 +1123,6 @@ function originTags() {
893
1123
  `).filter(Boolean).map((line) => (line.split("\t")[1] ?? "").replace(/^refs\/tags\//, "")).filter((ref) => ref && !ref.endsWith("^{}"));
894
1124
  }
895
1125
 
896
- // src/components/maintenance/maintenance.ts
897
- import { createInterface as createInterface2 } from "node:readline/promises";
898
-
899
1126
  // src/components/maintenance/maintenance.constants.ts
900
1127
  var DEFAULT_DAYS = 90;
901
1128
 
@@ -944,7 +1171,21 @@ function parsePositive(flags, name) {
944
1171
  }
945
1172
 
946
1173
  // src/components/maintenance/maintenance.ts
947
- function switchBranch(flags) {
1174
+ async function switchBranch(flags) {
1175
+ if (flags.size === 0)
1176
+ return switchPick();
1177
+ const kind = onlyOne(flags, ["release", "hotfix"]);
1178
+ if (kind !== undefined) {
1179
+ if (flags.size > 1)
1180
+ throw new Error(`usage: gith switch --${kind} | -${kind === "release" ? "r" : "h"} <version>`);
1181
+ const version = flags.get(kind) ?? "";
1182
+ const name = version === "" ? newestContainer(kind) : containerName(kind, version);
1183
+ if (!hasLocal(name) && !hasOnOrigin(name)) {
1184
+ throw new Error(`'${name}' does not exist, locally or on origin.`);
1185
+ }
1186
+ gitArgs(["switch", "--", name]);
1187
+ return;
1188
+ }
948
1189
  const commit = flags.get("commit");
949
1190
  if (commit !== undefined) {
950
1191
  if (flags.has("branch") || flags.size > 1)
@@ -953,10 +1194,14 @@ function switchBranch(flags) {
953
1194
  return;
954
1195
  }
955
1196
  const branch = requireBranch(flags, "switch");
956
- if (!hasLocal(branch) && !hasOnOrigin(branch)) {
957
- createBranch(branch, isDetached() ? "HEAD" : undefined);
1197
+ const from = isDetached() ? "HEAD" : undefined;
1198
+ const exists = (name) => hasLocal(name) || hasOnOrigin(name);
1199
+ let name = branch;
1200
+ if (!exists(branch)) {
1201
+ const made = workBranchName(branch, from);
1202
+ name = exists(made) ? made : createBranch(branch, from);
958
1203
  }
959
- gitArgs(["switch", "--", branch]);
1204
+ gitArgs(["switch", "--", name]);
960
1205
  }
961
1206
  function candidates(scope, local, remote, current) {
962
1207
  const notContainer = (name) => !isContainer(name);
@@ -969,20 +1214,6 @@ function candidates(scope, local, remote, current) {
969
1214
  }
970
1215
  return local.filter((name) => notContainer(name) && notCurrent(name) && remote.includes(name)).map((name) => ({ name, label: `${name} (local + origin)` }));
971
1216
  }
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
1217
  function branchTimes(output) {
987
1218
  const times = new Map;
988
1219
  for (const line of output.split(`
@@ -1028,7 +1259,7 @@ function detachedOptions(times, current) {
1028
1259
  return options.sort((x, y) => x.name.localeCompare(y.name));
1029
1260
  }
1030
1261
  async function remove(flags) {
1031
- const name = flags.get("branch");
1262
+ const name = flags.get("branch") ?? "";
1032
1263
  const side = onlyOne(flags, ["local", "remote"]);
1033
1264
  const scope = side ?? "pair";
1034
1265
  const obsoleteOn = flags.has("obsolete");
@@ -1041,14 +1272,12 @@ async function remove(flags) {
1041
1272
  throw new Error("--all only applies to --detached.");
1042
1273
  if (obsoleteOn || detachedOn) {
1043
1274
  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)
1275
+ if (flags.has("branch") || obsoleteOn && side === "remote" || detachedOn && side)
1045
1276
  throw new Error(usage);
1046
1277
  if (detachedOn)
1047
1278
  return detached(flags);
1048
1279
  return obsolete(flags, side === "local" ? "local" : "pair");
1049
1280
  }
1050
- if (name === undefined)
1051
- throw new Error("usage: gith remove -b <branch> [--local | --remote], or gith remove --branch to pick");
1052
1281
  if (name) {
1053
1282
  if (flags.has("limit"))
1054
1283
  throw new Error("-limit only applies to listings: gith remove --branch.");
@@ -1103,25 +1332,49 @@ async function clean(flags) {
1103
1332
  }
1104
1333
  gitArgs(["clean", "-fd"]);
1105
1334
  }
1106
- async function choose(options, limit) {
1335
+ var REMOVE_ACTION = {
1336
+ empty: "No branches to remove.",
1337
+ prompt: "Remove which?",
1338
+ multi: true
1339
+ };
1340
+ async function choose(options, limit, action = REMOVE_ACTION) {
1107
1341
  if (options.length === 0) {
1108
- console.log("No branches to remove.");
1342
+ console.log(action.empty);
1109
1343
  return [];
1110
1344
  }
1111
1345
  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
1346
  if (shown.length < options.length) {
1116
1347
  console.log(`showing ${shown.length} of ${options.length}; -limit <n> shows more`);
1117
1348
  }
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();
1349
+ return select(action.prompt, shown, action.multi);
1350
+ }
1351
+ function switchOptions() {
1352
+ const local = localBranches();
1353
+ const origin = remoteBranches();
1354
+ const current = currentBranch2();
1355
+ const names = [...new Set([...local, ...origin])].sort((a, b) => a.localeCompare(b));
1356
+ return names.map((name) => {
1357
+ const where = local.includes(name) && origin.includes(name) ? "local + origin" : local.includes(name) ? "local" : "origin";
1358
+ return {
1359
+ name,
1360
+ label: `${name} (${where}${name === current ? ", current" : ""})`
1361
+ };
1362
+ });
1363
+ }
1364
+ async function switchPick() {
1365
+ if (!process.stdin.isTTY) {
1366
+ throw new Error("pass a branch name with -b, e.g. gith switch -b <branch>; the list needs a terminal");
1124
1367
  }
1368
+ const picked = await choose(switchOptions(), undefined, {
1369
+ empty: "No branches.",
1370
+ prompt: "Switch to which?",
1371
+ multi: false
1372
+ });
1373
+ if (picked.length === 0)
1374
+ return;
1375
+ if (picked.length > 1)
1376
+ throw new Error("pick exactly one branch to switch to.");
1377
+ gitArgs(["switch", "--", picked[0] ?? ""]);
1125
1378
  }
1126
1379
  function requireTerminal() {
1127
1380
  if (!process.stdin.isTTY) {
@@ -1147,14 +1400,14 @@ async function removeBranches(scope, names, ask = true) {
1147
1400
  });
1148
1401
  if (plan.length > 0)
1149
1402
  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
1403
  for (const { name, local, origin } of plan) {
1155
1404
  const sides = [local && "local", origin && "origin"].filter(Boolean).join(" + ");
1156
1405
  console.log(` ${name} (${sides})`);
1157
1406
  }
1407
+ if (ask && plan.length > 0 && !await confirm(plan.length === 1 ? "Remove this branch? [Y/n] " : `Remove these ${plan.length} branches? [Y/n] `)) {
1408
+ console.log("Nothing removed.");
1409
+ return;
1410
+ }
1158
1411
  for (const { name, local, origin } of plan) {
1159
1412
  if (local)
1160
1413
  gitArgs(["branch", "-d", "--", name]);
@@ -1264,12 +1517,12 @@ function openerFor(platform, url) {
1264
1517
  var KINDS = ["patch", "minor", "major"];
1265
1518
 
1266
1519
  // src/components/release/release.utils.ts
1267
- import { execSync } from "node:child_process";
1520
+ import { execSync as execSync2 } from "node:child_process";
1268
1521
  function npmVersion(kind) {
1269
1522
  console.log(`> npm version ${kind}`);
1270
1523
  if (isDryRun())
1271
1524
  return;
1272
- execSync(`npm version ${kind}`, { stdio: "inherit" });
1525
+ execSync2(`npm version ${kind}`, { stdio: "inherit" });
1273
1526
  }
1274
1527
 
1275
1528
  // src/components/release/release.ts
@@ -1660,7 +1913,7 @@ function tree(flags) {
1660
1913
  }
1661
1914
 
1662
1915
  // src/components/version/version.ts
1663
- import { readFileSync as readFileSync3 } from "node:fs";
1916
+ import { readFileSync as readFileSync4 } from "node:fs";
1664
1917
 
1665
1918
  // src/components/version/version.constants.ts
1666
1919
  var MASCOT = [
@@ -1675,15 +1928,15 @@ var MASCOT = [
1675
1928
  ];
1676
1929
 
1677
1930
  // src/components/version/version.utils.ts
1678
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1931
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1679
1932
  import path2 from "node:path";
1680
1933
  import { fileURLToPath } from "node:url";
1681
1934
  function packageJsonPath() {
1682
1935
  let dir = path2.dirname(fileURLToPath(import.meta.url));
1683
1936
  for (;; ) {
1684
1937
  const candidate = path2.join(dir, "package.json");
1685
- if (existsSync2(candidate)) {
1686
- const pkg = JSON.parse(readFileSync2(candidate, "utf8"));
1938
+ if (existsSync3(candidate)) {
1939
+ const pkg = JSON.parse(readFileSync3(candidate, "utf8"));
1687
1940
  if (pkg.name === "@tertium/gith")
1688
1941
  return candidate;
1689
1942
  }
@@ -1696,7 +1949,7 @@ function packageJsonPath() {
1696
1949
 
1697
1950
  // src/components/version/version.ts
1698
1951
  function readVersion() {
1699
- const pkg = JSON.parse(readFileSync3(packageJsonPath(), "utf8"));
1952
+ const pkg = JSON.parse(readFileSync4(packageJsonPath(), "utf8"));
1700
1953
  return pkg.version ?? "unknown";
1701
1954
  }
1702
1955
  function versionText() {
@@ -1710,21 +1963,27 @@ function versionText() {
1710
1963
  var USAGE = `gith - git helper: branch flow and everyday git commands
1711
1964
 
1712
1965
  Start and finish work
1713
- gith create -b <branch> work branch from develop, named by its goal (no switch)
1966
+ gith create -b <branch> work branch develop--<branch> from develop (no switch)
1714
1967
  gith create -r <version> release/<version> from develop
1715
1968
  gith create -h <version> hotfix/<version> from main
1716
1969
  gith create --release | --hotfix version from the latest tag: minor for release, patch for hotfix
1717
- gith switch -b <branch> switch to a branch, creating it from develop if missing (on a detached HEAD: from that commit)
1970
+ gith switch list local and origin branches, and switch to the one picked
1971
+ gith switch -b <branch> switch to a branch, creating develop--<branch> from develop if missing (on a detached HEAD: <branch> from that commit)
1718
1972
  gith switch -commit <sha> detach HEAD at a commit, to look at it (tracked files must be clean)
1973
+ gith switch --release | --hotfix switch to the newest release/* or hotfix/* (local or origin); -r / -h do the same; -r <v> / -h <v> for one version
1719
1974
  gith update merge origin/develop into this work branch
1720
- gith merge [-r <v> | -h <v>] merge this branch into develop, release/<v> or hotfix/<v>
1975
+ gith finish --current-release | --current-hotfix release the newest release/* or hotfix/*: version, changelog, merge to main, tag, back to develop
1976
+ gith merge --develop merge this branch into develop
1977
+ gith merge --main merge this branch into main
1978
+ gith merge pick the branch to merge, then the target (main, develop, release/*, hotfix/*)
1979
+ gith merge [-r <v> | -h <v>] merge this branch into release/<v> or hotfix/<v>; without a version, the only one or a pick from the list
1721
1980
  gith merge --current-release merge this branch into the newest release (local or origin)
1722
1981
  gith merge --current-hotfix merge this branch into the newest hotfix (local or origin)
1723
1982
  gith release --patch | --minor | --major bump the version and publish
1724
1983
 
1725
1984
  Fix history
1726
1985
  gith undo undo the last commit, keep its changes staged (not if pushed)
1727
- gith save [-m <msg>] fold staged changes into the last commit, or commit them if the branch has none yet (asks for a message); pushes if origin differs (asks before a force-push)
1986
+ gith save [-m <msg>] [--all] fold staged changes (--all stages every change first) into the last commit, or commit them if the branch has none yet (asks for a message); pushes if origin differs (asks before a force-push)
1728
1987
  gith squash -limit <n> [-m <msg>] combine the last n commits into one (keeps the oldest message)
1729
1988
 
1730
1989
  Save and reset work
@@ -1737,7 +1996,8 @@ Clean up branches (every removal shows what it will remove first)
1737
1996
  gith remove -b <branch> delete the branch here and on origin (either side may be missing)
1738
1997
  gith remove -b <branch> --local delete only the local branch
1739
1998
  gith remove -b <branch> --remote delete only the origin branch
1740
- gith remove --branch [-l <n>] pick from branch pairs (here and on origin)
1999
+ gith remove pick branches to delete from a list (here and on origin)
2000
+ gith remove --branch [-l <n>] same list, for branch pairs (here and on origin)
1741
2001
  gith remove --branch --local pick from local branches
1742
2002
  gith remove --branch --remote pick from origin branches
1743
2003
  gith remove --obsolete [--local] [-days <n>] remove branches untouched for 90 days, after Y/n
@@ -1794,11 +2054,14 @@ try {
1794
2054
  case "create":
1795
2055
  create(parseFlags(rest));
1796
2056
  break;
2057
+ case "finish":
2058
+ await finish(parseFlags(rest));
2059
+ break;
1797
2060
  case "merge":
1798
- merge(parseFlags(rest));
2061
+ await merge(parseFlags(rest));
1799
2062
  break;
1800
2063
  case "switch":
1801
- switchBranch(parseFlags(rest));
2064
+ await switchBranch(parseFlags(rest));
1802
2065
  break;
1803
2066
  case "remove":
1804
2067
  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.1",
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,10 @@
15
15
  "test": "bun test --no-coverage",
16
16
  "lint": "biome check src",
17
17
  "lint:fix": "biome check --write src",
18
+ "format": "biome format --write src",
19
+ "format:check": "biome format src",
18
20
  "typecheck": "tsc --noEmit",
21
+ "prepare": "git config core.hooksPath .githooks || true",
19
22
  "release:patch": "node bin/gith.js release --patch",
20
23
  "release:minor": "node bin/gith.js release --minor",
21
24
  "release:major": "node bin/gith.js release --major",