@nurix/nustack 0.10.0-dev.10
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 +77 -0
- package/dist/commands/add.js +130 -0
- package/dist/commands/agent.js +100 -0
- package/dist/commands/bootstrap.js +54 -0
- package/dist/commands/describe.js +34 -0
- package/dist/commands/device.js +159 -0
- package/dist/commands/docs.js +119 -0
- package/dist/commands/doctor.js +284 -0
- package/dist/commands/graph.js +34 -0
- package/dist/commands/handoff.js +108 -0
- package/dist/commands/init.js +280 -0
- package/dist/commands/lane.js +12 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/login.js +134 -0
- package/dist/commands/logout.js +44 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/organization.js +114 -0
- package/dist/commands/review.js +152 -0
- package/dist/commands/search.js +57 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/sync.js +85 -0
- package/dist/commands/telemetry.js +112 -0
- package/dist/commands/toolkit.js +167 -0
- package/dist/commands/validate.js +67 -0
- package/dist/commands/workspace-sync.js +645 -0
- package/dist/commands/workspace.js +479 -0
- package/dist/index.js +357 -0
- package/dist/lib/agent/claudeHooks.js +77 -0
- package/dist/lib/auth_grant.js +398 -0
- package/dist/lib/auth_recovery.js +49 -0
- package/dist/lib/browser_opener.js +24 -0
- package/dist/lib/device_identity.js +28 -0
- package/dist/lib/device_keychain.js +101 -0
- package/dist/lib/env_file.js +75 -0
- package/dist/lib/etna/manager.js +43 -0
- package/dist/lib/etna/profile.js +41 -0
- package/dist/lib/etna/reconcile.js +77 -0
- package/dist/lib/filebeat_installer.js +246 -0
- package/dist/lib/git-sync/core/commit_range.js +37 -0
- package/dist/lib/git-sync/core/engine.js +263 -0
- package/dist/lib/git-sync/core/git_runner.js +137 -0
- package/dist/lib/git-sync/core/operation_state.js +22 -0
- package/dist/lib/git-sync/core/path_normalizer.js +98 -0
- package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
- package/dist/lib/git-sync/core/repository.js +49 -0
- package/dist/lib/git-sync/core/watch_stream.js +86 -0
- package/dist/lib/git-sync/core/watcher.js +61 -0
- package/dist/lib/git-sync/inbound/applier.js +131 -0
- package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
- package/dist/lib/git-sync/inbound/approval.js +117 -0
- package/dist/lib/git-sync/inbound/content_decision.js +148 -0
- package/dist/lib/git-sync/inbound/download.js +223 -0
- package/dist/lib/git-sync/inbound/event_sync.js +133 -0
- package/dist/lib/git-sync/inbound/index.js +10 -0
- package/dist/lib/git-sync/inbound/notification.js +39 -0
- package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
- package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
- package/dist/lib/git-sync/inbound/undo.js +198 -0
- package/dist/lib/git-sync/outbound/extraction.js +107 -0
- package/dist/lib/git-sync/outbound/history_decision.js +112 -0
- package/dist/lib/git-sync/outbound/index.js +26 -0
- package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
- package/dist/lib/git-sync/outbound/publisher.js +51 -0
- package/dist/lib/git-sync/outbound/queue.js +123 -0
- package/dist/lib/git-sync/outbound/state.js +12 -0
- package/dist/lib/git-sync/outbound/upload_client.js +141 -0
- package/dist/lib/git-sync/recovery/bundle.js +56 -0
- package/dist/lib/git-sync/recovery/crypto.js +114 -0
- package/dist/lib/git-sync/recovery/index.js +3 -0
- package/dist/lib/git-sync/recovery/store.js +55 -0
- package/dist/lib/git-sync/whitelist/index.js +4 -0
- package/dist/lib/git-sync/whitelist/matcher.js +62 -0
- package/dist/lib/git-sync/whitelist/store.js +99 -0
- package/dist/lib/git-sync/whitelist/types.js +46 -0
- package/dist/lib/git-sync/whitelist/validation.js +60 -0
- package/dist/lib/graph/answer.js +171 -0
- package/dist/lib/graph/freshness.js +29 -0
- package/dist/lib/graph/library.js +19 -0
- package/dist/lib/guards.js +106 -0
- package/dist/lib/interactive.js +12 -0
- package/dist/lib/json_output.js +8 -0
- package/dist/lib/lanes/claudeAdapter.js +135 -0
- package/dist/lib/lanes/frames.js +73 -0
- package/dist/lib/lanes/supervisor.js +247 -0
- package/dist/lib/lanes/worktree.js +33 -0
- package/dist/lib/local_store/accepted_uploads.js +49 -0
- package/dist/lib/local_store/bindings.js +163 -0
- package/dist/lib/local_store/commit_origins.js +25 -0
- package/dist/lib/local_store/database.js +71 -0
- package/dist/lib/local_store/device_state.js +72 -0
- package/dist/lib/local_store/filebeat_state.js +133 -0
- package/dist/lib/local_store/handoff_applications.js +166 -0
- package/dist/lib/local_store/history_decisions.js +48 -0
- package/dist/lib/local_store/index.js +14 -0
- package/dist/lib/local_store/migrations.js +240 -0
- package/dist/lib/local_store/paths.js +24 -0
- package/dist/lib/local_store/pending_uploads.js +76 -0
- package/dist/lib/local_store/recovery_bundles.js +66 -0
- package/dist/lib/local_store/sync_vocabulary.js +33 -0
- package/dist/lib/local_store/workspace_events_cache.js +54 -0
- package/dist/lib/loopback.js +78 -0
- package/dist/lib/manifest.js +83 -0
- package/dist/lib/nustack_client.js +547 -0
- package/dist/lib/nustack_store.js +17 -0
- package/dist/lib/onboarding/attach_existing_git.js +65 -0
- package/dist/lib/onboarding/binding.js +91 -0
- package/dist/lib/onboarding/folder_state.js +99 -0
- package/dist/lib/onboarding/git_init.js +10 -0
- package/dist/lib/onboarding/handoff_apply.js +68 -0
- package/dist/lib/onboarding/init_fresh_folder.js +100 -0
- package/dist/lib/onboarding/manifest_writer.js +161 -0
- package/dist/lib/onboarding/web_first_attach.js +133 -0
- package/dist/lib/package_manager.js +23 -0
- package/dist/lib/review/client.js +66 -0
- package/dist/lib/schema_validator.js +24 -0
- package/dist/lib/secure_file.js +10 -0
- package/dist/lib/session_telemetry.js +263 -0
- package/dist/lib/sessions/client.js +48 -0
- package/dist/lib/sessions/outsideSessions.js +54 -0
- package/dist/lib/sse_framing.js +48 -0
- package/dist/lib/telemetry/config_renderer.js +175 -0
- package/dist/lib/telemetry/health_report.js +173 -0
- package/dist/lib/telemetry/provider_paths.js +38 -0
- package/dist/lib/telemetry/redaction_policy.js +90 -0
- package/dist/lib/telemetry/reload.js +84 -0
- package/dist/lib/workspace_context.js +121 -0
- package/dist/spec_schema.json +422 -0
- package/dist/wire/auth.js +1 -0
- package/dist/wire/sync-paths.js +25 -0
- package/dist/wire/telemetry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { getBindingById, listActiveBindings } from "../../local_store/bindings.js";
|
|
2
|
+
import { NotARepositoryError } from "./git_runner.js";
|
|
3
|
+
import { resolveRepository, readHeadCommit, readSymbolicBranch, } from "./repository.js";
|
|
4
|
+
import { readGitOperationState, isTechnicalWaitState } from "./operation_state.js";
|
|
5
|
+
import { resolveCommitRange } from "./commit_range.js";
|
|
6
|
+
import { withReconciliationLock, } from "./reconciliation_lock.js";
|
|
7
|
+
import { watchGitControlPaths } from "./watcher.js";
|
|
8
|
+
const DEFAULT_INTERVAL_MS = 5 * 60_000;
|
|
9
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
10
|
+
export function observeBinding(binding) {
|
|
11
|
+
const base = {
|
|
12
|
+
bindingId: binding.id,
|
|
13
|
+
workspaceId: binding.workspaceId,
|
|
14
|
+
boundBranch: binding.boundBranch,
|
|
15
|
+
status: binding.status,
|
|
16
|
+
resolved: false,
|
|
17
|
+
symbolicBranch: null,
|
|
18
|
+
operationState: "unknown",
|
|
19
|
+
headCommitId: null,
|
|
20
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
21
|
+
lastAppliedWorkspaceSequence: binding.lastAppliedWorkspaceSequence,
|
|
22
|
+
pendingCommitCount: 0,
|
|
23
|
+
};
|
|
24
|
+
let repo;
|
|
25
|
+
try {
|
|
26
|
+
repo = resolveRepository(binding.repositoryRoot);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
const range = resolveCommitRange(repo, {
|
|
32
|
+
boundBranch: binding.boundBranch,
|
|
33
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
resolved: true,
|
|
38
|
+
symbolicBranch: readSymbolicBranch(repo),
|
|
39
|
+
operationState: readGitOperationState(repo).kind,
|
|
40
|
+
headCommitId: readHeadCommit(repo),
|
|
41
|
+
pendingCommitCount: range.kind === "range" ? range.commitIds.length : 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function createSyncEngine(options) {
|
|
45
|
+
const { store } = options;
|
|
46
|
+
const emitter = options.emitter ?? null;
|
|
47
|
+
const onLog = options.onLog ?? null;
|
|
48
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
49
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
50
|
+
const lockOptions = options.lockOptions ?? {};
|
|
51
|
+
const registry = {
|
|
52
|
+
outboundPublisher: null,
|
|
53
|
+
inboundApplier: null,
|
|
54
|
+
};
|
|
55
|
+
const bindingFilter = new Set(options.bindingIds ?? []);
|
|
56
|
+
function scopedActiveBindings() {
|
|
57
|
+
const active = listActiveBindings(store);
|
|
58
|
+
return bindingFilter.size === 0 ? active : active.filter((binding) => bindingFilter.has(binding.id));
|
|
59
|
+
}
|
|
60
|
+
const watchers = new Map();
|
|
61
|
+
const inFlight = new Set();
|
|
62
|
+
let started = false;
|
|
63
|
+
let intervalTimer;
|
|
64
|
+
let heartbeatTimer;
|
|
65
|
+
function safeEmit(input) {
|
|
66
|
+
if (!emitter)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
emitter.emit(input);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
try {
|
|
73
|
+
emitter.emit({ event: "error", code: "emit_failed", message: "an event was withheld" });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function log(line) {
|
|
80
|
+
if (onLog)
|
|
81
|
+
onLog(line);
|
|
82
|
+
}
|
|
83
|
+
function describeOutcome(outcome) {
|
|
84
|
+
const detail = outcome.outcome === "commit_range_ready"
|
|
85
|
+
? ` (${outcome.pendingCommitCount} pending commit(s))`
|
|
86
|
+
: outcome.waitReason
|
|
87
|
+
? ` (${outcome.waitReason})`
|
|
88
|
+
: outcome.errorCode
|
|
89
|
+
? ` (${outcome.errorCode})`
|
|
90
|
+
: "";
|
|
91
|
+
return `${outcome.bindingId}: ${outcome.outcome}${detail}`;
|
|
92
|
+
}
|
|
93
|
+
function emitResult(outcome) {
|
|
94
|
+
safeEmit({
|
|
95
|
+
event: "reconcile_result",
|
|
96
|
+
bindingId: outcome.bindingId,
|
|
97
|
+
outcome: outcome.outcome,
|
|
98
|
+
headCommitId: outcome.headCommitId,
|
|
99
|
+
lastProcessedCommitId: outcome.lastProcessedCommitId,
|
|
100
|
+
pendingCommitCount: outcome.pendingCommitCount,
|
|
101
|
+
...(outcome.waitReason ? { waitReason: outcome.waitReason } : {}),
|
|
102
|
+
...(outcome.errorCode ? { errorCode: outcome.errorCode } : {}),
|
|
103
|
+
});
|
|
104
|
+
log(describeOutcome(outcome));
|
|
105
|
+
}
|
|
106
|
+
async function reconcileBinding(bindingId, _source) {
|
|
107
|
+
return withReconciliationLock(bindingId, async () => {
|
|
108
|
+
safeEmit({ event: "reconcile_started", bindingId });
|
|
109
|
+
const binding = getBindingById(store, bindingId);
|
|
110
|
+
if (!binding) {
|
|
111
|
+
const outcome = {
|
|
112
|
+
bindingId,
|
|
113
|
+
outcome: "error",
|
|
114
|
+
headCommitId: null,
|
|
115
|
+
lastProcessedCommitId: null,
|
|
116
|
+
pendingCommitCount: 0,
|
|
117
|
+
errorCode: "binding_not_found",
|
|
118
|
+
};
|
|
119
|
+
emitResult(outcome);
|
|
120
|
+
return outcome;
|
|
121
|
+
}
|
|
122
|
+
let repo;
|
|
123
|
+
try {
|
|
124
|
+
repo = resolveRepository(binding.repositoryRoot);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const outcome = {
|
|
128
|
+
bindingId,
|
|
129
|
+
outcome: "error",
|
|
130
|
+
headCommitId: null,
|
|
131
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
132
|
+
pendingCommitCount: 0,
|
|
133
|
+
errorCode: error instanceof NotARepositoryError ? "not_a_repository" : "repository_unresolved",
|
|
134
|
+
};
|
|
135
|
+
emitResult(outcome);
|
|
136
|
+
return outcome;
|
|
137
|
+
}
|
|
138
|
+
const head = readHeadCommit(repo);
|
|
139
|
+
const operationState = readGitOperationState(repo);
|
|
140
|
+
if (isTechnicalWaitState(operationState)) {
|
|
141
|
+
const outcome = {
|
|
142
|
+
bindingId,
|
|
143
|
+
outcome: "technical_wait",
|
|
144
|
+
headCommitId: head,
|
|
145
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
146
|
+
pendingCommitCount: 0,
|
|
147
|
+
waitReason: operationState.kind,
|
|
148
|
+
};
|
|
149
|
+
emitResult(outcome);
|
|
150
|
+
return outcome;
|
|
151
|
+
}
|
|
152
|
+
const range = resolveCommitRange(repo, {
|
|
153
|
+
boundBranch: binding.boundBranch,
|
|
154
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
155
|
+
});
|
|
156
|
+
const outcome = {
|
|
157
|
+
bindingId,
|
|
158
|
+
outcome: "no_op",
|
|
159
|
+
headCommitId: head,
|
|
160
|
+
lastProcessedCommitId: binding.lastProcessedCommitId,
|
|
161
|
+
pendingCommitCount: 0,
|
|
162
|
+
};
|
|
163
|
+
switch (range.kind) {
|
|
164
|
+
case "branch_wait":
|
|
165
|
+
outcome.outcome = "branch_wait";
|
|
166
|
+
outcome.waitReason = range.reason;
|
|
167
|
+
break;
|
|
168
|
+
case "history_decision_required":
|
|
169
|
+
outcome.outcome = "history_decision_required";
|
|
170
|
+
break;
|
|
171
|
+
case "no_op":
|
|
172
|
+
outcome.outcome = "no_op";
|
|
173
|
+
break;
|
|
174
|
+
case "range":
|
|
175
|
+
outcome.outcome = "commit_range_ready";
|
|
176
|
+
outcome.pendingCommitCount = range.commitIds.length;
|
|
177
|
+
if (registry.outboundPublisher) {
|
|
178
|
+
await registry.outboundPublisher({ store, repo, binding, commitIds: range.commitIds, emit: safeEmit });
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
emitResult(outcome);
|
|
183
|
+
return outcome;
|
|
184
|
+
}, lockOptions);
|
|
185
|
+
}
|
|
186
|
+
function triggerReconcile(bindingId, source) {
|
|
187
|
+
safeEmit({ event: "wake", bindingId, source });
|
|
188
|
+
const promise = reconcileBinding(bindingId, source);
|
|
189
|
+
inFlight.add(promise);
|
|
190
|
+
void promise.finally(() => inFlight.delete(promise));
|
|
191
|
+
return promise;
|
|
192
|
+
}
|
|
193
|
+
async function reconcileAll(source) {
|
|
194
|
+
const outcomes = [];
|
|
195
|
+
for (const binding of scopedActiveBindings()) {
|
|
196
|
+
outcomes.push(await triggerReconcile(binding.id, source));
|
|
197
|
+
}
|
|
198
|
+
return outcomes;
|
|
199
|
+
}
|
|
200
|
+
async function start() {
|
|
201
|
+
if (started)
|
|
202
|
+
return;
|
|
203
|
+
started = true;
|
|
204
|
+
const bindings = scopedActiveBindings();
|
|
205
|
+
safeEmit({ event: "watch_started", pid: process.pid, bindingCount: bindings.length });
|
|
206
|
+
log(`watching ${bindings.length} binding(s) — Ctrl-C to stop`);
|
|
207
|
+
for (const binding of bindings) {
|
|
208
|
+
const observation = observeBinding(binding);
|
|
209
|
+
safeEmit({
|
|
210
|
+
event: "binding_status",
|
|
211
|
+
bindingId: observation.bindingId,
|
|
212
|
+
workspaceId: observation.workspaceId,
|
|
213
|
+
boundBranch: observation.boundBranch,
|
|
214
|
+
status: observation.status,
|
|
215
|
+
headCommitId: observation.headCommitId,
|
|
216
|
+
lastProcessedCommitId: observation.lastProcessedCommitId,
|
|
217
|
+
lastAppliedWorkspaceSequence: observation.lastAppliedWorkspaceSequence,
|
|
218
|
+
pendingCommitCount: observation.pendingCommitCount,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
await reconcileAll("startup");
|
|
222
|
+
for (const binding of bindings) {
|
|
223
|
+
try {
|
|
224
|
+
const repo = resolveRepository(binding.repositoryRoot);
|
|
225
|
+
watchers.set(binding.id, watchGitControlPaths(repo, binding.boundBranch, () => {
|
|
226
|
+
void triggerReconcile(binding.id, "fs");
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
intervalTimer = setInterval(() => void reconcileAll("interval"), intervalMs);
|
|
233
|
+
if (emitter)
|
|
234
|
+
heartbeatTimer = setInterval(() => safeEmit({ event: "heartbeat" }), heartbeatMs);
|
|
235
|
+
}
|
|
236
|
+
async function stop(reason = "signal") {
|
|
237
|
+
if (intervalTimer)
|
|
238
|
+
clearInterval(intervalTimer);
|
|
239
|
+
if (heartbeatTimer)
|
|
240
|
+
clearInterval(heartbeatTimer);
|
|
241
|
+
intervalTimer = undefined;
|
|
242
|
+
heartbeatTimer = undefined;
|
|
243
|
+
for (const handle of watchers.values())
|
|
244
|
+
handle.close();
|
|
245
|
+
watchers.clear();
|
|
246
|
+
await Promise.allSettled([...inFlight]);
|
|
247
|
+
started = false;
|
|
248
|
+
safeEmit({ event: "watch_stopped", reason });
|
|
249
|
+
log(`stopped (${reason})`);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
reconcileBinding,
|
|
253
|
+
reconcileAll,
|
|
254
|
+
registerOutboundPublisher(publisher) {
|
|
255
|
+
registry.outboundPublisher = publisher;
|
|
256
|
+
},
|
|
257
|
+
registerInboundApplier(applier) {
|
|
258
|
+
registry.inboundApplier = applier;
|
|
259
|
+
},
|
|
260
|
+
start,
|
|
261
|
+
stop,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export const GIT_READ_ONLY_SUBCOMMANDS = [
|
|
3
|
+
"rev-parse",
|
|
4
|
+
"symbolic-ref",
|
|
5
|
+
"rev-list",
|
|
6
|
+
"merge-base",
|
|
7
|
+
"for-each-ref",
|
|
8
|
+
"cat-file",
|
|
9
|
+
"diff-tree",
|
|
10
|
+
"ls-tree",
|
|
11
|
+
"status",
|
|
12
|
+
];
|
|
13
|
+
export const GIT_WRITE_PLUMBING_SUBCOMMANDS = [
|
|
14
|
+
"hash-object",
|
|
15
|
+
"read-tree",
|
|
16
|
+
"update-index",
|
|
17
|
+
"write-tree",
|
|
18
|
+
"commit-tree",
|
|
19
|
+
"update-ref",
|
|
20
|
+
"ls-files",
|
|
21
|
+
"merge-file",
|
|
22
|
+
"config",
|
|
23
|
+
];
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
25
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
26
|
+
export class GitNotInstalledError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("git is not installed or not on PATH");
|
|
29
|
+
this.name = "GitNotInstalledError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class NotARepositoryError extends Error {
|
|
33
|
+
startDir;
|
|
34
|
+
constructor(startDir) {
|
|
35
|
+
super(`not a Git repository (or any parent): ${startDir}`);
|
|
36
|
+
this.name = "NotARepositoryError";
|
|
37
|
+
this.startDir = startDir;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class GitSubcommandNotAllowedError extends Error {
|
|
41
|
+
subcommand;
|
|
42
|
+
constructor(subcommand) {
|
|
43
|
+
super(`git subcommand not permitted by the allowlist: ${subcommand}`);
|
|
44
|
+
this.name = "GitSubcommandNotAllowedError";
|
|
45
|
+
this.subcommand = subcommand;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class GitConfigWriteNotAllowedError extends Error {
|
|
49
|
+
args;
|
|
50
|
+
constructor(args) {
|
|
51
|
+
super("git config is permitted for --get reads only; a write/unset argument was refused");
|
|
52
|
+
this.name = "GitConfigWriteNotAllowedError";
|
|
53
|
+
this.args = args;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class GitCommandError extends Error {
|
|
57
|
+
subcommand;
|
|
58
|
+
redactedArgv;
|
|
59
|
+
exitCode;
|
|
60
|
+
stderr;
|
|
61
|
+
constructor(subcommand, redactedArgv, exitCode, stderr) {
|
|
62
|
+
super(`git ${subcommand} failed (exit ${exitCode})`);
|
|
63
|
+
this.name = "GitCommandError";
|
|
64
|
+
this.subcommand = subcommand;
|
|
65
|
+
this.redactedArgv = redactedArgv;
|
|
66
|
+
this.exitCode = exitCode;
|
|
67
|
+
this.stderr = stderr;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function redactArg(arg) {
|
|
71
|
+
return arg.startsWith("/") || /^[A-Za-z]:\\/.test(arg) ? "<path>" : arg;
|
|
72
|
+
}
|
|
73
|
+
function isAllowed(subcommand) {
|
|
74
|
+
return (GIT_READ_ONLY_SUBCOMMANDS.includes(subcommand) ||
|
|
75
|
+
GIT_WRITE_PLUMBING_SUBCOMMANDS.includes(subcommand));
|
|
76
|
+
}
|
|
77
|
+
function isConfigReadOnly(args) {
|
|
78
|
+
const rest = args.slice(1).filter((arg) => arg !== "--null" && arg !== "-z");
|
|
79
|
+
const verb = rest[0];
|
|
80
|
+
if (verb !== "--get" && verb !== "--get-all" && verb !== "--get-regexp")
|
|
81
|
+
return false;
|
|
82
|
+
return !rest.slice(1).some((arg) => arg.startsWith("--") && arg !== "--get" && arg !== "--get-all");
|
|
83
|
+
}
|
|
84
|
+
export function runGit(cwd, args, options = {}) {
|
|
85
|
+
const subcommand = args[0] ?? "";
|
|
86
|
+
if (!isAllowed(subcommand)) {
|
|
87
|
+
throw new GitSubcommandNotAllowedError(subcommand);
|
|
88
|
+
}
|
|
89
|
+
if (subcommand === "config" && !isConfigReadOnly(args)) {
|
|
90
|
+
throw new GitConfigWriteNotAllowedError(args);
|
|
91
|
+
}
|
|
92
|
+
const env = { ...process.env };
|
|
93
|
+
delete env.GIT_DIR;
|
|
94
|
+
delete env.GIT_WORK_TREE;
|
|
95
|
+
delete env.GIT_INDEX_FILE;
|
|
96
|
+
if (options.indexFile)
|
|
97
|
+
env.GIT_INDEX_FILE = options.indexFile;
|
|
98
|
+
if (options.identity) {
|
|
99
|
+
const id = options.identity;
|
|
100
|
+
if (id.authorName !== undefined)
|
|
101
|
+
env.GIT_AUTHOR_NAME = id.authorName;
|
|
102
|
+
if (id.authorEmail !== undefined)
|
|
103
|
+
env.GIT_AUTHOR_EMAIL = id.authorEmail;
|
|
104
|
+
if (id.committerName !== undefined)
|
|
105
|
+
env.GIT_COMMITTER_NAME = id.committerName;
|
|
106
|
+
if (id.committerEmail !== undefined)
|
|
107
|
+
env.GIT_COMMITTER_EMAIL = id.committerEmail;
|
|
108
|
+
if (id.authorDate !== undefined)
|
|
109
|
+
env.GIT_AUTHOR_DATE = id.authorDate;
|
|
110
|
+
if (id.committerDate !== undefined)
|
|
111
|
+
env.GIT_COMMITTER_DATE = id.committerDate;
|
|
112
|
+
}
|
|
113
|
+
const result = spawnSync("git", args, {
|
|
114
|
+
cwd,
|
|
115
|
+
env,
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
118
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
119
|
+
...(options.input !== undefined ? { input: options.input } : {}),
|
|
120
|
+
});
|
|
121
|
+
if (result.error) {
|
|
122
|
+
const code = result.error.code;
|
|
123
|
+
if (code === "ENOENT")
|
|
124
|
+
throw new GitNotInstalledError();
|
|
125
|
+
throw new GitCommandError(subcommand, [subcommand, ...args.slice(1).map(redactArg)], -1, String(result.error));
|
|
126
|
+
}
|
|
127
|
+
const exitCode = result.status ?? -1;
|
|
128
|
+
const stdout = result.stdout ?? "";
|
|
129
|
+
const stderr = result.stderr ?? "";
|
|
130
|
+
if (exitCode !== 0 && !options.allowFailure) {
|
|
131
|
+
if (/not a git repository/i.test(stderr)) {
|
|
132
|
+
throw new NotARepositoryError(cwd);
|
|
133
|
+
}
|
|
134
|
+
throw new GitCommandError(subcommand, [subcommand, ...args.slice(1).map(redactArg)], exitCode, stderr);
|
|
135
|
+
}
|
|
136
|
+
return { stdout, stderr, exitCode };
|
|
137
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function readGitOperationState(repo) {
|
|
4
|
+
const gitDir = repo.gitDir;
|
|
5
|
+
const has = (entry) => existsSync(path.join(gitDir, entry));
|
|
6
|
+
if (has("MERGE_HEAD"))
|
|
7
|
+
return { kind: "merge" };
|
|
8
|
+
if (has("rebase-merge") || has("rebase-apply"))
|
|
9
|
+
return { kind: "rebase" };
|
|
10
|
+
if (has("CHERRY_PICK_HEAD"))
|
|
11
|
+
return { kind: "cherry_pick" };
|
|
12
|
+
if (has("REVERT_HEAD"))
|
|
13
|
+
return { kind: "revert" };
|
|
14
|
+
if (has("BISECT_LOG"))
|
|
15
|
+
return { kind: "bisect" };
|
|
16
|
+
if (has("index.lock"))
|
|
17
|
+
return { kind: "index_locked" };
|
|
18
|
+
return { kind: "normal" };
|
|
19
|
+
}
|
|
20
|
+
export function isTechnicalWaitState(state) {
|
|
21
|
+
return state.kind !== "normal";
|
|
22
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
export const PROTECTED_PATTERNS = [
|
|
4
|
+
".git/**",
|
|
5
|
+
".env*",
|
|
6
|
+
".nustack/recovery/**",
|
|
7
|
+
".npmrc",
|
|
8
|
+
".netrc",
|
|
9
|
+
"*.pem",
|
|
10
|
+
"*.key",
|
|
11
|
+
"id_rsa*",
|
|
12
|
+
];
|
|
13
|
+
const PROTECTED_PREFIXES = [".git", ".nustack/recovery"];
|
|
14
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
15
|
+
const CONTROL_CHARACTER = /[\u0001-\u001F\u007F]/;
|
|
16
|
+
export function isProtectedPath(normalizedPath) {
|
|
17
|
+
for (const prefix of PROTECTED_PREFIXES) {
|
|
18
|
+
if (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`))
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const basename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
|
|
22
|
+
if (basename.startsWith(".env"))
|
|
23
|
+
return true;
|
|
24
|
+
if (basename === ".npmrc" || basename === ".netrc")
|
|
25
|
+
return true;
|
|
26
|
+
if (basename.endsWith(".pem") || basename.endsWith(".key"))
|
|
27
|
+
return true;
|
|
28
|
+
if (basename.startsWith("id_rsa"))
|
|
29
|
+
return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
export function normalizeRepositoryPath(raw) {
|
|
33
|
+
if (raw.length === 0)
|
|
34
|
+
return { ok: false, code: "empty" };
|
|
35
|
+
if (LONE_SURROGATE.test(raw))
|
|
36
|
+
return { ok: false, code: "invalid_encoding" };
|
|
37
|
+
if (raw.includes("\u0000"))
|
|
38
|
+
return { ok: false, code: "nul_byte" };
|
|
39
|
+
if (CONTROL_CHARACTER.test(raw))
|
|
40
|
+
return { ok: false, code: "control_character" };
|
|
41
|
+
const slashed = raw.replace(/\\/g, "/");
|
|
42
|
+
if (slashed.startsWith("//"))
|
|
43
|
+
return { ok: false, code: "unc" };
|
|
44
|
+
if (/^[A-Za-z]:/.test(slashed))
|
|
45
|
+
return { ok: false, code: "drive_qualified" };
|
|
46
|
+
if (slashed.startsWith("/"))
|
|
47
|
+
return { ok: false, code: "absolute" };
|
|
48
|
+
const rawSegments = slashed.split("/");
|
|
49
|
+
for (const segment of rawSegments) {
|
|
50
|
+
if (segment === "." || segment === "..")
|
|
51
|
+
return { ok: false, code: "dot_segment" };
|
|
52
|
+
}
|
|
53
|
+
const segments = rawSegments.filter((segment) => segment.length > 0);
|
|
54
|
+
if (segments.length === 0)
|
|
55
|
+
return { ok: false, code: "empty" };
|
|
56
|
+
const normalizedPath = segments.join("/");
|
|
57
|
+
if (isProtectedPath(normalizedPath))
|
|
58
|
+
return { ok: false, code: "protected_path" };
|
|
59
|
+
return { ok: true, path: normalizedPath };
|
|
60
|
+
}
|
|
61
|
+
export function safeJoin(repositoryRoot, normalizedPath) {
|
|
62
|
+
const root = path.resolve(repositoryRoot);
|
|
63
|
+
const target = path.resolve(root, normalizedPath);
|
|
64
|
+
const rel = path.relative(root, target);
|
|
65
|
+
if (rel.length === 0 || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
66
|
+
return { ok: false, code: "outside_root" };
|
|
67
|
+
}
|
|
68
|
+
let current = root;
|
|
69
|
+
for (const segment of rel.split(path.sep)) {
|
|
70
|
+
current = path.join(current, segment);
|
|
71
|
+
try {
|
|
72
|
+
if (lstatSync(current).isSymbolicLink())
|
|
73
|
+
return { ok: false, code: "outside_root" };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, path: target };
|
|
80
|
+
}
|
|
81
|
+
export function findCaseFoldCollisions(paths) {
|
|
82
|
+
return groupCollisions(paths, (candidate) => candidate.toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
export function findUnicodeCollisions(paths) {
|
|
85
|
+
return groupCollisions(paths, (candidate) => candidate.normalize("NFC"));
|
|
86
|
+
}
|
|
87
|
+
function groupCollisions(paths, keyOf) {
|
|
88
|
+
const groups = new Map();
|
|
89
|
+
for (const candidate of paths) {
|
|
90
|
+
const key = keyOf(candidate);
|
|
91
|
+
const bucket = groups.get(key);
|
|
92
|
+
if (bucket)
|
|
93
|
+
bucket.push(candidate);
|
|
94
|
+
else
|
|
95
|
+
groups.set(key, [candidate]);
|
|
96
|
+
}
|
|
97
|
+
return [...groups.values()].filter((group) => new Set(group).size > 1);
|
|
98
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { openSync, writeSync, closeSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdirSecure } from "../../secure_file.js";
|
|
4
|
+
import { locksDir } from "../../local_store/paths.js";
|
|
5
|
+
const DEFAULT_STALENESS_MS = 60_000;
|
|
6
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 30_000;
|
|
7
|
+
const DEFAULT_POLL_INTERVAL_MS = 50;
|
|
8
|
+
export class ReconciliationLockBusyError extends Error {
|
|
9
|
+
bindingId;
|
|
10
|
+
constructor(bindingId) {
|
|
11
|
+
super(`reconciliation lock for binding ${bindingId} is held by another live process`);
|
|
12
|
+
this.name = "ReconciliationLockBusyError";
|
|
13
|
+
this.bindingId = bindingId;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const inProcessChains = new Map();
|
|
17
|
+
function delay(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
function isProcessAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return error.code === "EPERM";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function takeOverIfStale(lockPath, stalenessMs) {
|
|
30
|
+
let content;
|
|
31
|
+
try {
|
|
32
|
+
content = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
content = undefined;
|
|
36
|
+
}
|
|
37
|
+
const stale = content === undefined ||
|
|
38
|
+
typeof content.pid !== "number" ||
|
|
39
|
+
!isProcessAlive(content.pid) ||
|
|
40
|
+
Date.now() - content.acquiredAtMs > stalenessMs;
|
|
41
|
+
if (!stale)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(lockPath);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
async function acquireFileLock(bindingId, options) {
|
|
51
|
+
const dir = options.lockDir ?? locksDir();
|
|
52
|
+
await mkdirSecure(dir);
|
|
53
|
+
const lockPath = path.join(dir, `${bindingId}.lock`);
|
|
54
|
+
const stalenessMs = options.stalenessMs ?? DEFAULT_STALENESS_MS;
|
|
55
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
56
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
57
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
61
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, acquiredAtMs: Date.now() }));
|
|
62
|
+
closeSync(fd);
|
|
63
|
+
return lockPath;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== "EEXIST")
|
|
67
|
+
throw error;
|
|
68
|
+
if (takeOverIfStale(lockPath, stalenessMs))
|
|
69
|
+
continue;
|
|
70
|
+
if (Date.now() >= deadline)
|
|
71
|
+
throw new ReconciliationLockBusyError(bindingId);
|
|
72
|
+
await delay(pollIntervalMs);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function releaseFileLock(lockPath) {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(lockPath);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function withReconciliationLock(bindingId, fn, options = {}) {
|
|
84
|
+
const previous = inProcessChains.get(bindingId) ?? Promise.resolve();
|
|
85
|
+
let releaseInProcess;
|
|
86
|
+
const nextTail = new Promise((resolve) => (releaseInProcess = resolve));
|
|
87
|
+
inProcessChains.set(bindingId, nextTail);
|
|
88
|
+
await previous;
|
|
89
|
+
try {
|
|
90
|
+
const lockPath = await acquireFileLock(bindingId, options);
|
|
91
|
+
try {
|
|
92
|
+
return await fn();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
releaseFileLock(lockPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
releaseInProcess();
|
|
100
|
+
if (inProcessChains.get(bindingId) === nextTail)
|
|
101
|
+
inProcessChains.delete(bindingId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { runGit, NotARepositoryError } from "./git_runner.js";
|
|
3
|
+
export function resolveRepository(startDir) {
|
|
4
|
+
const { stdout } = runGit(startDir, [
|
|
5
|
+
"rev-parse",
|
|
6
|
+
"--show-toplevel",
|
|
7
|
+
"--absolute-git-dir",
|
|
8
|
+
"--git-common-dir",
|
|
9
|
+
]);
|
|
10
|
+
const lines = stdout
|
|
11
|
+
.split("\n")
|
|
12
|
+
.map((line) => line.trim())
|
|
13
|
+
.filter((line) => line.length > 0);
|
|
14
|
+
const [repositoryRoot, gitDir, commonDirRaw] = lines;
|
|
15
|
+
if (!repositoryRoot || !gitDir || !commonDirRaw) {
|
|
16
|
+
throw new NotARepositoryError(startDir);
|
|
17
|
+
}
|
|
18
|
+
const commonDir = path.isAbsolute(commonDirRaw)
|
|
19
|
+
? commonDirRaw
|
|
20
|
+
: path.resolve(startDir, commonDirRaw);
|
|
21
|
+
return { repositoryRoot, gitDir, commonDir };
|
|
22
|
+
}
|
|
23
|
+
export function readHeadCommit(repo) {
|
|
24
|
+
const result = runGit(repo.repositoryRoot, ["rev-parse", "--verify", "--quiet", "HEAD"], {
|
|
25
|
+
allowFailure: true,
|
|
26
|
+
});
|
|
27
|
+
if (result.exitCode !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
const commit = result.stdout.trim();
|
|
30
|
+
return commit.length > 0 ? commit : null;
|
|
31
|
+
}
|
|
32
|
+
export function readSymbolicBranch(repo) {
|
|
33
|
+
const result = runGit(repo.repositoryRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
34
|
+
allowFailure: true,
|
|
35
|
+
});
|
|
36
|
+
if (result.exitCode !== 0)
|
|
37
|
+
return null;
|
|
38
|
+
const branch = result.stdout.trim();
|
|
39
|
+
return branch.length > 0 ? branch : null;
|
|
40
|
+
}
|
|
41
|
+
export function readRefCommit(repo, ref) {
|
|
42
|
+
const result = runGit(repo.repositoryRoot, ["rev-parse", "--verify", "--quiet", ref], {
|
|
43
|
+
allowFailure: true,
|
|
44
|
+
});
|
|
45
|
+
if (result.exitCode !== 0)
|
|
46
|
+
return null;
|
|
47
|
+
const commit = result.stdout.trim();
|
|
48
|
+
return commit.length > 0 ? commit : null;
|
|
49
|
+
}
|