gitnexus 1.6.13-rc.34 → 1.6.13-rc.35
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/README.md +30 -2
- package/dist/cli/editor-targets.d.ts +1 -1
- package/dist/cli/editor-targets.js +12 -0
- package/dist/cli/i18n/en.d.ts +1 -1
- package/dist/cli/i18n/en.js +1 -1
- package/dist/cli/i18n/resources.d.ts +1 -1
- package/dist/cli/i18n/zh-CN.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +57 -0
- package/hooks/claude/hook-db-lock-probe.cjs +74 -43
- package/hooks/claude/hook-lock.cjs +130 -6
- package/hooks/claude/registry-query.cjs +18 -23
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +1 -0
- package/scripts/sync-plugin-manifests.mjs +13 -0
- package/web/assets/{abnfDiagram-PAR5IXJI-nRjsvWp-.js → abnfDiagram-PAR5IXJI-CM9OPETj.js} +1 -1
- package/web/assets/{agent-Bv5z78CW.js → agent-C8bWDcKv.js} +2 -2
- package/web/assets/{architectureDiagram-2PNWDENW-56fl8SSH.js → architectureDiagram-2PNWDENW-CFGPVOFL.js} +1 -1
- package/web/assets/{chunk-32MCUW2P-xLjc0mcF.js → chunk-32MCUW2P-BAKWSoJ_.js} +1 -1
- package/web/assets/{chunk-CLS4B6BI-DPDLP81t.js → chunk-CLS4B6BI-C6Eyf-Zq.js} +1 -1
- package/web/assets/{chunk-NF366YI6-D2OzsNXY.js → chunk-NF366YI6-BjQUpZZg.js} +1 -1
- package/web/assets/{chunk-SLDQ463B-DUcQswS-.js → chunk-SLDQ463B-DAs7sJcm.js} +1 -1
- package/web/assets/{classDiagram-FGAMPOII-q43g3Dpn.js → classDiagram-FGAMPOII-ipJcwT2g.js} +1 -1
- package/web/assets/{classDiagram-v2-AI5RJEK4-q43g3Dpn.js → classDiagram-v2-AI5RJEK4-ipJcwT2g.js} +1 -1
- package/web/assets/{cynefinDiagram-2OOWUY4M-Cfoko1Ws.js → cynefinDiagram-2OOWUY4M-BfCkzyqC.js} +1 -1
- package/web/assets/{dagre-MPVFI544-CTob3D2r.js → dagre-MPVFI544-CMeiFj2H.js} +1 -1
- package/web/assets/{diagram-AE4NWXFW-Dt2xfADa.js → diagram-AE4NWXFW-B7JUvrjH.js} +1 -1
- package/web/assets/{diagram-BUJID5UG-D-RUCl07.js → diagram-BUJID5UG-KMWjNniB.js} +1 -1
- package/web/assets/{diagram-C44CZ5NI-B9gPH4Gl.js → diagram-C44CZ5NI-Bx93uHLb.js} +1 -1
- package/web/assets/{diagram-EYOLEXXS-wR__cI23.js → diagram-EYOLEXXS-Dsja58oR.js} +1 -1
- package/web/assets/{ebnfDiagram-FTPZ3PV6-uw79FGz3.js → ebnfDiagram-FTPZ3PV6-Iwxb9NAS.js} +1 -1
- package/web/assets/{erDiagram-H3ERR26V-ZP_VIa5d.js → erDiagram-H3ERR26V-CJU6qpcb.js} +1 -1
- package/web/assets/{flowDiagram-YHGXBVSY-DEsmG3vH.js → flowDiagram-YHGXBVSY-Dz6dhw6j.js} +1 -1
- package/web/assets/{index-DofRiYRj.js → index-dXsywuPQ.js} +7 -7
- package/web/assets/{infoDiagram-LV547AFH-D1JPzJlf.js → infoDiagram-LV547AFH-P074dR6u.js} +1 -1
- package/web/assets/{ishikawaDiagram-PUJQGNDS-DxWZMRPX.js → ishikawaDiagram-PUJQGNDS-CZlaIkzm.js} +1 -1
- package/web/assets/{kanban-definition-JSQERAZB-S1lKJN_M.js → kanban-definition-JSQERAZB-BLX38hbF.js} +1 -1
- package/web/assets/{mindmap-definition-VBJCLQLM-JwX0Gc3J.js → mindmap-definition-VBJCLQLM-Bnu-7Z_9.js} +1 -1
- package/web/assets/{node.browser-C9G7Ehhe.js → node.browser-DIc1knUO.js} +1 -1
- package/web/assets/{pegDiagram-7WILOQTJ-DJWiAJJM.js → pegDiagram-7WILOQTJ-SH-3F770.js} +1 -1
- package/web/assets/{pieDiagram-EFA7CBSB-3i7IEboS.js → pieDiagram-EFA7CBSB-BGEXuMLa.js} +1 -1
- package/web/assets/{railroadDiagram-5NIQVNLQ-CQGcjlaN.js → railroadDiagram-5NIQVNLQ-DZMK703w.js} +1 -1
- package/web/assets/{requirementDiagram-SXREGOKZ-DH-LMk5X.js → requirementDiagram-SXREGOKZ-DLW6bNTm.js} +1 -1
- package/web/assets/{sequenceDiagram-52ZFIFDL-CrUW-Kdw.js → sequenceDiagram-52ZFIFDL-Bu6IKvYQ.js} +1 -1
- package/web/assets/{stateDiagram-3QAKPIVI-BuvX8wUJ.js → stateDiagram-3QAKPIVI-BrLxhUMB.js} +1 -1
- package/web/assets/{stateDiagram-v2-HN2P4RDZ-N7DodpOh.js → stateDiagram-v2-HN2P4RDZ-yZRLNv94.js} +1 -1
- package/web/assets/{swimlanes-QLVP42QD-DpvGI6UP.js → swimlanes-QLVP42QD-BN3CFF8X.js} +1 -1
- package/web/assets/{swimlanesDiagram-JKPDBFCN-Rd-o-as4.js → swimlanesDiagram-JKPDBFCN-BQ7NwLky.js} +1 -1
- package/web/assets/{timeline-definition-GS45XAXW-B9Rw1d9t.js → timeline-definition-GS45XAXW-DMlAtIe0.js} +1 -1
- package/web/assets/{vennDiagram-QIQU2ZRJ-CGo08RCI.js → vennDiagram-QIQU2ZRJ-DWmjtOEV.js} +1 -1
- package/web/assets/{wardleyDiagram-U6YNM66N-Dk7T5hBn.js → wardleyDiagram-U6YNM66N-BRdmEKKB.js} +1 -1
- package/web/assets/{xychartDiagram-XJAZEDNM-BJr5UGSi.js → xychartDiagram-XJAZEDNM-roHne1SF.js} +1 -1
- package/web/index.html +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
|
|
4
4
|
|
|
5
|
-
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
|
|
5
|
+
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Factory** (Droid), **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/gitnexus)
|
|
8
8
|
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
|
@@ -44,12 +44,13 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|
|
44
44
|
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
|
45
45
|
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
|
|
46
46
|
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
|
|
47
|
+
| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](../gitnexus-factory-plugin/)) | **Full** |
|
|
47
48
|
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
|
48
49
|
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
|
|
49
50
|
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
|
|
50
51
|
| **Windsurf** | Yes | — | — | MCP |
|
|
51
52
|
|
|
52
|
-
> **
|
|
53
|
+
> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
|
|
53
54
|
|
|
54
55
|
### Community Integrations
|
|
55
56
|
|
|
@@ -88,6 +89,33 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
|
|
|
88
89
|
|
|
89
90
|
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
|
|
90
91
|
|
|
92
|
+
### Factory (Droid) (full support — MCP + skills + hooks)
|
|
93
|
+
|
|
94
|
+
`gitnexus setup -c droid` writes the MCP server to `~/.factory/mcp.json` and installs skills to
|
|
95
|
+
`~/.factory/skills/`. To configure MCP by hand instead, add to `~/.factory/mcp.json`
|
|
96
|
+
([user scope](https://docs.factory.ai/cli/configuration/mcp) — applies to all projects):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"gitnexus": {
|
|
102
|
+
"command": "npx",
|
|
103
|
+
"args": ["-y", "gitnexus@latest", "mcp"]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For the PostToolUse search-augment hook, install the bundled
|
|
110
|
+
[`gitnexus-factory-plugin/`](../gitnexus-factory-plugin/) with `droid plugin install gitnexus@<marketplace>`
|
|
111
|
+
from a marketplace that includes this repo, or point Droid at it via `extraKnownMarketplaces` in
|
|
112
|
+
`.factory/settings.json`.
|
|
113
|
+
|
|
114
|
+
> **Factory notes:** Droid reads [AGENTS.md natively](https://docs.factory.ai/), which already carries the
|
|
115
|
+
> GitNexus context block, so no SessionStart hook is registered. Pick **one** install route
|
|
116
|
+
> (`gitnexus setup -c droid` **or** the plugin) — the plugin ships its own MCP entry, so installing both
|
|
117
|
+
> can register the server twice.
|
|
118
|
+
|
|
91
119
|
### Cursor / Windsurf
|
|
92
120
|
|
|
93
121
|
Add to `~/.cursor/mcp.json` (global — works for all projects):
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* implementations remain behaviourally symmetrical on top of this shared
|
|
16
16
|
* structure.
|
|
17
17
|
*/
|
|
18
|
-
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codebuddy' | 'qoder' | 'codex';
|
|
18
|
+
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codebuddy' | 'qoder' | 'codex' | 'droid';
|
|
19
19
|
/** An editor whose MCP config is a JSONC document (server keyed by name). */
|
|
20
20
|
export interface McpJsoncTarget {
|
|
21
21
|
id: EditorId;
|
|
@@ -76,6 +76,15 @@ export function getEditorTargets(home = os.homedir()) {
|
|
|
76
76
|
file: path.join(home, '.qoder.json'),
|
|
77
77
|
keyPath: ['mcpServers', 'gitnexus'],
|
|
78
78
|
},
|
|
79
|
+
{
|
|
80
|
+
id: 'droid',
|
|
81
|
+
label: 'Factory Droid',
|
|
82
|
+
// Factory's user-scope MCP config (https://docs.factory.ai/cli/configuration/mcp).
|
|
83
|
+
// Same `mcpServers` object shape as Cursor/Claude; user config takes
|
|
84
|
+
// precedence over project-level .factory/mcp.json.
|
|
85
|
+
file: path.join(home, '.factory', 'mcp.json'),
|
|
86
|
+
keyPath: ['mcpServers', 'gitnexus'],
|
|
87
|
+
},
|
|
79
88
|
];
|
|
80
89
|
const codex = {
|
|
81
90
|
id: 'codex',
|
|
@@ -98,6 +107,9 @@ export function getEditorTargets(home = os.homedir()) {
|
|
|
98
107
|
{ id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
|
|
99
108
|
// Codex reads skills from ~/.agents/skills (not ~/.codex).
|
|
100
109
|
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
|
|
110
|
+
// Factory Droid reads user-scope skills from ~/.factory/skills/{name}/SKILL.md
|
|
111
|
+
// (https://docs.factory.ai/cli/configuration/skills).
|
|
112
|
+
{ id: 'droid', label: 'Factory Droid', dir: path.join(home, '.factory', 'skills') },
|
|
101
113
|
];
|
|
102
114
|
const hooks = [
|
|
103
115
|
{
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -152,7 +152,7 @@ export declare const en: {
|
|
|
152
152
|
readonly 'help.command.help.description': 'display help for command';
|
|
153
153
|
readonly 'help.option.help': 'display help for command';
|
|
154
154
|
readonly 'help.option.version': 'output the version number';
|
|
155
|
-
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex';
|
|
155
|
+
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
|
|
156
156
|
readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
|
|
157
157
|
readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
|
|
158
158
|
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.';
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -154,7 +154,7 @@ export const en = {
|
|
|
154
154
|
'help.command.help.description': 'display help for command',
|
|
155
155
|
'help.option.help': 'display help for command',
|
|
156
156
|
'help.option.version': 'output the version number',
|
|
157
|
-
'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
|
|
157
|
+
'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
|
|
158
158
|
'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
|
|
159
159
|
'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
|
|
160
160
|
'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
|
|
@@ -153,7 +153,7 @@ export declare const cliResources: {
|
|
|
153
153
|
readonly 'help.command.help.description': 'display help for command';
|
|
154
154
|
readonly 'help.option.help': 'display help for command';
|
|
155
155
|
readonly 'help.option.version': 'output the version number';
|
|
156
|
-
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex';
|
|
156
|
+
readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
|
|
157
157
|
readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
|
|
158
158
|
readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
|
|
159
159
|
readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.';
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -152,7 +152,7 @@ export const zhCN = {
|
|
|
152
152
|
'help.command.help.description': '显示命令帮助',
|
|
153
153
|
'help.option.help': '显示命令帮助',
|
|
154
154
|
'help.option.version': '输出版本号',
|
|
155
|
-
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
|
|
155
|
+
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex、Factory Droid 配置 MCP',
|
|
156
156
|
'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
|
|
157
157
|
'help.command.autoSync.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
|
|
158
158
|
'help.autoSync.details': '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
|
package/dist/cli/index.js
CHANGED
|
@@ -17,7 +17,7 @@ function collectCodingAgents(value, previous) {
|
|
|
17
17
|
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(packageVersion());
|
|
18
18
|
program
|
|
19
19
|
.command('setup')
|
|
20
|
-
.description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex')
|
|
20
|
+
.description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid')
|
|
21
21
|
.option('-c, --coding-agent <agents>', 'Configure only these coding agents (comma-separated or repeatable)', collectCodingAgents)
|
|
22
22
|
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
|
23
23
|
program
|
package/dist/cli/setup.js
CHANGED
|
@@ -70,6 +70,7 @@ const CODING_AGENT_IDS = {
|
|
|
70
70
|
codebuddy: 'codebuddy',
|
|
71
71
|
qoder: 'qoder',
|
|
72
72
|
codex: 'codex',
|
|
73
|
+
droid: 'droid',
|
|
73
74
|
};
|
|
74
75
|
const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
|
|
75
76
|
function selectedCodingAgents(values) {
|
|
@@ -824,6 +825,55 @@ async function installQoderSkills(result) {
|
|
|
824
825
|
result.errors.push(`Qoder skills: ${err instanceof Error ? err.message : String(err)}`);
|
|
825
826
|
}
|
|
826
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* Configure the GitNexus MCP server for Factory Droid.
|
|
830
|
+
*
|
|
831
|
+
* Factory stores user-scope MCP config in ~/.factory/mcp.json using the same
|
|
832
|
+
* `{ mcpServers: { <name>: {...} } }` JSONC shape as Cursor/Claude
|
|
833
|
+
* (https://docs.factory.ai/cli/configuration/mcp), so we reuse mergeJsoncFile
|
|
834
|
+
* rather than shelling out to `droid mcp add` — that keeps setup working even
|
|
835
|
+
* when the `droid` binary isn't on PATH.
|
|
836
|
+
*/
|
|
837
|
+
async function setupDroid(result) {
|
|
838
|
+
const factoryDir = path.join(os.homedir(), '.factory');
|
|
839
|
+
if (!(await dirExists(factoryDir))) {
|
|
840
|
+
result.skipped.push('Factory Droid (not installed)');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const { file: mcpPath, keyPath } = mcpTarget('droid');
|
|
844
|
+
try {
|
|
845
|
+
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
|
846
|
+
if (ok) {
|
|
847
|
+
result.configured.push('Factory Droid');
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
result.errors.push('Factory Droid: mcp.json is corrupt — skipping to preserve existing content');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
result.errors.push(`Factory Droid: ${err.message}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Install global Factory Droid skills to ~/.factory/skills/{name}/SKILL.md
|
|
859
|
+
* (https://docs.factory.ai/cli/configuration/skills — same SKILL.md layout as
|
|
860
|
+
* Claude Code).
|
|
861
|
+
*/
|
|
862
|
+
async function installDroidSkills(result) {
|
|
863
|
+
const factoryDir = path.join(os.homedir(), '.factory');
|
|
864
|
+
if (!(await dirExists(factoryDir)))
|
|
865
|
+
return;
|
|
866
|
+
const skillsDir = skillTarget('droid').dir;
|
|
867
|
+
try {
|
|
868
|
+
const installed = await installSkillsTo(skillsDir);
|
|
869
|
+
if (installed.length > 0) {
|
|
870
|
+
result.configured.push(`Factory Droid skills (${installed.length} skills → ~/.factory/skills/)`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
result.errors.push(`Factory Droid skills: ${err.message}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
827
877
|
/**
|
|
828
878
|
* Build a TOML section for Codex MCP config (~/.codex/config.toml).
|
|
829
879
|
*/
|
|
@@ -1107,6 +1157,8 @@ export const setupCommand = async (options) => {
|
|
|
1107
1157
|
await setupQoder(result);
|
|
1108
1158
|
if (selected.has('codex'))
|
|
1109
1159
|
await setupCodex(result);
|
|
1160
|
+
if (selected.has('droid'))
|
|
1161
|
+
await setupDroid(result);
|
|
1110
1162
|
// Install global skills for platforms that support them
|
|
1111
1163
|
if (selected.has('claude')) {
|
|
1112
1164
|
await installClaudeCodeSkills(result);
|
|
@@ -1128,6 +1180,11 @@ export const setupCommand = async (options) => {
|
|
|
1128
1180
|
await installCodexSkills(result);
|
|
1129
1181
|
await installClaudeSchemaHooks(result, 'codex');
|
|
1130
1182
|
}
|
|
1183
|
+
// MCP + skills only. Factory hooks (Execute matcher, PostToolUse) ship in the
|
|
1184
|
+
// standalone gitnexus-factory-plugin instead of the setup path — add a droid
|
|
1185
|
+
// hook target + adapter here if setup-installed hooks are wanted.
|
|
1186
|
+
if (selected.has('droid'))
|
|
1187
|
+
await installDroidSkills(result);
|
|
1131
1188
|
// Print results
|
|
1132
1189
|
if (result.configured.length > 0) {
|
|
1133
1190
|
console.log(' Configured:');
|
|
@@ -80,9 +80,11 @@ function debugLog(msg) {
|
|
|
80
80
|
|
|
81
81
|
function resolveHookBinary(tool) {
|
|
82
82
|
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
// Trim once, exactly as hasMissingHookBinaryOverride does, so a padded but
|
|
84
|
+
// valid override (" /tmp/lsof ") is both accepted there and used here.
|
|
85
|
+
const fromEnv = process.env[envKey] ? String(process.env[envKey]).trim() : '';
|
|
86
|
+
if (fromEnv && fs.existsSync(fromEnv)) {
|
|
87
|
+
return fromEnv;
|
|
86
88
|
}
|
|
87
89
|
const candidates =
|
|
88
90
|
tool === 'lsof'
|
|
@@ -177,7 +179,13 @@ function resolveUnixGuardTimeout() {
|
|
|
177
179
|
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
|
178
180
|
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
|
179
181
|
const candidates = [];
|
|
180
|
-
|
|
182
|
+
// Resolve the override against THIS process's cwd — the directory the
|
|
183
|
+
// existsSync check and the self-test run in — so the cached/returned path is
|
|
184
|
+
// always absolute. The adapters spawn the wrapper with a different `cwd`
|
|
185
|
+
// (the tool request's), where a relative value would resolve elsewhere
|
|
186
|
+
// (ENOENT), and a slashless name would switch to a PATH lookup.
|
|
187
|
+
const override = trimmed ? path.resolve(trimmed) : '';
|
|
188
|
+
if (override && fs.existsSync(override)) candidates.push(override);
|
|
181
189
|
for (const builtin of [
|
|
182
190
|
'/usr/bin/timeout',
|
|
183
191
|
'/bin/timeout',
|
|
@@ -317,15 +325,30 @@ function getProcRoot() {
|
|
|
317
325
|
// `node <abs path to .../node_modules/gitnexus/dist/cli/index.js> mcp` line
|
|
318
326
|
// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
|
|
319
327
|
// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
|
|
320
|
-
// tests
|
|
328
|
+
// tests via GITNEXUS_HOOK_PROC_CMDLINE_MAX: an integer in
|
|
329
|
+
// [PROC_CMDLINE_FLOOR, PROC_CMDLINE_CEIL] is used as-is; a larger integer is
|
|
330
|
+
// CLAMPED to PROC_CMDLINE_CEIL; anything else (below the floor, fractional,
|
|
331
|
+
// non-numeric, Infinity) falls back to the 16 KiB default.
|
|
321
332
|
const PROC_CMDLINE_FLOOR = 4096;
|
|
333
|
+
// Upper bound for a single cmdline read chunk, and the absolute ceiling of the
|
|
334
|
+
// escalation path in readLinuxCmdline (same 256 KiB — no single read may exceed
|
|
335
|
+
// what the whole escalation is allowed to collect). Without it, an oversized
|
|
336
|
+
// override (e.g. 2**40 — past buffer.constants.MAX_LENGTH on older Node lines
|
|
337
|
+
// and unallocatable in practice on any) made Buffer.allocUnsafe throw; readLinuxCmdline's catch turned that into '' (a
|
|
338
|
+
// NON-candidate), so a real server owner was silently missed (fail-OPEN, the
|
|
339
|
+
// #1492 race). Oversized values are clamped rather than defaulted: the operator
|
|
340
|
+
// asked for MORE bytes, and the ceiling is the most the read will ever collect
|
|
341
|
+
// anyway, so clamping honours the intent while keeping allocation bounded.
|
|
342
|
+
const PROC_CMDLINE_CEIL = 262144;
|
|
322
343
|
function getCmdlineMaxBytes() {
|
|
323
344
|
const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
|
|
324
345
|
// Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
|
|
325
346
|
// 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
|
|
326
347
|
// default; trailing garbage ("8abc") now -> NaN -> default (stricter).
|
|
327
348
|
const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
|
|
328
|
-
|
|
349
|
+
// Number.isInteger rejects NaN, +/-Infinity and fractions (a fractional
|
|
350
|
+
// Buffer/readSync length is not a byte count).
|
|
351
|
+
if (Number.isInteger(n) && n >= PROC_CMDLINE_FLOOR) return Math.min(n, PROC_CMDLINE_CEIL);
|
|
329
352
|
return 16384;
|
|
330
353
|
}
|
|
331
354
|
|
|
@@ -381,19 +404,24 @@ const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
|
|
|
381
404
|
|
|
382
405
|
// Bounded /proc/<pid>/cmdline read for Phase 1. openSync+readSync (not
|
|
383
406
|
// readFileSync) so a D-state holder cannot stall the hook on a huge or
|
|
384
|
-
// never-EOF argv: we read
|
|
385
|
-
//
|
|
407
|
+
// never-EOF argv: we read in `cap`-sized chunks and stop as soon as the text
|
|
408
|
+
// holds both server tokens, at EOF, at PROC_CMDLINE_CEIL, or when the scan
|
|
409
|
+
// budget runs out (see below). cmdline separates argv with NULs; convert to
|
|
410
|
+
// spaces for isGitNexusServerCommand.
|
|
386
411
|
//
|
|
387
412
|
// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
|
|
388
413
|
// first path component while the `mcp`/`serve` mode token is the LAST argv, so
|
|
389
414
|
// a naive 4 KB read could clip the mode token off a server launched with a very
|
|
390
415
|
// long interpreter path and silently miss a real owner. We mitigate two ways:
|
|
391
|
-
// (a) the default cap (16 KiB) already clears realistic lines
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
416
|
+
// (a) the default cap (16 KiB) already clears realistic lines, so almost every
|
|
417
|
+
// process is decided by the first read hitting EOF; (b) a read that fills the
|
|
418
|
+
// cap stops early ONLY once it holds BOTH tokens (decided owner). Holding one
|
|
419
|
+
// token, or neither, decides nothing: interpreter flags can put a mode-looking
|
|
420
|
+
// word first (`node --require mcp .../gitnexus/... serve`) or push the gitnexus
|
|
421
|
+
// path past the first chunk. So we keep reading in cap-sized chunks until both
|
|
422
|
+
// tokens appear, the file ends, or the hard ceiling is reached. Only processes
|
|
423
|
+
// that passed the Phase 0 comm prefilter AND have a cmdline longer than the cap
|
|
424
|
+
// ever escalate, and each escalation step is budget-gated (below).
|
|
397
425
|
//
|
|
398
426
|
// Budget (F3): the escalation loop above is the one place a SINGLE pathological
|
|
399
427
|
// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
|
|
@@ -411,7 +439,7 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
|
|
|
411
439
|
return '';
|
|
412
440
|
}
|
|
413
441
|
try {
|
|
414
|
-
const HARD_CEIL =
|
|
442
|
+
const HARD_CEIL = PROC_CMDLINE_CEIL; // 256 KiB absolute ceiling for the escalation path
|
|
415
443
|
let collected = Buffer.alloc(0);
|
|
416
444
|
let offset = 0;
|
|
417
445
|
let chunkCap = cap;
|
|
@@ -425,16 +453,12 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
|
|
|
425
453
|
collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
|
|
426
454
|
offset += bytes;
|
|
427
455
|
const text = collected.toString('utf8').replace(/\0+/g, ' ');
|
|
428
|
-
// Stop early when
|
|
429
|
-
// token and a mode token
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
/node_modules[/\\]gitnexus[/\\]/.test(text);
|
|
434
|
-
const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
|
|
435
|
-
if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
|
|
456
|
+
// Stop early only when the partial read is DECIDED: both the gitnexus
|
|
457
|
+
// token and a mode token are present (isGitNexusServerCommand is exactly
|
|
458
|
+
// that conjunction). A partial read missing either token is undecided —
|
|
459
|
+
// the missing one may lie past the chunk boundary — so it keeps reading.
|
|
460
|
+
if (isGitNexusServerCommand(text)) break; // decided (positive)
|
|
436
461
|
if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
|
|
437
|
-
if (!hasGitNexus) break; // not a candidate; do not escalate the read
|
|
438
462
|
if (offset >= HARD_CEIL) break; // bounded escalation only
|
|
439
463
|
// Budget gate the escalation: a single huge-argv candidate must not burn
|
|
440
464
|
// the whole scan deadline before we re-check. Return the timeout sentinel
|
|
@@ -578,17 +602,24 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
|
|
578
602
|
);
|
|
579
603
|
return 'timeout';
|
|
580
604
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
605
|
+
if (code === 'ENOTDIR') {
|
|
606
|
+
// The fd path is not a directory at all, so this is structurally not a
|
|
607
|
+
// real /proc/<pid>/fd — not a plausible live owner. Move on.
|
|
608
|
+
debugLog(
|
|
609
|
+
`fd dir not a directory for candidate pid ${pidStr} (ENOTDIR); ` +
|
|
610
|
+
`treating candidate as non-owner -> continue`,
|
|
611
|
+
);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// Any other error (EMFILE, ENFILE, ENOMEM, EINTR, no code, …) says nothing
|
|
615
|
+
// about whether this already-identified server candidate holds the lbug.
|
|
616
|
+
// Only ENOENT/ENOTDIR above establish non-ownership; everything else is
|
|
617
|
+
// inconclusive and fails closed via 'timeout', same as EACCES/EIO.
|
|
587
618
|
debugLog(
|
|
588
|
-
`fd dir
|
|
589
|
-
`(${code || 'unknown'});
|
|
619
|
+
`fd dir read failed for candidate pid ${pidStr} ` +
|
|
620
|
+
`(${code || 'unknown'}); probe inconclusive -> fail-closed (timeout)`,
|
|
590
621
|
);
|
|
591
|
-
|
|
622
|
+
return 'timeout';
|
|
592
623
|
}
|
|
593
624
|
for (const fd of fds) {
|
|
594
625
|
if (outOfBudget()) return 'timeout';
|
|
@@ -707,16 +738,12 @@ module.exports = {
|
|
|
707
738
|
// name is pinned by a source-contract test.
|
|
708
739
|
linuxProcScanFindGitNexusServer,
|
|
709
740
|
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
|
|
710
|
-
// guard. Returns a self-tested wrapper path
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
|
|
717
|
-
// a value can pass here yet ENOENT at the augment call site — set the
|
|
718
|
-
// override to an absolute path. Returns null when the wrapper is
|
|
719
|
-
// disabled/unavailable. Never call on win32 (see its JSDoc).
|
|
741
|
+
// guard. Returns a self-tested, always-ABSOLUTE wrapper path: the built-in
|
|
742
|
+
// candidates are absolute, and a GITNEXUS_HOOK_TIMEOUT_PATH override is
|
|
743
|
+
// path.resolve()d against this process's cwd before its existsSync check
|
|
744
|
+
// and self-test, so the adapters can spawn it under any `cwd` option.
|
|
745
|
+
// Returns null when the wrapper is disabled/unavailable. Never call on
|
|
746
|
+
// win32 (see its JSDoc).
|
|
720
747
|
resolveUnixGuardTimeout,
|
|
721
748
|
// Exported for white-box unit tests of the numeric-env parsing (#2183 review):
|
|
722
749
|
// Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
|
|
@@ -725,4 +752,8 @@ module.exports = {
|
|
|
725
752
|
// otherwise only observable indirectly through scan timing/escalation.
|
|
726
753
|
getCmdlineMaxBytes,
|
|
727
754
|
resolveLinuxProcBudgetMs,
|
|
755
|
+
// Exported for white-box tests pinning that the override check and the
|
|
756
|
+
// override lookup agree on whitespace-padded GITNEXUS_HOOK_{LSOF,PS}_PATH.
|
|
757
|
+
resolveHookBinary,
|
|
758
|
+
hasMissingHookBinaryOverride,
|
|
728
759
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
1
2
|
const fs = require('fs');
|
|
2
3
|
const path = require('path');
|
|
3
4
|
|
|
@@ -5,6 +6,128 @@ const HOOK_LOCK_SUBDIR = '.hook-locks';
|
|
|
5
6
|
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
|
6
7
|
const HOOK_LOCK_STALE_MS = 30000;
|
|
7
8
|
|
|
9
|
+
// An evictor's claim marker older than this belongs to a crashed evictor.
|
|
10
|
+
// The critical section it guards is a few syscalls (token read, lstat,
|
|
11
|
+
// unlink), so any live evictor finishes orders of magnitude sooner; kept well
|
|
12
|
+
// under HOOK_LOCK_STALE_MS so an orphan never blocks a slot for long.
|
|
13
|
+
const HOOK_LOCK_EVICT_MARKER_STALE_MS = 5000;
|
|
14
|
+
|
|
15
|
+
// Same file iff inode identity AND content metadata match. dev+ino alone is
|
|
16
|
+
// not enough: filesystems reuse a freed inode number immediately (ext4), so a
|
|
17
|
+
// file recreated after an unlink can carry the old file's ino. bigint stats
|
|
18
|
+
// keep Windows' 64-bit file ids exact.
|
|
19
|
+
function sameSlotFile(a, b) {
|
|
20
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readMarkerToken(marker) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.readFileSync(marker, 'utf-8');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Stat and token of a marker, both taken from one open descriptor so they
|
|
32
|
+
// describe the same file (a path stat followed by a path read could straddle
|
|
33
|
+
// a replacement). O_NOFOLLOW where the platform has it: a marker is always a
|
|
34
|
+
// regular file this module created. Returns null when there is no marker.
|
|
35
|
+
function readMarkerSnapshot(marker) {
|
|
36
|
+
let fd;
|
|
37
|
+
try {
|
|
38
|
+
fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
39
|
+
return { stat: fs.fstatSync(fd, { bigint: true }), token: fs.readFileSync(fd, 'utf-8') };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
} finally {
|
|
43
|
+
if (fd !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
} catch {
|
|
47
|
+
/* already closed */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Break an evictor's claim marker only if it is an orphan: older than
|
|
54
|
+
// HOOK_LOCK_EVICT_MARKER_STALE_MS, and still the exact file (identity and
|
|
55
|
+
// owner token) judged old when it is re-checked just before the unlink. A
|
|
56
|
+
// marker released and re-created by a new claimant in between is fresh, so
|
|
57
|
+
// it fails the check and stays.
|
|
58
|
+
function breakOrphanedMarker(marker) {
|
|
59
|
+
const seen = readMarkerSnapshot(marker);
|
|
60
|
+
if (!seen || Date.now() - Number(seen.stat.mtimeMs) <= HOOK_LOCK_EVICT_MARKER_STALE_MS) return;
|
|
61
|
+
const now = readMarkerSnapshot(marker);
|
|
62
|
+
if (!now || !sameSlotFile(now.stat, seen.stat) || now.token !== seen.token) return;
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(marker);
|
|
65
|
+
} catch {
|
|
66
|
+
/* another contender already cleared it */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Evict a slot judged stale from the `inspected` stat. A slot file is only
|
|
71
|
+
// ever deleted, never moved, and only by the evictor holding the per-slot
|
|
72
|
+
// `<slot>.evicting` marker, created O_EXCL with a token unique to this call.
|
|
73
|
+
// Every destructive step verifies first:
|
|
74
|
+
// - the slot is unlinked only if the marker still carries our token (an
|
|
75
|
+
// evictor stalled long enough for its marker to be broken as an orphan has
|
|
76
|
+
// lost its claim and backs off) and the slot is still the exact file
|
|
77
|
+
// inspected — identical dev/ino/size/mtimeNs means its content and age are
|
|
78
|
+
// unchanged, so the stale verdict still holds, while a slot recreated since
|
|
79
|
+
// inspection fails the check and its lock stands;
|
|
80
|
+
// - our marker is removed only if it still carries our token, so a marker
|
|
81
|
+
// that has passed to another claimant is left alone;
|
|
82
|
+
// - an orphaned marker is broken only if it is still the old file it was
|
|
83
|
+
// judged to be (see breakOrphanedMarker).
|
|
84
|
+
//
|
|
85
|
+
// Residual windows. POSIX has no conditional unlink, so each check-then-
|
|
86
|
+
// unlink pair keeps a gap of two adjacent syscalls:
|
|
87
|
+
// (a) Slot: between the lstat identity check and unlinkSync(slot), a live
|
|
88
|
+
// owner past HOOK_LOCK_STALE_MS could release and a new hook recreate the
|
|
89
|
+
// slot, whose fresh lock would then be deleted. The consequence is at
|
|
90
|
+
// most one extra concurrent augment beyond HOOK_LOCK_MAX_INFLIGHT for
|
|
91
|
+
// that run — the cap is a load guard, and no data or index state
|
|
92
|
+
// depends on it. The victim's release() sees a foreign or missing file
|
|
93
|
+
// and leaves it alone.
|
|
94
|
+
// (b) Marker: between the token re-read and unlinkSync(marker) (ours or an
|
|
95
|
+
// orphan's), the marker could pass to another claimant, whose claim would
|
|
96
|
+
// then be removed. That only re-opens the slot to one more evictor, which
|
|
97
|
+
// still has to pass the slot identity check before deleting anything.
|
|
98
|
+
// Both need a stall of seconds landing on that exact syscall pair, and the
|
|
99
|
+
// only thing lost is one run's cap accounting, so they are accepted rather
|
|
100
|
+
// than traded for heavier machinery. A crash at any point orphans at most the
|
|
101
|
+
// marker, which the next contender breaks after it expires.
|
|
102
|
+
function evictStaleSlot(slotPath, inspected) {
|
|
103
|
+
const marker = `${slotPath}.evicting`;
|
|
104
|
+
breakOrphanedMarker(marker);
|
|
105
|
+
const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`;
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(marker, token, { flag: 'wx' });
|
|
108
|
+
} catch {
|
|
109
|
+
return; // Another evictor holds this slot — leave it to that evictor.
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
if (
|
|
113
|
+
readMarkerToken(marker) === token &&
|
|
114
|
+
sameSlotFile(fs.lstatSync(slotPath, { bigint: true }), inspected)
|
|
115
|
+
) {
|
|
116
|
+
fs.unlinkSync(slotPath);
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
/* slot already gone — the retry claims it */
|
|
120
|
+
} finally {
|
|
121
|
+
if (readMarkerToken(marker) === token) {
|
|
122
|
+
try {
|
|
123
|
+
fs.unlinkSync(marker);
|
|
124
|
+
} catch {
|
|
125
|
+
/* already gone */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
8
131
|
function acquireHookSlot(gitNexusDir) {
|
|
9
132
|
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
|
10
133
|
try {
|
|
@@ -28,6 +151,7 @@ function acquireHookSlot(gitNexusDir) {
|
|
|
28
151
|
const release = () => {
|
|
29
152
|
if (released) return;
|
|
30
153
|
released = true;
|
|
154
|
+
process.removeListener('exit', release);
|
|
31
155
|
try {
|
|
32
156
|
// Only unlink if we still own the slot. If we appeared stale and
|
|
33
157
|
// another hook took over, the file now belongs to it — leave alone.
|
|
@@ -52,8 +176,10 @@ function acquireHookSlot(gitNexusDir) {
|
|
|
52
176
|
}
|
|
53
177
|
let isLive = false;
|
|
54
178
|
let mtimeMs = Date.now();
|
|
179
|
+
let inspected = null;
|
|
55
180
|
try {
|
|
56
|
-
|
|
181
|
+
inspected = fs.fstatSync(fd, { bigint: true });
|
|
182
|
+
mtimeMs = Number(inspected.mtimeMs);
|
|
57
183
|
const buf = Buffer.alloc(32);
|
|
58
184
|
const n = fs.readSync(fd, buf, 0, 32, 0);
|
|
59
185
|
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
|
@@ -98,11 +224,9 @@ function acquireHookSlot(gitNexusDir) {
|
|
|
98
224
|
isLive = false;
|
|
99
225
|
}
|
|
100
226
|
if (isLive) break; // Try the next slot.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
/* another hook beat us to it — retry will hit EEXIST */
|
|
105
|
-
}
|
|
227
|
+
// No stat means we cannot prove which file we judged stale; leave it
|
|
228
|
+
// (the retry re-inspects it) rather than risk deleting a fresh lock.
|
|
229
|
+
if (inspected) evictStaleSlot(slotPath, inspected);
|
|
106
230
|
// Loop and retry this slot.
|
|
107
231
|
}
|
|
108
232
|
}
|