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.
Files changed (55) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +13 -0
  3. package/dist/cli.js +176 -1
  4. package/package.json +1 -1
  5. package/src/cli.ts +33 -1
  6. package/src/core/update-check.ts +214 -0
  7. package/src/utils/logger.ts +8 -0
  8. package/tests/core/update-check.test.ts +494 -0
  9. package/.claude/settings.local.json +0 -43
  10. package/.temp/.sgit/COMMIT_EDITMSG +0 -1
  11. package/.temp/.sgit/HEAD +0 -1
  12. package/.temp/.sgit/config +0 -8
  13. package/.temp/.sgit/description +0 -1
  14. package/.temp/.sgit/hooks/applypatch-msg.sample +0 -15
  15. package/.temp/.sgit/hooks/commit-msg.sample +0 -24
  16. package/.temp/.sgit/hooks/fsmonitor-watchman.sample +0 -174
  17. package/.temp/.sgit/hooks/post-update.sample +0 -8
  18. package/.temp/.sgit/hooks/pre-applypatch.sample +0 -14
  19. package/.temp/.sgit/hooks/pre-commit +0 -6
  20. package/.temp/.sgit/hooks/pre-commit.sample +0 -49
  21. package/.temp/.sgit/hooks/pre-merge-commit.sample +0 -13
  22. package/.temp/.sgit/hooks/pre-push.sample +0 -53
  23. package/.temp/.sgit/hooks/pre-rebase.sample +0 -169
  24. package/.temp/.sgit/hooks/pre-receive.sample +0 -24
  25. package/.temp/.sgit/hooks/prepare-commit-msg.sample +0 -42
  26. package/.temp/.sgit/hooks/push-to-checkout.sample +0 -78
  27. package/.temp/.sgit/hooks/sendemail-validate.sample +0 -77
  28. package/.temp/.sgit/hooks/update.sample +0 -128
  29. package/.temp/.sgit/index +0 -0
  30. package/.temp/.sgit/info/exclude +0 -20
  31. package/.temp/.sgit/logs/HEAD +0 -1
  32. package/.temp/.sgit/logs/refs/heads/master +0 -1
  33. package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
  34. package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
  35. package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
  36. package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
  37. package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
  38. package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +0 -3
  39. package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
  40. package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  41. package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
  42. package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
  43. package/.temp/.sgit/refs/heads/master +0 -1
  44. package/.temp/.sgit.json +0 -10
  45. package/.temp/README.md +0 -0
  46. package/.temp/docs/guide.md +0 -10
  47. package/.temp/lib/__init__.py +0 -1
  48. package/.temp/lib/utils.py +0 -5
  49. package/.temp/rec1/rec2/test +0 -0
  50. package/.temp/src/config.py +0 -3
  51. package/.temp/src/main.py +0 -5
  52. package/.temp/temp/shouldnt-track +0 -0
  53. package/.temp/temp.md +0 -3
  54. package/.temp/test/1 +0 -0
  55. 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("0.1.0");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-git",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "A secondary git repository for tracking files excluded from your main repo",
5
5
  "type": "module",
6
6
  "bin": {
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('0.1.0');
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
+ }
@@ -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
+ }