@tertium/gith 0.3.1 → 0.3.3

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 (4) hide show
  1. package/LICENSE +18 -12
  2. package/README.md +12 -30
  3. package/bin/gith.js +82 -32
  4. package/package.json +2 -2
package/LICENSE CHANGED
@@ -1,15 +1,21 @@
1
- ISC License
1
+ MIT License
2
2
 
3
3
  Copyright (c) 2026 Vitalii Balabanov
4
4
 
5
- Permission to use, copy, modify, and/or distribute this software for any
6
- purpose with or without fee is hereby granted, provided that the above
7
- copyright notice and this permission notice appear in all copies.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
- ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
- ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
- OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # gith
1
+ # @tertium/gith
2
+
3
+ gith: git-only branch flow CLI (goal branches, release/hotfix containers, synced merges).
2
4
 
3
5
  ```
4
6
  o
@@ -15,19 +17,6 @@ Git helper: the branch flow from [git-convention](https://github.com/Tertiumnon/
15
17
 
16
18
  > **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
19
 
18
- ## Install
19
-
20
- Needs [Node.js](https://nodejs.org) 18 or newer and git on the PATH.
21
-
22
- ```bash
23
- npm install -g @tertium/gith
24
- gith --version
25
- ```
26
-
27
- From a clone of this repository, build the CLI and link it: `bun install`, `bun run build`, `npm link`.
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
-
31
20
  ## Usage
32
21
 
33
22
  ```bash
@@ -58,7 +47,7 @@ gith release --major # npm version major, from develop into main
58
47
 
59
48
  # Fix history
60
49
  gith undo # undo the last commit, keep its changes staged (not if pushed)
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
50
+ gith save [-m "msg"] [--all] # stages unstaged changes first (--all without asking), commits locally, asks to amend the previous commit or to make a new one, then asks to push (force-push after an amend)
62
51
  gith squash -limit 3 [-m "msg"] # combine the last 3 commits into one (keeps the oldest message)
63
52
 
64
53
  # Save and reset work
@@ -100,7 +89,9 @@ gith stat # fun facts about this branch: biggest commi
100
89
  gith stat --last-week -limit 50 # the same for last week, looking at no more than 50 commits
101
90
 
102
91
  # Remote and health
103
- gith sync # fetch all remotes and tags, prune stale refs
92
+ gith sync # fetch all remotes and tags, prune stale refs, then sync this branch with origin both ways
93
+ gith sync --develop | --main # the same for develop or main
94
+ gith sync develop:develop # two-way sync of one branch with origin
104
95
  gith open # open origin in the browser
105
96
  gith doctor # check the repo is ready for the branch flow
106
97
  gith changelog --dry-run # preview the CHANGELOG.md entry for package.json's version
@@ -158,7 +149,7 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
158
149
  - **`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.
159
150
  - **`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.
160
151
  - **`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.
161
- - **`save`** saves the staged changes. If the branch has commits of its own (commits origin's `develop`, or `main`, lacks), it folds them into the last commit with `git commit --amend --no-edit`, keeping its message. `-m` replaces the message. If the branch has no commits of its own, it never touches the base commit: it creates a new commit, asking for its message (an empty answer is refused, and `-m` skips the question). It refuses with nothing staged. It pushes only when origin already has this branch and its copy differs from the new commit: a plain `git push origin <branch>` when the commit only moves forward, or, when a pushed commit was rewritten, a `Save it and force-push ...? [Y/n]` question (Enter means yes; a no changes nothing) followed by `git push --force-with-lease origin <branch>`, which refuses if origin moved since your last fetch. If origin has no such branch, the save stays local. The questions need a terminal, so a script cannot force-push or commit by accident.
152
+ - **`save`** commits the staged changes locally and never pushes without asking. When changes are not staged yet, it stages them first: a single file is staged silently, and with several it asks `Stage all N changes first? [Y/n]` (a no keeps only what is staged; `--all` stages without asking). If the branch has commits of its own (commits origin's `develop`, or `main`, lacks), it asks `Amend the previous commit? [Y/n]`. Yes folds the changes into the last commit with `git commit --amend --no-edit`, keeping its message; `-m` replaces it. No, or no own commits, creates a new commit, asking for its message (an empty answer is refused, and `-m` skips the question). It refuses with nothing staged. Then it asks `Push <branch> to origin? [Y/n]`, a plain `git push -u origin <branch>`. After an amend of a commit origin already has, it asks `Force-push <branch> to origin? [Y/n]` instead, which runs `git push --force-with-lease origin <branch>` and refuses if origin moved since your last fetch. A no leaves the commit local. The questions need a terminal, so a script must pass `--yes`, and a commit made before the push question stays local if that question is not answered.
162
153
  - **`stash`** saves changes with `git stash push --include-untracked`. `-n` names the stash. It refuses when there is nothing to save.
163
154
  - **`find`** (also `search`) lists the newest commits on this branch that match every filter given:
164
155
  - `-commit <text>` matches the commit message, ignoring case, and the text is literal.
@@ -176,7 +167,8 @@ Commands that choose between flags (for example `create -b`, `-r` and `-h`, or `
176
167
  - **Every removal shows what it will remove** before deleting anything, including the direct `remove -b` form. The picker and the prompt both show it first.
177
168
  - **`open`** opens origin in the browser, turning an `ssh` or `https` remote into a web address. GitHub and GitLab style addresses work.
178
169
  - **`doctor`** checks git, a repository, origin, its reachability, `main` and `develop` on origin, and a clean working tree. It exits 1 if a required check fails, and only warns about the tree.
179
- - **`sync`** runs `git fetch --all --tags --prune`.
170
+ - **`sync`** runs `git fetch --all --tags --prune`, then syncs the current branch with origin in both directions. `sync --develop` and `sync --main` do the same for that branch, even when another branch is checked out.
171
+ - **`sync <branch>:<branch>`** (for example `sync develop:develop`) is the same as `sync --develop` for any branch name. If origin is ahead, it fetches the branch like `git fetch origin develop:develop`; if local is ahead, it pushes it; if it is the same, it says so. A checked-out branch is fast-forwarded with `merge --ff-only`. Diverged branches stop with an error, and nothing is forced. A branch missing on one side is created there. `sync develop` alone works too.
180
172
  - **`tree`** lists the working trees of the repository (`git worktree list`): each path, its branch or `(detached)`, and its short HEAD. The current one is marked `*`, and bare, locked or prunable trees are noted. `-limit <n>` caps the rows; all are shown by default.
181
173
  - **`list --tags`** shows every tag once, with yes/no columns for local and origin, so you can see which tags are unpushed or missing locally. Version tags are listed newest first, and only the first 5 are shown by default. `-limit <n>` (or `-l <n>`) changes that and adds a note when rows are left out.
182
174
  - **`list --commits`** shows the newest commits on the current branch, one per line: short hash, date, author and subject. With `--graph` it draws the branch graph instead. It shows 5 by default; `-l <n>` (or `-limit <n>`) changes that and must be a positive whole number. `-limit` only applies to listings. `gith list` with no flag prints usage.
@@ -208,16 +200,6 @@ Both need a clean working tree and a `package.json` with a version, because they
208
200
 
209
201
  Each failure exits with code 1 and a message. Conflicts leave you on the work branch with the merge aborted, so you can resolve and retry.
210
202
 
211
- ## Development
212
-
213
- ```bash
214
- bun install
215
- bun run test # unit tests
216
- bun run lint # biome check
217
- bun run lint:fix # biome check --write
218
- bun run typecheck # tsc --noEmit
219
- ```
220
-
221
- ## Publishing
203
+ ## Support
222
204
 
223
- The package ships one built file, `bin/gith.js`, which runs on Node. `npm pack --dry-run` lists what would be published. `prepublishOnly` runs lint, typecheck, the tests and the build before any publish. `.github/workflows/publish.yml` runs the same checks, then `npm publish --access public`, whenever a `v*` tag is pushed. It uses npm trusted publishing (OIDC), so no npm token is stored in the repo. Bump the version with `gith release` or `npm version`, which creates the tag.
205
+ If this project is useful to you, you can support its development on [Ko-fi](https://ko-fi.com/tertiumnon).
package/bin/gith.js CHANGED
@@ -168,6 +168,14 @@ function capture(args) {
168
168
  function hasOnOrigin(branch) {
169
169
  return capture(`ls-remote --heads origin ${branch}`) !== "";
170
170
  }
171
+ function isAncestor(ancestor, descendant) {
172
+ try {
173
+ captureArgs(["merge-base", "--is-ancestor", ancestor, descendant]);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
171
179
  function listTags() {
172
180
  const local = capture("tag --list").split(`
173
181
  `);
@@ -466,8 +474,10 @@ async function confirm(question) {
466
474
  console.log(`dry run: would ask: ${question}`);
467
475
  return true;
468
476
  }
469
- if (assumeYes)
477
+ if (assumeYes) {
478
+ console.log(`${question}yes (--yes)`);
470
479
  return true;
480
+ }
471
481
  if (!process.stdin.isTTY)
472
482
  throw new Error("this needs a terminal to confirm; pass --yes to skip the question.");
473
483
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -922,43 +932,36 @@ async function save(flags) {
922
932
  if (extra.length > 0)
923
933
  throw new Error("usage: gith save [-m <message>] [--all]");
924
934
  const branch = currentBranch();
925
- if (flags.has("all"))
935
+ const unstaged = captureArgs(["add", "-A", "--dry-run"]).split(`
936
+ `).filter(Boolean);
937
+ const stageAll = flags.has("all") || unstaged.length === 1 || unstaged.length > 1 && await confirm(`Stage all ${unstaged.length} changes first? [Y/n] `);
938
+ if (stageAll && unstaged.length > 0) {
939
+ console.log(`Staging ${unstaged.length} unstaged change(s):`);
940
+ for (const line of unstaged)
941
+ console.log(` ${line}`);
942
+ }
943
+ if (stageAll)
926
944
  gitArgs(["add", "-A"]);
927
945
  if (captureArgs(["diff", "--cached", "--name-only"]) === "") {
928
- throw new Error("nothing staged; stage the changes to add to the last commit.");
929
- }
930
- const owned = hasOwnCommit();
931
- const pushed = captureArgs(["branch", "-r", "--contains", "HEAD"]) !== "";
932
- const rewrite = owned && pushed;
933
- const remote = remoteSha(branch);
934
- if (rewrite && remote === "") {
935
- throw new Error("HEAD is already on origin under another branch; changing it would need a force-push.");
936
- }
937
- if (rewrite) {
938
- const yes = await confirm(`HEAD is already on origin/${branch}. Save it and force-push ${branch}? [Y/n] `);
939
- if (!yes) {
940
- console.log("save cancelled; nothing changed.");
941
- return;
942
- }
946
+ throw new Error("nothing staged; stage the changes to save.");
943
947
  }
944
- if (owned) {
948
+ const previous = captureArgs(["rev-parse", "HEAD"]);
949
+ const amend = hasOwnCommit() && await confirm("Amend the previous commit? [Y/n] ");
950
+ if (amend) {
945
951
  gitArgs(message ? ["commit", "--amend", "-m", message] : ["commit", "--amend", "--no-edit"]);
946
952
  } else {
947
- const text = message ?? await askMessage();
948
- gitArgs(["commit", "-m", text]);
953
+ gitArgs(["commit", "-m", message ?? await askMessage()]);
949
954
  }
950
- if (remote === "") {
951
- console.log(`origin has no ${branch}; not pushed.`);
955
+ const rewrites = amend && remoteSha(branch) === previous;
956
+ const question = rewrites ? `Force-push ${branch} to origin? [Y/n] ` : `Push ${branch} to origin? [Y/n] `;
957
+ if (!await confirm(question)) {
958
+ console.log("Saved locally; not pushed.");
952
959
  return;
953
960
  }
954
- if (!isDryRun() && captureArgs(["rev-parse", "HEAD"]) === remote) {
955
- console.log("origin already has this commit; not pushed.");
956
- return;
957
- }
958
- if (rewrite)
961
+ if (rewrites)
959
962
  gitArgs(["push", "--force-with-lease", "origin", branch]);
960
963
  else
961
- gitArgs(["push", "origin", branch]);
964
+ gitArgs(["push", "-u", "origin", branch]);
962
965
  }
963
966
  async function squash(flags) {
964
967
  const count = parseLimit(flags);
@@ -1291,8 +1294,50 @@ async function remove(flags) {
1291
1294
  await removeBranches(scope, await pick(scope, parseLimit(flags)));
1292
1295
  }
1293
1296
  function sync(flags) {
1294
- noFlags2(flags, "sync");
1297
+ const kind = onlyOne(flags, ["develop", "main"]);
1298
+ const extra = [...flags.keys()].filter((key) => key !== kind);
1299
+ if (extra.length > 0)
1300
+ throw new Error("usage: gith sync [--develop | --main]");
1301
+ const name = kind ?? currentBranch2();
1302
+ if (name === "HEAD")
1303
+ throw new Error("HEAD is detached; check out a branch, or use gith sync --develop | --main.");
1304
+ syncWithOrigin(name);
1305
+ }
1306
+ function syncBranch(args) {
1307
+ const [spec = ""] = args;
1308
+ const [name = "", other = name] = spec.split(":");
1309
+ if (args.length !== 1 || other !== name || name === "")
1310
+ throw new Error("usage: gith sync <branch>:<branch>, the same name on both sides.");
1311
+ syncWithOrigin(name);
1312
+ }
1313
+ function syncWithOrigin(name) {
1314
+ assertBranchName(name);
1295
1315
  gitArgs(["fetch", "--all", "--tags", "--prune"]);
1316
+ const local = hasLocal(name);
1317
+ const remote = hasOnOrigin(name);
1318
+ if (!local && !remote)
1319
+ throw new Error(`'${name}' does not exist, locally or on origin.`);
1320
+ if (!remote) {
1321
+ gitArgs(["push", "origin", `${name}:${name}`]);
1322
+ return;
1323
+ }
1324
+ if (!local) {
1325
+ gitArgs(["fetch", "origin", `${name}:${name}`]);
1326
+ return;
1327
+ }
1328
+ const here = capture(`rev-parse refs/heads/${name}`);
1329
+ const there = capture(`rev-parse refs/remotes/origin/${name}`);
1330
+ if (here === there) {
1331
+ console.log(`'${name}' is already in sync with origin.`);
1332
+ } else if (isAncestor(there, here)) {
1333
+ gitArgs(["push", "origin", `${name}:${name}`]);
1334
+ } else if (!isAncestor(here, there)) {
1335
+ throw new Error(`'${name}' has diverged from origin/${name}; merge or rebase one side first.`);
1336
+ } else if (currentBranch2() === name) {
1337
+ gitArgs(["merge", "--ff-only", `origin/${name}`]);
1338
+ } else {
1339
+ gitArgs(["fetch", "origin", `${name}:${name}`]);
1340
+ }
1296
1341
  }
1297
1342
  async function reset(flags) {
1298
1343
  noFlags2(flags, "reset");
@@ -1983,7 +2028,7 @@ Start and finish work
1983
2028
 
1984
2029
  Fix history
1985
2030
  gith undo undo the last commit, keep its changes staged (not if pushed)
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)
2031
+ gith save [-m <msg>] [--all] commit staged changes locally; stages unstaged changes first (one silently, several after asking; --all without asking), then asks to amend the previous commit (else asks for a message), then asks to push, or to force-push after an amend
1987
2032
  gith squash -limit <n> [-m <msg>] combine the last n commits into one (keeps the oldest message)
1988
2033
 
1989
2034
  Save and reset work
@@ -2018,7 +2063,9 @@ Look at the repo
2018
2063
  period: --overall (default), --current-year|-month|-week, --last-year|-month|-week
2019
2064
 
2020
2065
  Remote and health
2021
- gith sync fetch all remotes and tags, prune stale refs
2066
+ gith sync fetch all remotes and tags, prune stale refs, then sync this branch with origin both ways
2067
+ gith sync --develop | --main the same for develop or main
2068
+ gith sync develop:develop two-way sync of one branch with origin: fetch it when behind, push it when ahead, stop when diverged
2022
2069
  gith open open origin in the browser
2023
2070
  gith doctor check the repo is ready for the branch flow (exit 1 on failures)
2024
2071
  gith changelog [--dry-run] add an entry for package.json's version to CHANGELOG.md (commits since the last tag)
@@ -2068,7 +2115,10 @@ try {
2068
2115
  await remove(parseFlags(rest));
2069
2116
  break;
2070
2117
  case "sync":
2071
- sync(parseFlags(rest));
2118
+ if (rest[0] !== undefined && !rest[0].startsWith("-"))
2119
+ syncBranch(rest);
2120
+ else
2121
+ sync(parseFlags(rest));
2072
2122
  break;
2073
2123
  case "clean":
2074
2124
  await clean(parseFlags(rest));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertium/gith",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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": {
@@ -24,7 +24,7 @@
24
24
  "release:major": "node bin/gith.js release --major",
25
25
  "version": "bun run build && node bin/gith.js changelog"
26
26
  },
27
- "license": "ISC",
27
+ "license": "MIT",
28
28
  "author": "Vitalii Balabanov",
29
29
  "repository": {
30
30
  "type": "git",