@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.
- package/LICENSE +18 -12
- package/README.md +12 -30
- package/bin/gith.js +82 -32
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2026 Vitalii Balabanov
|
|
4
4
|
|
|
5
|
-
Permission
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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`**
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
gitArgs(["commit", "-m", text]);
|
|
953
|
+
gitArgs(["commit", "-m", message ?? await askMessage()]);
|
|
949
954
|
}
|
|
950
|
-
|
|
951
|
-
|
|
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 (
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
27
|
+
"license": "MIT",
|
|
28
28
|
"author": "Vitalii Balabanov",
|
|
29
29
|
"repository": {
|
|
30
30
|
"type": "git",
|