riftri 0.4.0 → 0.5.0

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 CHANGED
@@ -162,9 +162,11 @@ $ sudo riftri overlayfs install-helper
162
162
  ```
163
163
 
164
164
  **Riftri fails closed.** When a checkout cannot be reproduced exactly — custom
165
- filters, sparse checkout, submodules, external attributes, checkout hooks, or non-canonical
166
- [Git LFS](docs/git-lfs.md) setups — it stops before changing anything rather
167
- than silently falling back to a full copy. See
165
+ filters, a repository's own sparse-checkout profile, submodules, external
166
+ attributes, or non-canonical [Git LFS](docs/git-lfs.md) setups
167
+ — it stops before changing anything rather than silently falling back to a full
168
+ copy. Sparse worktrees are supported when you ask for them explicitly, with
169
+ [`--sparse-dir` cone selection](docs/sparse-checkout.md). See
168
170
  [how Riftri stays safe](docs/safety.md).
169
171
 
170
172
  ## Common commands
package/bin/riftri.js CHANGED
@@ -50,22 +50,18 @@ if (process.platform !== "win32") {
50
50
  }
51
51
  }
52
52
 
53
- function releaseSignals() {
54
- for (const signal of ignoredSignals) {
55
- process.removeListener(signal, ignoreSignal);
56
- }
57
- for (const signal of forwardedSignals) {
58
- process.removeListener(signal, forwardSignal);
59
- }
60
- }
53
+ // The handlers deliberately stay installed until the process exits. Removing
54
+ // the last listener for a signal restores its default disposition, so a SIGINT
55
+ // still in flight — the common case, since the terminal sent it to the whole
56
+ // foreground group — would kill the launcher in the window before
57
+ // `process.exit`, losing the command's real status. `process.exit` does not
58
+ // need them removed.
61
59
 
62
60
  child.on("error", (error) => {
63
- releaseSignals();
64
61
  fail(`could not start the native executable at ${binary}: ${error.message}`);
65
62
  });
66
63
 
67
64
  child.on("exit", (code, signal) => {
68
- releaseSignals();
69
65
  if (signal !== null) {
70
66
  const exitCode = signalExitCode(signal);
71
67
  if (exitCode === null) {
package/lib/client.d.ts CHANGED
@@ -20,7 +20,15 @@ export interface FailureReceipt {
20
20
  }
21
21
 
22
22
  export class RiftriError extends Error {
23
+ /**
24
+ * Always a number. A process killed by a signal reports 128 plus the signal
25
+ * number, the convention native `riftri exec` uses for its own children.
26
+ */
23
27
  readonly exitCode: number;
28
+ /** The signal that killed the process, or null if it exited on its own. */
29
+ readonly signal: NodeJS.Signals | null;
30
+ /** The process was killed rather than exiting on its own. */
31
+ readonly wasSignalled: boolean;
24
32
  readonly receipt: FailureReceipt | null;
25
33
  /** Riftri declined before touching anything; falling back is safe. */
26
34
  readonly isPolicyRefusal: boolean;
@@ -136,7 +144,17 @@ export class Riftri {
136
144
  /** Inspect Git and storage. Creates nothing. */
137
145
  doctor(options?: { destination?: string }): Promise<DoctorReport>;
138
146
  backends(path?: string): Promise<unknown>;
139
- /** Convenience: true when this destination can be optimized. */
147
+ /**
148
+ * True when this destination can be optimized.
149
+ *
150
+ * Resolves `false` only when Riftri answers the question: no active
151
+ * copy-on-write backend, a destination Riftri reports as `blocked`, or an
152
+ * outright refusal. A missing or non-executable binary, an unreadable
153
+ * repository, or output that is not JSON rejects instead.
154
+ *
155
+ * A `needs-activation` destination is optimizable: `worktree.add` works
156
+ * without `enable()`, which only gates Git interception.
157
+ */
140
158
  isOptimizable(destination?: string): Promise<boolean>;
141
159
 
142
160
  enable(): Promise<null>;
package/lib/client.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  const { spawn } = require("node:child_process");
11
11
  const { resolveBinary } = require("./platform.js");
12
+ const { signalExitCode } = require("./signals.js");
12
13
 
13
14
  /** Process exit codes. Documented in docs/cli.md. */
14
15
  const EXIT_SUCCESS = 0;
@@ -27,13 +28,25 @@ const REPORTING = new Set([
27
28
  ]);
28
29
 
29
30
  class RiftriError extends Error {
30
- constructor(exitCode, receipt, stderr) {
31
- super(receipt?.message || stderr.trim() || `riftri exited ${exitCode}`);
31
+ constructor(exitCode, receipt, stderr, signal = null) {
32
+ super(
33
+ receipt?.message ||
34
+ stderr.trim() ||
35
+ (signal ? `riftri terminated by ${signal}` : `riftri exited ${exitCode}`),
36
+ );
32
37
  this.name = "RiftriError";
38
+ // Always a number. A signalled process reports 128 + the signal number,
39
+ // the convention native `riftri exec` already uses for its own children.
33
40
  this.exitCode = exitCode;
41
+ this.signal = signal;
34
42
  this.receipt = receipt ?? null;
35
43
  }
36
44
 
45
+ /** The process was killed rather than exiting on its own. */
46
+ get wasSignalled() {
47
+ return this.signal !== null;
48
+ }
49
+
37
50
  /** Riftri declined before touching anything. Falling back is safe. */
38
51
  get isPolicyRefusal() {
39
52
  return this.exitCode === EXIT_POLICY;
@@ -55,6 +68,18 @@ class RiftriError extends Error {
55
68
  }
56
69
  }
57
70
 
71
+ /**
72
+ * Place Riftri's own flags before any `--`. Appending them instead puts them
73
+ * inside the payload `riftri exec` hands to the child, which then fails on a
74
+ * flag meant for Riftri.
75
+ */
76
+ function withRiftriFlags(args, flags) {
77
+ if (flags.length === 0) return [...args];
78
+ const boundary = args.indexOf("--");
79
+ if (boundary === -1) return [...args, ...flags];
80
+ return [...args.slice(0, boundary), ...flags, ...args.slice(boundary)];
81
+ }
82
+
58
83
  function flagsFor(options) {
59
84
  const flags = [];
60
85
  for (const [key, value] of Object.entries(options)) {
@@ -104,7 +129,7 @@ class Riftri {
104
129
  return new Promise((resolve, reject) => {
105
130
  let child;
106
131
  try {
107
- child = spawn(this.#executable(), [...args, ...flags], {
132
+ child = spawn(this.#executable(), withRiftriFlags(args, flags), {
108
133
  cwd,
109
134
  stdio: ["ignore", "pipe", "pipe"],
110
135
  });
@@ -117,9 +142,33 @@ class Riftri {
117
142
  child.stdout.on("data", (chunk) => (stdout += chunk));
118
143
  child.stderr.on("data", (chunk) => (stderr += chunk));
119
144
  child.on("error", reject);
120
- child.on("close", (exitCode) => {
145
+ child.on("close", (exitCode, signal) => {
146
+ // A signalled child reports a null exit code. Translate it rather than
147
+ // letting `null` reach callers through a field typed as a number.
148
+ if (signal) {
149
+ const code = signalExitCode(signal) ?? EXIT_OPERATIONAL;
150
+ reject(new RiftriError(code, null, stderr, signal));
151
+ return;
152
+ }
121
153
  if (exitCode === EXIT_SUCCESS) {
122
- resolve(json && stdout.trim() ? JSON.parse(stdout) : null);
154
+ if (!json || !stdout.trim()) {
155
+ resolve(null);
156
+ return;
157
+ }
158
+ try {
159
+ resolve(JSON.parse(stdout));
160
+ } catch (error) {
161
+ // Thrown from a close handler this would be uncatchable and would
162
+ // take the host process down, so it has to reject instead.
163
+ reject(
164
+ new RiftriError(
165
+ EXIT_OPERATIONAL,
166
+ null,
167
+ `riftri ${args.join(" ")} returned output that is not JSON: ` +
168
+ `${error.message}\n${stdout.slice(0, 2000)}`,
169
+ ),
170
+ );
171
+ }
123
172
  return;
124
173
  }
125
174
  let receipt = null;
@@ -148,13 +197,28 @@ class Riftri {
148
197
  return this.run(["backends", ...target]);
149
198
  }
150
199
 
151
- /** True when this destination can get an optimized worktree. */
200
+ /**
201
+ * True when this destination can get an optimized worktree.
202
+ *
203
+ * `false` is an answer from Riftri: either the report says no backend is
204
+ * active, or Riftri refused this destination outright. A broken
205
+ * installation, an unreadable repository, or any other unexpected failure
206
+ * rejects instead, so a caller can tell "not supported here" apart from
207
+ * "this client cannot run at all".
208
+ */
152
209
  async isOptimizable(destination) {
153
210
  try {
154
211
  const report = await this.doctor(destination ? { destination } : {});
155
- return Boolean(report?.cow_backend_active);
212
+ if (!report?.cow_backend_active) return false;
213
+ // cow_backend_active is a fact about the volume, not about this
214
+ // destination. Outside a Git repository it is still true while
215
+ // `worktree add` cannot possibly succeed, so the readiness verdict
216
+ // decides. `needs-activation` stays optimizable: the explicit interface
217
+ // works without `enable`, which only gates Git interception.
218
+ const readiness = report.destination_readiness?.status;
219
+ return readiness === undefined || readiness !== "blocked";
156
220
  } catch (error) {
157
- if (error.code === "ENOENT" || error instanceof RiftriError === false) return false;
221
+ if (error instanceof RiftriError && error.isPolicyRefusal) return false;
158
222
  throw error;
159
223
  }
160
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riftri",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Experimental lightweight Git workspaces for parallel development",
5
5
  "license": "MIT",
6
6
  "author": "Assistant UI contributors",
@@ -18,14 +18,14 @@
18
18
  "node": ">=18.18"
19
19
  },
20
20
  "optionalDependencies": {
21
- "riftri-darwin-arm64": "0.4.0",
22
- "riftri-darwin-x64": "0.4.0",
23
- "riftri-linux-arm64-gnu": "0.4.0",
24
- "riftri-linux-arm64-musl": "0.4.0",
25
- "riftri-linux-x64-gnu": "0.4.0",
26
- "riftri-linux-x64-musl": "0.4.0",
27
- "riftri-win32-arm64": "0.4.0",
28
- "riftri-win32-x64": "0.4.0"
21
+ "riftri-darwin-arm64": "0.5.0",
22
+ "riftri-darwin-x64": "0.5.0",
23
+ "riftri-linux-arm64-gnu": "0.5.0",
24
+ "riftri-linux-arm64-musl": "0.5.0",
25
+ "riftri-linux-x64-gnu": "0.5.0",
26
+ "riftri-linux-x64-musl": "0.5.0",
27
+ "riftri-win32-arm64": "0.5.0",
28
+ "riftri-win32-x64": "0.5.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",