shadow-git 1.1.0 → 1.1.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.
- package/CLAUDE.md +3 -0
- package/README.md +13 -0
- package/dist/cli.js +176 -1
- package/package.json +1 -1
- package/src/cli.ts +33 -1
- package/src/core/update-check.ts +214 -0
- package/src/utils/logger.ts +8 -0
- package/tests/core/update-check.test.ts +494 -0
- package/.claude/settings.local.json +0 -43
- package/.temp/.sgit/COMMIT_EDITMSG +0 -1
- package/.temp/.sgit/HEAD +0 -1
- package/.temp/.sgit/config +0 -8
- package/.temp/.sgit/description +0 -1
- package/.temp/.sgit/hooks/applypatch-msg.sample +0 -15
- package/.temp/.sgit/hooks/commit-msg.sample +0 -24
- package/.temp/.sgit/hooks/fsmonitor-watchman.sample +0 -174
- package/.temp/.sgit/hooks/post-update.sample +0 -8
- package/.temp/.sgit/hooks/pre-applypatch.sample +0 -14
- package/.temp/.sgit/hooks/pre-commit +0 -6
- package/.temp/.sgit/hooks/pre-commit.sample +0 -49
- package/.temp/.sgit/hooks/pre-merge-commit.sample +0 -13
- package/.temp/.sgit/hooks/pre-push.sample +0 -53
- package/.temp/.sgit/hooks/pre-rebase.sample +0 -169
- package/.temp/.sgit/hooks/pre-receive.sample +0 -24
- package/.temp/.sgit/hooks/prepare-commit-msg.sample +0 -42
- package/.temp/.sgit/hooks/push-to-checkout.sample +0 -78
- package/.temp/.sgit/hooks/sendemail-validate.sample +0 -77
- package/.temp/.sgit/hooks/update.sample +0 -128
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +0 -20
- package/.temp/.sgit/logs/HEAD +0 -1
- package/.temp/.sgit/logs/refs/heads/master +0 -1
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
- package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
- package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
- package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +0 -3
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit/refs/heads/master +0 -1
- package/.temp/.sgit.json +0 -10
- package/.temp/README.md +0 -0
- package/.temp/docs/guide.md +0 -10
- package/.temp/lib/__init__.py +0 -1
- package/.temp/lib/utils.py +0 -5
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/src/config.py +0 -3
- package/.temp/src/main.py +0 -5
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/temp.md +0 -3
- package/.temp/test/1 +0 -0
- package/.temp/tests/test_utils.py +0 -7
package/CLAUDE.md
CHANGED
|
@@ -43,6 +43,7 @@ src/
|
|
|
43
43
|
hooks.ts # Install/uninstall pre-commit hooks
|
|
44
44
|
errors.ts # Custom error classes (SgitError hierarchy)
|
|
45
45
|
gitignore.ts # Disable/enable .gitignore files during sgit operations
|
|
46
|
+
update-check.ts # Daily npm registry check (detached worker) and update notice
|
|
46
47
|
utils/
|
|
47
48
|
logger.ts # Color-coded stdout/stderr logging
|
|
48
49
|
process.ts # Child process execution
|
|
@@ -56,5 +57,7 @@ src/
|
|
|
56
57
|
- Shadow repo exclude uses a deny-all + allow-list pattern (`*` then `!path`)
|
|
57
58
|
- Error classes extend `SgitError`; the CLI catches these and prints the message
|
|
58
59
|
- Hidden `debug` commands (`sgit debug <cmd>`) are registered with `{ hidden: true }` so they don't appear in `sgit --help`. They are only visible via `sgit debug --help`. Debug commands manage their own gitignore guard (the CLI-level guard is skipped for them). No tests are written for debug commands.
|
|
60
|
+
- **Update check:** `setupUpdateNotifier` (`src/core/update-check.ts`) runs at the start of every interactive invocation — stdout and stderr must both be TTYs; it is skipped for the hook-driven `track verify` and when `NO_UPDATE_NOTIFIER` is set. The command's own process never touches the network: when the last successful check is more than a day old, the CLI re-spawns itself detached with the `__update-check` argument, and that worker writes the registry's `latest` version to `update-check.json` in `$XDG_CACHE_HOME/shadow-git` (default `~/.cache/shadow-git`). The notice is printed from a `process.on('exit')` listener on every run for as long as the cached `latest` is newer than the installed version; only the registry check is limited to once a day. sgit processes started underneath another sgit (the shadow hooks run `sgit track sync`) see `SGIT_UPDATE_NOTICE_HANDLED` in their environment and stay quiet, so one command prints the notice once. Everything on this path fails silently: a failed check records nothing, so the next invocation tries again, and the exit listener must never throw — a throwing `'exit'` listener keeps later ones (the gitignore guard's cleanup) from running.
|
|
61
|
+
- The CLI version (`sgit --version`, and the "current" side of the update notice) is read at runtime from `package.json` by `readVersion` in `src/cli.ts` — it is not hardcoded anywhere.
|
|
59
62
|
- **HARD RULE — gitignore revert guarantee:** `.gitignore` files inside sgit-tracked directories are temporarily renamed to `.gitignore.sgit-parked` during sgit command execution (via `setupGitignoreGuard` in `src/core/gitignore.ts`). **The rename MUST always be reverted** when the command ends — whether it succeeds, fails, is cancelled (Ctrl+C), or receives SIGTERM. The guard uses `try/finally`, signal handlers, and `process.on('exit')` to ensure this. Any new code that executes sgit operations must use the guard or equivalent cleanup guarantees. Pre-commit hooks verify no `.gitignore.sgit-parked` files exist at commit time.
|
|
60
63
|
- **Swap-mode exception to the gitignore revert rule:** while swap mode is active (marker `.git.sgit-parked` present), `.gitignore.sgit-parked` files are **expected to persist** across commands — they stay disabled from `sgit track swap` until `sgit track unswap`. To prevent drift, `disableGitignores`, `enableGitignores`, and `setupGitignoreGuard` refuse to run (throwing `SwapModeActiveError`) whenever `isSwapModeSync(root)` is true. The swap and unswap commands call these helpers only outside the swap window (swap calls `disableGitignores` before creating the marker; unswap calls `enableGitignores` after removing it), so the refusal never blocks the legitimate transitions. The CLI-level gitignore guard is already skipped for `swap`/`unswap` and whenever swap mode is active.
|
package/README.md
CHANGED
|
@@ -54,6 +54,19 @@ Any non-track command is forwarded to git using the shadow repository (e.g. `sgi
|
|
|
54
54
|
- **`.sgit/info/exclude`** — Configured to only see shadow-tracked files
|
|
55
55
|
- **Pre-commit hooks** — Verify that staged files belong to the correct repository
|
|
56
56
|
|
|
57
|
+
## Update Notifications
|
|
58
|
+
|
|
59
|
+
Once a day, `sgit` checks the npm registry for a newer version of shadow-git. When one is available, a short note is printed after your command's output:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Update available: shadow-git 1.1.0 → 1.2.0
|
|
63
|
+
Please run 'npm install -g shadow-git' to update.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It is only a note — nothing is installed or blocked. It is shown on every run until you update. The check runs in a background process, so commands never wait on the network. If the registry cannot be reached, the check fails silently and is tried again the next time you run `sgit`.
|
|
67
|
+
|
|
68
|
+
All of this happens only in an interactive terminal: nothing is checked or printed when output is piped or redirected. To turn the check off entirely, set `NO_UPDATE_NOTIFIER=1`.
|
|
69
|
+
|
|
57
70
|
## License
|
|
58
71
|
|
|
59
72
|
MIT
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
4
6
|
import { Command as Command2 } from "commander";
|
|
5
7
|
|
|
6
8
|
// src/core/repository.ts
|
|
@@ -273,6 +275,15 @@ function swapModeBanner() {
|
|
|
273
275
|
process.stderr.write(`${YELLOW}${msg}${RESET}
|
|
274
276
|
`);
|
|
275
277
|
}
|
|
278
|
+
function updateNotice(current, latest) {
|
|
279
|
+
const msg = [
|
|
280
|
+
`Update available: shadow-git ${current} \u2192 ${latest}`,
|
|
281
|
+
"Please run 'npm install -g shadow-git' to update."
|
|
282
|
+
].join("\n");
|
|
283
|
+
process.stderr.write(`
|
|
284
|
+
${CYAN}${msg}${RESET}
|
|
285
|
+
`);
|
|
286
|
+
}
|
|
276
287
|
|
|
277
288
|
// src/commands/track/init.ts
|
|
278
289
|
import { join as join5 } from "path";
|
|
@@ -1912,10 +1923,174 @@ function wrapAction2(fn) {
|
|
|
1912
1923
|
};
|
|
1913
1924
|
}
|
|
1914
1925
|
|
|
1926
|
+
// src/core/update-check.ts
|
|
1927
|
+
import { spawn } from "child_process";
|
|
1928
|
+
import {
|
|
1929
|
+
mkdirSync,
|
|
1930
|
+
readFileSync as readFileSync2,
|
|
1931
|
+
statSync,
|
|
1932
|
+
unlinkSync as unlinkSync2,
|
|
1933
|
+
writeFileSync as writeFileSync2
|
|
1934
|
+
} from "fs";
|
|
1935
|
+
import { homedir as homedir2 } from "os";
|
|
1936
|
+
import { isAbsolute, join as join14 } from "path";
|
|
1937
|
+
var PACKAGE_NAME = "shadow-git";
|
|
1938
|
+
var DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
1939
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1940
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
1941
|
+
var LOCK_STALE_MS = 6e4;
|
|
1942
|
+
var STATE_FILE = "update-check.json";
|
|
1943
|
+
var LOCK_FILE2 = "update-check.lock";
|
|
1944
|
+
var VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1945
|
+
var UPDATE_CHECK_ARG = "__update-check";
|
|
1946
|
+
var HANDLED_ENV = "SGIT_UPDATE_NOTICE_HANDLED";
|
|
1947
|
+
function setupUpdateNotifier(currentVersion, entry, args) {
|
|
1948
|
+
const nested = Boolean(process.env[HANDLED_ENV]);
|
|
1949
|
+
process.env[HANDLED_ENV] = "1";
|
|
1950
|
+
if (nested || !isEnabled(args)) return;
|
|
1951
|
+
try {
|
|
1952
|
+
const dir = getCacheDir();
|
|
1953
|
+
if (isCheckDue(readState(dir)) && acquireLock(dir)) {
|
|
1954
|
+
spawnWorker(dir, entry);
|
|
1955
|
+
}
|
|
1956
|
+
process.on("exit", () => showNotice(dir, currentVersion));
|
|
1957
|
+
} catch {
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
async function runUpdateCheckWorker() {
|
|
1961
|
+
try {
|
|
1962
|
+
const dir = getCacheDir();
|
|
1963
|
+
try {
|
|
1964
|
+
const latest = await fetchLatestVersion();
|
|
1965
|
+
if (latest !== void 0) {
|
|
1966
|
+
writeState(dir, { checkedAt: Date.now(), latest });
|
|
1967
|
+
}
|
|
1968
|
+
} finally {
|
|
1969
|
+
releaseLock(dir);
|
|
1970
|
+
}
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function isNewerVersion(latest, current) {
|
|
1975
|
+
const a = VERSION_RE.exec(latest);
|
|
1976
|
+
const b = VERSION_RE.exec(current);
|
|
1977
|
+
if (!a || !b) return false;
|
|
1978
|
+
for (let i = 1; i <= 3; i++) {
|
|
1979
|
+
const diff = Number(a[i]) - Number(b[i]);
|
|
1980
|
+
if (diff !== 0) return diff > 0;
|
|
1981
|
+
}
|
|
1982
|
+
return a[4] === void 0 && b[4] !== void 0;
|
|
1983
|
+
}
|
|
1984
|
+
function getCacheDir() {
|
|
1985
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
1986
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join14(homedir2(), ".cache");
|
|
1987
|
+
return join14(base, PACKAGE_NAME);
|
|
1988
|
+
}
|
|
1989
|
+
function isEnabled(args) {
|
|
1990
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
|
|
1991
|
+
if (process.env.NO_UPDATE_NOTIFIER) return false;
|
|
1992
|
+
if (args[0] === "track" && args[1] === "verify") return false;
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
function isCheckDue(state) {
|
|
1996
|
+
if (!state) return true;
|
|
1997
|
+
const age = Date.now() - state.checkedAt;
|
|
1998
|
+
return age < 0 || age >= DAY_MS;
|
|
1999
|
+
}
|
|
2000
|
+
function showNotice(dir, currentVersion) {
|
|
2001
|
+
try {
|
|
2002
|
+
const latest = readState(dir)?.latest;
|
|
2003
|
+
if (latest !== void 0 && isNewerVersion(latest, currentVersion)) {
|
|
2004
|
+
updateNotice(currentVersion, latest);
|
|
2005
|
+
}
|
|
2006
|
+
} catch {
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function spawnWorker(dir, entry) {
|
|
2010
|
+
try {
|
|
2011
|
+
const child = spawn(process.execPath, [entry, UPDATE_CHECK_ARG], {
|
|
2012
|
+
cwd: dir,
|
|
2013
|
+
// not the user's repo — a lingering cwd can block deleting it
|
|
2014
|
+
detached: true,
|
|
2015
|
+
stdio: "ignore",
|
|
2016
|
+
windowsHide: true
|
|
2017
|
+
});
|
|
2018
|
+
child.on("error", () => releaseLock(dir));
|
|
2019
|
+
child.unref();
|
|
2020
|
+
} catch {
|
|
2021
|
+
releaseLock(dir);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function fetchLatestVersion() {
|
|
2025
|
+
const res = await fetch(DIST_TAGS_URL, {
|
|
2026
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
2027
|
+
});
|
|
2028
|
+
if (!res.ok) return void 0;
|
|
2029
|
+
const tags = await res.json();
|
|
2030
|
+
return typeof tags?.latest === "string" ? tags.latest : void 0;
|
|
2031
|
+
}
|
|
2032
|
+
function readState(dir) {
|
|
2033
|
+
try {
|
|
2034
|
+
const data = JSON.parse(
|
|
2035
|
+
readFileSync2(join14(dir, STATE_FILE), "utf-8")
|
|
2036
|
+
);
|
|
2037
|
+
if (typeof data?.checkedAt !== "number" || typeof data.latest !== "string") {
|
|
2038
|
+
return void 0;
|
|
2039
|
+
}
|
|
2040
|
+
return { checkedAt: data.checkedAt, latest: data.latest };
|
|
2041
|
+
} catch {
|
|
2042
|
+
return void 0;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function writeState(dir, state) {
|
|
2046
|
+
writeFileSync2(join14(dir, STATE_FILE), JSON.stringify(state, null, 2) + "\n");
|
|
2047
|
+
}
|
|
2048
|
+
function acquireLock(dir) {
|
|
2049
|
+
const lockPath = join14(dir, LOCK_FILE2);
|
|
2050
|
+
try {
|
|
2051
|
+
mkdirSync(dir, { recursive: true });
|
|
2052
|
+
writeFileSync2(lockPath, String(process.pid), { flag: "wx" });
|
|
2053
|
+
return true;
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
try {
|
|
2057
|
+
const age = Math.abs(Date.now() - statSync(lockPath).mtimeMs);
|
|
2058
|
+
if (age < LOCK_STALE_MS) return false;
|
|
2059
|
+
writeFileSync2(lockPath, String(process.pid));
|
|
2060
|
+
return true;
|
|
2061
|
+
} catch {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
function releaseLock(dir) {
|
|
2066
|
+
try {
|
|
2067
|
+
unlinkSync2(join14(dir, LOCK_FILE2));
|
|
2068
|
+
} catch {
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1915
2072
|
// src/cli.ts
|
|
1916
2073
|
var COMMANDER_COMMANDS = /* @__PURE__ */ new Set(["track", "debug"]);
|
|
2074
|
+
function readVersion() {
|
|
2075
|
+
try {
|
|
2076
|
+
const pkg = JSON.parse(
|
|
2077
|
+
readFileSync3(new URL("../package.json", import.meta.url), "utf-8")
|
|
2078
|
+
);
|
|
2079
|
+
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
2080
|
+
} catch {
|
|
2081
|
+
return void 0;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1917
2084
|
async function run() {
|
|
1918
2085
|
const args = process.argv.slice(2);
|
|
2086
|
+
if (args[0] === UPDATE_CHECK_ARG) {
|
|
2087
|
+
await runUpdateCheckWorker();
|
|
2088
|
+
process.exit(0);
|
|
2089
|
+
}
|
|
2090
|
+
const version = readVersion();
|
|
2091
|
+
if (version) {
|
|
2092
|
+
setupUpdateNotifier(version, fileURLToPath(import.meta.url), args);
|
|
2093
|
+
}
|
|
1919
2094
|
let swapActive = false;
|
|
1920
2095
|
try {
|
|
1921
2096
|
const root = await findRoot();
|
|
@@ -1944,7 +2119,7 @@ async function run() {
|
|
|
1944
2119
|
}
|
|
1945
2120
|
try {
|
|
1946
2121
|
if (args.length === 0 || COMMANDER_COMMANDS.has(args[0]) || args[0].startsWith("-")) {
|
|
1947
|
-
const program = new Command2("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version("
|
|
2122
|
+
const program = new Command2("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version(version ?? "unknown");
|
|
1948
2123
|
registerTrackCommands(program);
|
|
1949
2124
|
registerDebugCommands(program);
|
|
1950
2125
|
program.addHelpText(
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
1
3
|
import { Command } from 'commander';
|
|
2
4
|
import { findRoot, validateShadowRepo } from './core/repository.js';
|
|
3
5
|
import { forwardToGit } from './commands/forward.js';
|
|
@@ -8,13 +10,43 @@ import { readManifest } from './core/manifest.js';
|
|
|
8
10
|
import { setupGitignoreGuard } from './core/gitignore.js';
|
|
9
11
|
import { isSwapMode, assertConsistentState } from './core/swap-mode.js';
|
|
10
12
|
import { SwapModeCorruptError } from './core/errors.js';
|
|
13
|
+
import {
|
|
14
|
+
UPDATE_CHECK_ARG,
|
|
15
|
+
runUpdateCheckWorker,
|
|
16
|
+
setupUpdateNotifier,
|
|
17
|
+
} from './core/update-check.js';
|
|
11
18
|
import * as logger from './utils/logger.js';
|
|
12
19
|
|
|
13
20
|
const COMMANDER_COMMANDS = new Set(['track', 'debug']);
|
|
14
21
|
|
|
22
|
+
// package.json sits one directory above this file, both as src/cli.ts and as
|
|
23
|
+
// the built dist/cli.js. Read at runtime so it is always the installed version.
|
|
24
|
+
function readVersion(): string | undefined {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(
|
|
27
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
|
|
28
|
+
);
|
|
29
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
async function run(): Promise<void> {
|
|
16
36
|
const args = process.argv.slice(2);
|
|
17
37
|
|
|
38
|
+
// Detached process started by setupUpdateNotifier — it only refreshes the
|
|
39
|
+
// cached registry answer and never runs a command
|
|
40
|
+
if (args[0] === UPDATE_CHECK_ARG) {
|
|
41
|
+
await runUpdateCheckWorker();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const version = readVersion();
|
|
46
|
+
if (version) {
|
|
47
|
+
setupUpdateNotifier(version, fileURLToPath(import.meta.url), args);
|
|
48
|
+
}
|
|
49
|
+
|
|
18
50
|
// Detect swap mode (and surface any inconsistent state) up front
|
|
19
51
|
let swapActive = false;
|
|
20
52
|
try {
|
|
@@ -67,7 +99,7 @@ async function run(): Promise<void> {
|
|
|
67
99
|
) {
|
|
68
100
|
const program = new Command('sgit')
|
|
69
101
|
.description('Shadow Git — a secondary git repository for your project')
|
|
70
|
-
.version('
|
|
102
|
+
.version(version ?? 'unknown');
|
|
71
103
|
|
|
72
104
|
registerTrackCommands(program);
|
|
73
105
|
registerDebugCommands(program);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
statSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { isAbsolute, join } from 'node:path';
|
|
11
|
+
import * as logger from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
const PACKAGE_NAME = 'shadow-git';
|
|
14
|
+
const DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
17
|
+
// A lock older than this was left behind by a worker that died mid-check
|
|
18
|
+
const LOCK_STALE_MS = 60_000;
|
|
19
|
+
const STATE_FILE = 'update-check.json';
|
|
20
|
+
const LOCK_FILE = 'update-check.lock';
|
|
21
|
+
|
|
22
|
+
// x.y.z with an optional prerelease and build suffix
|
|
23
|
+
const VERSION_RE =
|
|
24
|
+
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
25
|
+
|
|
26
|
+
// First CLI argument of the detached process that asks the registry
|
|
27
|
+
export const UPDATE_CHECK_ARG = '__update-check';
|
|
28
|
+
|
|
29
|
+
// Tells sgit processes started underneath this one (git hooks run
|
|
30
|
+
// `sgit track sync`) that the outermost invocation takes care of the notice
|
|
31
|
+
const HANDLED_ENV = 'SGIT_UPDATE_NOTICE_HANDLED';
|
|
32
|
+
|
|
33
|
+
interface UpdateState {
|
|
34
|
+
checkedAt: number; // last time the registry answered (ms since epoch)
|
|
35
|
+
latest: string; // version behind the `latest` dist-tag at that time
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Public API ---
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Called once per CLI invocation. Hands the daily registry check to a detached
|
|
42
|
+
* process, so the command itself never waits on the network, and prints the
|
|
43
|
+
* update notice when the command ends — on every run for as long as a newer
|
|
44
|
+
* version is known.
|
|
45
|
+
*/
|
|
46
|
+
export function setupUpdateNotifier(
|
|
47
|
+
currentVersion: string,
|
|
48
|
+
entry: string,
|
|
49
|
+
args: string[],
|
|
50
|
+
): void {
|
|
51
|
+
const nested = Boolean(process.env[HANDLED_ENV]);
|
|
52
|
+
process.env[HANDLED_ENV] = '1';
|
|
53
|
+
if (nested || !isEnabled(args)) return;
|
|
54
|
+
try {
|
|
55
|
+
const dir = getCacheDir();
|
|
56
|
+
if (isCheckDue(readState(dir)) && acquireLock(dir)) {
|
|
57
|
+
spawnWorker(dir, entry);
|
|
58
|
+
}
|
|
59
|
+
process.on('exit', () => showNotice(dir, currentVersion));
|
|
60
|
+
} catch {
|
|
61
|
+
// The update check must never get in the way of the actual command
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Body of the detached process: asks the npm registry for the latest version
|
|
67
|
+
* and records the answer. Any failure (offline, registry down, timeout) is
|
|
68
|
+
* silent and records nothing, so the next invocation simply checks again.
|
|
69
|
+
*/
|
|
70
|
+
export async function runUpdateCheckWorker(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const dir = getCacheDir();
|
|
73
|
+
try {
|
|
74
|
+
const latest = await fetchLatestVersion();
|
|
75
|
+
if (latest !== undefined) {
|
|
76
|
+
writeState(dir, { checkedAt: Date.now(), latest });
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
releaseLock(dir);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// stay silent
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isNewerVersion(latest: string, current: string): boolean {
|
|
87
|
+
const a = VERSION_RE.exec(latest);
|
|
88
|
+
const b = VERSION_RE.exec(current);
|
|
89
|
+
if (!a || !b) return false;
|
|
90
|
+
|
|
91
|
+
for (let i = 1; i <= 3; i++) {
|
|
92
|
+
const diff = Number(a[i]) - Number(b[i]);
|
|
93
|
+
if (diff !== 0) return diff > 0;
|
|
94
|
+
}
|
|
95
|
+
// Same x.y.z: a release is newer than its own prerelease. Two prereleases
|
|
96
|
+
// of the same version are not ordered — `latest` is never a prerelease.
|
|
97
|
+
return a[4] === undefined && b[4] !== undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getCacheDir(): string {
|
|
101
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
102
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), '.cache');
|
|
103
|
+
return join(base, PACKAGE_NAME);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Internal helpers ---
|
|
107
|
+
|
|
108
|
+
function isEnabled(args: string[]): boolean {
|
|
109
|
+
// Only when a person is at the terminal — pipes and scripts stay untouched
|
|
110
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
|
|
111
|
+
if (process.env.NO_UPDATE_NOTIFIER) return false;
|
|
112
|
+
// `track verify` is run by the pre-commit hooks, not typed by the user
|
|
113
|
+
if (args[0] === 'track' && args[1] === 'verify') return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* True when no check has succeeded yet, the last one is at least a day old,
|
|
119
|
+
* or it is dated in the future (the clock was changed).
|
|
120
|
+
*/
|
|
121
|
+
function isCheckDue(state: UpdateState | undefined): boolean {
|
|
122
|
+
if (!state) return true;
|
|
123
|
+
const age = Date.now() - state.checkedAt;
|
|
124
|
+
return age < 0 || age >= DAY_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function showNotice(dir: string, currentVersion: string): void {
|
|
128
|
+
try {
|
|
129
|
+
// Read again — the worker may have finished while the command was running
|
|
130
|
+
const latest = readState(dir)?.latest;
|
|
131
|
+
if (latest !== undefined && isNewerVersion(latest, currentVersion)) {
|
|
132
|
+
logger.updateNotice(currentVersion, latest);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Must not throw: an 'exit' listener that throws keeps the ones after it
|
|
136
|
+
// from running, and the gitignore guard restores .gitignore files in one
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function spawnWorker(dir: string, entry: string): void {
|
|
141
|
+
try {
|
|
142
|
+
const child = spawn(process.execPath, [entry, UPDATE_CHECK_ARG], {
|
|
143
|
+
cwd: dir, // not the user's repo — a lingering cwd can block deleting it
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: 'ignore',
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
child.on('error', () => releaseLock(dir));
|
|
149
|
+
child.unref();
|
|
150
|
+
} catch {
|
|
151
|
+
releaseLock(dir);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function fetchLatestVersion(): Promise<string | undefined> {
|
|
156
|
+
const res = await fetch(DIST_TAGS_URL, {
|
|
157
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) return undefined;
|
|
160
|
+
|
|
161
|
+
const tags = (await res.json()) as { latest?: unknown } | null;
|
|
162
|
+
return typeof tags?.latest === 'string' ? tags.latest : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readState(dir: string): UpdateState | undefined {
|
|
166
|
+
try {
|
|
167
|
+
const data = JSON.parse(
|
|
168
|
+
readFileSync(join(dir, STATE_FILE), 'utf-8'),
|
|
169
|
+
) as Partial<UpdateState> | null;
|
|
170
|
+
if (typeof data?.checkedAt !== 'number' || typeof data.latest !== 'string') {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return { checkedAt: data.checkedAt, latest: data.latest };
|
|
174
|
+
} catch {
|
|
175
|
+
// Missing or corrupt — same as never having checked
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeState(dir: string, state: UpdateState): void {
|
|
181
|
+
writeFileSync(join(dir, STATE_FILE), JSON.stringify(state, null, 2) + '\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The lock keeps a burst of invocations from each starting their own worker.
|
|
186
|
+
* It is held from spawn until the worker exits; the worker removes it whether
|
|
187
|
+
* the check succeeded or not.
|
|
188
|
+
*/
|
|
189
|
+
function acquireLock(dir: string): boolean {
|
|
190
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
// Already locked, or the cache directory is not writable
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const age = Math.abs(Date.now() - statSync(lockPath).mtimeMs);
|
|
200
|
+
if (age < LOCK_STALE_MS) return false;
|
|
201
|
+
writeFileSync(lockPath, String(process.pid)); // take over the stale lock
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function releaseLock(dir: string): void {
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(join(dir, LOCK_FILE));
|
|
211
|
+
} catch {
|
|
212
|
+
// already released
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -31,3 +31,11 @@ export function swapModeBanner(): void {
|
|
|
31
31
|
].join('\n');
|
|
32
32
|
process.stderr.write(`${YELLOW}${msg}${RESET}\n`);
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
export function updateNotice(current: string, latest: string): void {
|
|
36
|
+
const msg = [
|
|
37
|
+
`Update available: shadow-git ${current} → ${latest}`,
|
|
38
|
+
"Please run 'npm install -g shadow-git' to update.",
|
|
39
|
+
].join('\n');
|
|
40
|
+
process.stderr.write(`\n${CYAN}${msg}${RESET}\n`);
|
|
41
|
+
}
|