@niroai/niro 0.7.0 → 0.9.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/CHANGELOG.md +87 -0
- package/package.json +1 -1
- package/src/commands/add-repo.js +1 -0
- package/src/commands/init.js +2 -9
- package/src/commands/release.js +76 -10
- package/src/commands/takeover.js +123 -6
- package/src/lib/folderSync.js +44 -2
- package/src/lib/git.js +44 -3
- package/src/lib/resolveContext.js +85 -3
- package/src/lib/tempProject.js +25 -1
- package/src/mcp/server.js +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@niroai/niro`. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.9.0 — 2026-08-26
|
|
6
|
+
|
|
7
|
+
One change to how the client waits, and two fixes for cases where Niro and your machine
|
|
8
|
+
disagreed about what still exists.
|
|
9
|
+
|
|
10
|
+
### Upgrading
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @niroai/niro@latest
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Nothing extra is needed from 0.8.0. Against an older server the waiting change simply does not
|
|
17
|
+
engage, because the client decides from the server's answer rather than from a version.
|
|
18
|
+
|
|
19
|
+
**Creating a working copy no longer times out.** It takes 44 to 60 seconds, and it used to run
|
|
20
|
+
inside a single request, against a gateway that gives up at 60. Three at once on 26 August all
|
|
21
|
+
came back as gateway error pages, and because such an error says nothing about whether the work
|
|
22
|
+
finished, the real cause underneath went unread for an afternoon. The client now asks the server
|
|
23
|
+
to do it in the background and waits for it to finish, showing progress. A dropped connection
|
|
24
|
+
while waiting is not treated as a failure, because the copy is still being made on the server; an
|
|
25
|
+
expired login is, because waiting cannot fix it.
|
|
26
|
+
|
|
27
|
+
**Discarding a working copy the server had already deleted.** A working copy can go away
|
|
28
|
+
without your machine being told: someone deletes it in the web UI, or the server reaps it
|
|
29
|
+
after it has sat idle for two weeks. The folder on your machine kept a registration pointing
|
|
30
|
+
at it, and `niro discard-temp-project`, which is what clears that registration, failed before
|
|
31
|
+
it got that far. The registration is also what makes `niro new-temp-project` in a second
|
|
32
|
+
worktree refuse, advising you to run discard in the other folder, so the advice named the one
|
|
33
|
+
command that could not work. Discard now treats a project the server says is missing as
|
|
34
|
+
nothing left to delete: it skips the server call and does the local half, pointing the folder
|
|
35
|
+
back at the original project. A server it could not reach, or one that refuses because the
|
|
36
|
+
copy belongs to another developer, still fails loudly and changes nothing, which matters
|
|
37
|
+
because the server answers both of those with the same status code as a deleted project.
|
|
38
|
+
|
|
39
|
+
**A detached checkout stopped Niro answering.** During `git bisect`, or after checking out a
|
|
40
|
+
tag, your repo is on no branch. `git rev-parse --abbrev-ref HEAD` does not fail there, it
|
|
41
|
+
prints the word `HEAD`, and Niro passed that on as though it were a branch name. Nothing
|
|
42
|
+
indexes a branch called `HEAD`, so the server treated every such question as coming from a
|
|
43
|
+
diverged branch and declined to answer it, for the whole bisect. Niro now reports no branch,
|
|
44
|
+
which is what the server already had a rule for, and answers from the indexed project.
|
|
45
|
+
|
|
46
|
+
## 0.8.0 — 2026-08-26
|
|
47
|
+
|
|
48
|
+
Needed before a server has the layered store turned on. On an older client the new
|
|
49
|
+
server-side setting is not just unusable, it is misleading.
|
|
50
|
+
|
|
51
|
+
### Upgrading
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @niroai/niro@latest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Nothing extra is needed from 0.7.0.
|
|
58
|
+
|
|
59
|
+
### Why this release exists
|
|
60
|
+
|
|
61
|
+
A working copy is a private index of the branch in your folder, including edits you have not
|
|
62
|
+
committed. The server can now keep several of them per developer, and can store one as a
|
|
63
|
+
layer over the shared project rather than a full copy. Three things in 0.7.0 get in the way.
|
|
64
|
+
|
|
65
|
+
**A second working copy was refused on your own machine.** 0.7.0 decided by itself that one
|
|
66
|
+
folder already held the project, and never asked the server. Once the server allows several,
|
|
67
|
+
that refusal is simply wrong. Worse, run unattended, by an agent or a script, it threw an
|
|
68
|
+
error advising `niro discard-temp-project` in the other folder. Following that advice
|
|
69
|
+
destroys a working copy that did not need destroying. The client now asks the server and
|
|
70
|
+
does what it says.
|
|
71
|
+
|
|
72
|
+
**A git worktree of a repo with no remote could not be onboarded at all.** Niro records
|
|
73
|
+
onboarding against the folder that was onboarded, which is the main checkout. A linked
|
|
74
|
+
worktree has no entry of its own, so the repo could not be named and `niro new-temp-project`
|
|
75
|
+
failed. It now resolves the main working tree through `git rev-parse --git-common-dir`.
|
|
76
|
+
That combination, a worktree of a remote-less repo, is the layered store's main use case.
|
|
77
|
+
|
|
78
|
+
**Large files were dropped from a working copy without telling you.** The size warning
|
|
79
|
+
existed only in `niro init`. A developer ran `niro new-temp-project` and lost their three
|
|
80
|
+
largest source files, the ones the whole branch was about. Niro then answered questions
|
|
81
|
+
about those files from the shared project's older copy, confidently, with nothing on screen
|
|
82
|
+
to say the working copy did not contain them. The warning now runs on both commands, and it
|
|
83
|
+
quotes the limit the server reports rather than a number compiled into the client, which
|
|
84
|
+
said 256 KiB while a server raised to 2 MiB was accepting the file happily.
|
|
85
|
+
|
|
86
|
+
### Compatibility
|
|
87
|
+
|
|
88
|
+
Against an older server, the per-file limit is not reported and the client falls back to its
|
|
89
|
+
built-in figure, so the warning quotes an estimate rather than the server's real cap. Nothing
|
|
90
|
+
else changes.
|
|
91
|
+
|
|
5
92
|
## 0.7.0 — 2026-08-17
|
|
6
93
|
|
|
7
94
|
A data-loss release. If you use `niro watch`, upgrade.
|
package/package.json
CHANGED
package/src/commands/add-repo.js
CHANGED
|
@@ -174,6 +174,7 @@ async function addRepo(pathArg, opts = {}) {
|
|
|
174
174
|
// Get filter recommendation
|
|
175
175
|
console.log(` Analyzing project structure...`);
|
|
176
176
|
const filterResult = await agentApi.filterManifest(gitId, manifest);
|
|
177
|
+
folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(color.yellow(m)));
|
|
177
178
|
const includedCount = filterResult.included_count || filterResult.includedCount || 0;
|
|
178
179
|
const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
|
|
179
180
|
|
package/src/commands/init.js
CHANGED
|
@@ -422,16 +422,9 @@ async function init(pathArg, opts = {}) {
|
|
|
422
422
|
process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
|
|
423
423
|
const manifest = folderSync.buildManifest(targetDir);
|
|
424
424
|
process.stdout.write(` ${manifest.length} files found.\n`);
|
|
425
|
-
for (const m of manifest) {
|
|
426
|
-
if (m.size_bytes > folderSync.PER_FILE_CAP_BYTES) {
|
|
427
|
-
console.log(warn(
|
|
428
|
-
` ⚠ ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) exceeds the ` +
|
|
429
|
-
`256 KiB per-file cap — it will be skipped and not indexed.`
|
|
430
|
-
));
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
425
|
const filterResult = await agentApi.filterManifest(gitId, manifest);
|
|
426
|
+
// After the filter, not before: the limit is the SERVER's, and it reports it back to us.
|
|
427
|
+
folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
|
|
435
428
|
const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
|
|
436
429
|
const includedCount = filterResult.included_count || filterResult.includedCount || includedPaths.length;
|
|
437
430
|
const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
|
package/src/commands/release.js
CHANGED
|
@@ -22,6 +22,7 @@ const git = require("../lib/git");
|
|
|
22
22
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
23
23
|
const prompt = require("../lib/prompt");
|
|
24
24
|
const watchState = require("../lib/watchState");
|
|
25
|
+
const tempProject = require("../lib/tempProject");
|
|
25
26
|
|
|
26
27
|
const ok = (s) => color.green(s);
|
|
27
28
|
const warn = (s) => color.yellow(s);
|
|
@@ -56,26 +57,80 @@ async function release(pathArg, opts = {}) {
|
|
|
56
57
|
"`niro discard-temp-project --project <temp-project-id>`. The server refuses to delete anything " +
|
|
57
58
|
"that is not a temporary project, so this is always safe.");
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// The temporary project may ALREADY be gone from the server, and nothing tells this machine when
|
|
61
|
+
// that happens: the idle sweeper reaps it after N days, and deleting it in the UI goes down the
|
|
62
|
+
// ordinary project-delete path. This fetch used to throw on that, BEFORE any local cleanup ran, so
|
|
63
|
+
// the folder kept a registration pointing at a project that no longer exists — and the only advice
|
|
64
|
+
// anything offered ("run `niro discard-temp-project` in that folder first") named this very command,
|
|
65
|
+
// which then failed the same way. A definitive not-found means there is nothing left to delete, so
|
|
66
|
+
// skip the server half and do the local half, which is the half that was still needed.
|
|
67
|
+
let project = null;
|
|
68
|
+
let cloneOf = null;
|
|
69
|
+
let alreadyGone = false;
|
|
70
|
+
try {
|
|
71
|
+
project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Only a definitive "no such project" counts. A network blip, a 401, a 500 or a genuine
|
|
74
|
+
// permissions refusal must still fail loudly — rewriting local registrations because the server
|
|
75
|
+
// could not be reached is exactly the mistake this command exists to undo.
|
|
76
|
+
if (!tempProject.isProjectNotFound(err)) throw err;
|
|
77
|
+
alreadyGone = true;
|
|
78
|
+
}
|
|
79
|
+
if (alreadyGone && !opts.project && !(entry && entry.takeover)) {
|
|
80
|
+
// The id did not come from a takeover backup or from the user — it came from a `.niro-project`
|
|
81
|
+
// pin that happens to name a missing project. Nothing here says this folder was ever taken over,
|
|
82
|
+
// so there is no warrant to touch its registration.
|
|
83
|
+
throw new Error(`Project ${tempProjectId} does not exist on this backend, and nothing on this ` +
|
|
84
|
+
"machine records a temporary project for this folder — refusing to change anything. " +
|
|
85
|
+
"If you know the temporary project's id, pass it explicitly: " +
|
|
86
|
+
"`niro discard-temp-project --project <temp-project-id>`.");
|
|
87
|
+
}
|
|
88
|
+
if (!alreadyGone) {
|
|
89
|
+
cloneOf = project.clone_of_project_id || project.cloneOfProjectId;
|
|
90
|
+
if (!cloneOf) {
|
|
91
|
+
throw new Error(`Project ${tempProjectId} is not a temporary project — refusing. ` +
|
|
92
|
+
"(discard only deletes projects created by `niro new-temp-project`)");
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
// Every folder registered against this temp project (multi-repo takeover, ADR-016).
|
|
67
97
|
const currentDir = path.resolve(targetDir);
|
|
68
|
-
|
|
98
|
+
let folders = watchState.allLocal(baseUrl).filter((e) => e.projectId === tempProjectId);
|
|
69
99
|
if (!folders.some((e) => path.resolve(e.folderPath) === currentDir)) {
|
|
70
100
|
folders.push({ folderPath: targetDir, ...(entry || {}) });
|
|
71
101
|
}
|
|
102
|
+
if (alreadyGone) {
|
|
103
|
+
// Two corrections that only matter once the server is no longer there to check the id against.
|
|
104
|
+
//
|
|
105
|
+
// Drop any folder not actually registered against THIS project. The line above adds the current
|
|
106
|
+
// folder unconditionally, which is right when the id came from that folder, and wrong when it came
|
|
107
|
+
// from `--project`: a mistyped or already-discarded id, run from a folder holding a DIFFERENT live
|
|
108
|
+
// working copy, would rewrite that copy's registration and report it as cleaned up. On the alive
|
|
109
|
+
// path the server's own answer bounds the damage; here nothing does.
|
|
110
|
+
folders = folders.filter((e) => e.projectId === tempProjectId);
|
|
111
|
+
// Then recover the source project from whichever folder recorded it. Every folder in one working
|
|
112
|
+
// copy is a clone of the SAME source project (ADR-016 extends a copy with more repos, it never
|
|
113
|
+
// spans two sources), so any one backup answers for all of them — including a sibling that has no
|
|
114
|
+
// backup of its own, which would otherwise be left pointing at the deleted project while the alive
|
|
115
|
+
// path restores it from `clone_of_project_id`.
|
|
116
|
+
const witness = folders.find((e) => e.takeover && e.takeover.sourceProjectId);
|
|
117
|
+
if (!witness) {
|
|
118
|
+
throw new Error(`Project ${tempProjectId} does not exist on this backend, and no folder on this ` +
|
|
119
|
+
"machine records which project it was a working copy of — refusing to guess. " +
|
|
120
|
+
"Nothing has been changed.");
|
|
121
|
+
}
|
|
122
|
+
cloneOf = witness.takeover.sourceProjectId;
|
|
123
|
+
}
|
|
72
124
|
|
|
73
125
|
// ADR-018 discard guard: deleting the temp project kills coverage for EVERY repo in it. Check each
|
|
74
126
|
// folder's CURRENT branch against its recorded source branch; a folder still on a feature branch is
|
|
75
127
|
// in-flight work whose coverage this discard would destroy. Old entries without sourceBranch can't be
|
|
76
128
|
// verified client-side (the server-side nudge guard still covers the agent-driven path).
|
|
129
|
+
// Skipped when the copy is already gone: this guard exists to warn BEFORE destroying coverage, and
|
|
130
|
+
// there is no coverage left to destroy. Leaving it on would ask the developer to confirm losing
|
|
131
|
+
// something they have already lost, and then refuse non-interactively over it.
|
|
77
132
|
const stillDiverged = [];
|
|
78
|
-
for (const e of folders) {
|
|
133
|
+
for (const e of alreadyGone ? [] : folders) {
|
|
79
134
|
const sourceBranch = e.takeover && e.takeover.sourceBranch;
|
|
80
135
|
if (!sourceBranch || !fs.existsSync(e.folderPath)) continue;
|
|
81
136
|
try {
|
|
@@ -103,7 +158,7 @@ async function release(pathArg, opts = {}) {
|
|
|
103
158
|
return;
|
|
104
159
|
}
|
|
105
160
|
}
|
|
106
|
-
} else if (process.stdout.isTTY && !opts.yes) {
|
|
161
|
+
} else if (!alreadyGone && process.stdout.isTTY && !opts.yes) {
|
|
107
162
|
const sure = await prompt.confirm(
|
|
108
163
|
`Discard temporary project ${color.bold(project.alias || tempProjectId)}?`);
|
|
109
164
|
if (!sure) {
|
|
@@ -112,7 +167,9 @@ async function release(pathArg, opts = {}) {
|
|
|
112
167
|
}
|
|
113
168
|
}
|
|
114
169
|
|
|
115
|
-
|
|
170
|
+
if (!alreadyGone) {
|
|
171
|
+
await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
|
|
172
|
+
}
|
|
116
173
|
|
|
117
174
|
// Restore each folder's local registration. ADR-018: the .niro-project file is left ALONE unless it
|
|
118
175
|
// still carries the OLD-STYLE swapped pin (pre-ADR-018 takeover) — then it points at a now-deleted
|
|
@@ -175,7 +232,16 @@ async function release(pathArg, opts = {}) {
|
|
|
175
232
|
}
|
|
176
233
|
}
|
|
177
234
|
|
|
178
|
-
|
|
235
|
+
if (alreadyGone) {
|
|
236
|
+
console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
|
|
237
|
+
color.dim(" (deleted from the UI, or reaped after sitting idle)."));
|
|
238
|
+
console.log(repinned.length
|
|
239
|
+
? ` ${ok("✓")} Cleaned up ${repinned.length} local registration(s) that still pointed at it. ` +
|
|
240
|
+
"Niro routes back to the original project automatically."
|
|
241
|
+
: ` ${ok("✓")} Nothing left to clean up locally.`);
|
|
242
|
+
} else {
|
|
243
|
+
console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
|
|
244
|
+
}
|
|
179
245
|
if (healed.length) {
|
|
180
246
|
console.log(color.dim(" Healed old-style pins:"));
|
|
181
247
|
for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
|
package/src/commands/takeover.js
CHANGED
|
@@ -22,6 +22,7 @@ const git = require("../lib/git");
|
|
|
22
22
|
const prompt = require("../lib/prompt");
|
|
23
23
|
const watchState = require("../lib/watchState");
|
|
24
24
|
const resolveCtxLib = require("../lib/resolveContext");
|
|
25
|
+
const spinner = require("../lib/spinner");
|
|
25
26
|
|
|
26
27
|
const ok = (s) => color.green(s);
|
|
27
28
|
const warn = (s) => color.yellow(s);
|
|
@@ -48,7 +49,18 @@ async function takeover(pathArg, opts = {}) {
|
|
|
48
49
|
const ctx = await resolveCtxLib.resolveContext(targetDir, { apiUrl: opts.apiUrl });
|
|
49
50
|
if (ctx.state === "error") throw new Error(`Could not resolve project: ${ctx.error}`);
|
|
50
51
|
if (!ctx.projects.length) {
|
|
51
|
-
|
|
52
|
+
// Two different dead ends, and telling them apart is the difference between a fix and a guess.
|
|
53
|
+
// A folder with no git remote is not a broken setup: `niro init` never required one. What is
|
|
54
|
+
// missing is the LOCAL record naming the repo, and for a git worktree that record belongs to the
|
|
55
|
+
// main checkout, not to the folder you are standing in. The old message sent every such user off
|
|
56
|
+
// to add a remote, which does not help: the repo keeps the URL it was registered with.
|
|
57
|
+
if (ctx.state === "no_origin") {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"This folder has no git remote, and no Niro repo is recorded for it on this machine. "
|
|
60
|
+
+ "Run `niro init` here, or, if this is a git worktree, run it once in the main checkout, "
|
|
61
|
+
+ "then re-run this command.");
|
|
62
|
+
}
|
|
63
|
+
throw new Error("This folder's git remote does not match any Niro project. Run `niro init` first.");
|
|
52
64
|
}
|
|
53
65
|
let source = ctx.projects[0];
|
|
54
66
|
if (opts.project) {
|
|
@@ -83,8 +95,29 @@ async function takeover(pathArg, opts = {}) {
|
|
|
83
95
|
// already reflecting this folder, and the branch it names has moved on. The server relabels it, which
|
|
84
96
|
// is right, and a prompt there would be pure friction.
|
|
85
97
|
const here = path.resolve(repoRoot);
|
|
86
|
-
|
|
98
|
+
// Matched on the REPO as well as the project, and that second clause is the whole point.
|
|
99
|
+
//
|
|
100
|
+
// The hazard this guard exists for is one REPO checked out in two folders on two branches, both
|
|
101
|
+
// uploading into one copy until every answer is a blend of the two. Matching on the project alone
|
|
102
|
+
// also caught the case immediately below this block: a DIFFERENT repo of the same project, which is
|
|
103
|
+
// the multi-repo extend the very next call performs on purpose (ADR-016).
|
|
104
|
+
//
|
|
105
|
+
// So a developer whose change spans ai-orchestrator and ai-assistant was told their project's copy
|
|
106
|
+
// was "already held by another folder" and asked to take it away from themselves, when the server
|
|
107
|
+
// would have happily added the second repo to the same copy. Verified 2026-08-22.
|
|
108
|
+
//
|
|
109
|
+
// It stands down only when it can PROVE the other folder holds a different repo. An entry written
|
|
110
|
+
// before sourceGitId existed records no repo at all, and for those the honest answer is "cannot
|
|
111
|
+
// tell", which has to mean warn: skipping on an unknown would quietly remove the guard for exactly
|
|
112
|
+
// the older entries most likely to have been left behind. Two tests cover that case.
|
|
113
|
+
// ...and it does not apply at all when the server allows several copies per developer. The hazard
|
|
114
|
+
// named above is two folders uploading into ONE copy; when each folder gets its own, there is no
|
|
115
|
+
// blending to prevent. Refusing anyway is what made LAYERED_STORE_MULTIPLE_WORKTREES_PER_DEVELOPER
|
|
116
|
+
// unreachable: the server answers `multiple_worktrees_allowed: true` on every resolve and the CLI
|
|
117
|
+
// refused the second worktree regardless.
|
|
118
|
+
const holder = ctx.multipleWorktreesAllowed ? null : watchState.allLocal(api.currentBaseUrl()).find((e) =>
|
|
87
119
|
e.takeover && e.takeover.sourceProjectId === source.project_id
|
|
120
|
+
&& (e.takeover.sourceGitId == null || e.takeover.sourceGitId === source.git_id)
|
|
88
121
|
&& path.resolve(e.folderPath) !== here);
|
|
89
122
|
if (holder) {
|
|
90
123
|
const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
|
|
@@ -114,9 +147,7 @@ async function takeover(pathArg, opts = {}) {
|
|
|
114
147
|
// 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
|
|
115
148
|
// the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
|
|
116
149
|
// cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
|
|
117
|
-
const outcome = await
|
|
118
|
-
`/api/projects/${encodeURIComponent(source.project_id)}/clone`,
|
|
119
|
-
{ local_git_ids: [source.git_id], branch });
|
|
150
|
+
const outcome = await requestClone(source.project_id, source.git_id, branch);
|
|
120
151
|
const newProjectId = outcome.new_project_id || outcome.newProjectId;
|
|
121
152
|
const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
|
|
122
153
|
const newGitId = gitIdMap[source.git_id];
|
|
@@ -154,6 +185,9 @@ async function takeover(pathArg, opts = {}) {
|
|
|
154
185
|
const manifest = folderSync.buildManifest(repoRoot);
|
|
155
186
|
const filterResult = await agentApi.filterManifest(newGitId, manifest);
|
|
156
187
|
const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
|
|
188
|
+
// The gap that cost a day: this command had no oversize warning at all, so a working copy
|
|
189
|
+
// silently omitted the three largest source files in the repo.
|
|
190
|
+
folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
|
|
157
191
|
const sizes = {};
|
|
158
192
|
for (const m of manifest) sizes[m.path] = m.size_bytes;
|
|
159
193
|
console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
|
|
@@ -238,4 +272,87 @@ async function takeover(pathArg, opts = {}) {
|
|
|
238
272
|
console.log(color.dim(" routing falls back to the original project by itself."));
|
|
239
273
|
}
|
|
240
274
|
|
|
241
|
-
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ask the server for a working copy, and wait for it without holding the request open.
|
|
278
|
+
*
|
|
279
|
+
* WHY. The copy takes 44 to 60 seconds. It used to run inside the POST, and the gateway in front of
|
|
280
|
+
* the server gives up at 60, so three requests at once on 2026-08-26 all came back as HTML 504 pages.
|
|
281
|
+
* A 504 also says nothing about whether the work finished: the real error underneath, a duplicate key
|
|
282
|
+
* from a migration that had not been run, went unread for an afternoon because of it.
|
|
283
|
+
*
|
|
284
|
+
* WORKS AGAINST BOTH SERVERS, and that is why it branches on the RESPONSE rather than on a version.
|
|
285
|
+
* A server that understands `async` returns 202 and a job id to poll. An older one ignores the unknown
|
|
286
|
+
* field and returns the outcome inline exactly as before, which is the shape this already handled. So
|
|
287
|
+
* there is no capability check to keep in step, and no flag day.
|
|
288
|
+
*/
|
|
289
|
+
async function requestClone(projectId, gitId, branch, opts = {}) {
|
|
290
|
+
const started = await api.post(
|
|
291
|
+
`/api/projects/${encodeURIComponent(projectId)}/clone`,
|
|
292
|
+
{ local_git_ids: [gitId], branch, async: true });
|
|
293
|
+
|
|
294
|
+
const jobId = started && (started.job_id || started.jobId);
|
|
295
|
+
if (!jobId) {
|
|
296
|
+
return started; // older server: it did the work inline and this IS the outcome
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Poll until it finishes. The interval is deliberately not tight: nothing is waiting on the answer
|
|
300
|
+
// faster than a person reads, and a shorter one only adds load to the server doing the copy.
|
|
301
|
+
// A seam, not a knob: tests pass a small value so they do not sit through real polls.
|
|
302
|
+
const POLL_MS = opts.pollMs || 2000;
|
|
303
|
+
const GIVE_UP_MS = opts.giveUpMs || 15 * 60 * 1000;
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
const deadline = startedAt + GIVE_UP_MS;
|
|
306
|
+
const sp = spinner.start("Building your temporary project...");
|
|
307
|
+
try {
|
|
308
|
+
for (;;) {
|
|
309
|
+
await sleep(POLL_MS);
|
|
310
|
+
let job;
|
|
311
|
+
try {
|
|
312
|
+
job = await api.get(`/api/projects/clone/job/${encodeURIComponent(jobId)}`);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
// A blip while polling is not a failed clone. The copy is running on the server regardless of
|
|
315
|
+
// whether this one request reached it, so keep waiting rather than reporting a failure that
|
|
316
|
+
// has not happened. A 404 is different: the job is gone, and waiting cannot bring it back.
|
|
317
|
+
// Waiting cannot fix an expired token or a revoked account, and 15 minutes of silent retrying
|
|
318
|
+
// before finally reporting it is worse than saying so at once.
|
|
319
|
+
if (e && (e.status === 401 || e.status === 403)) {
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
if (e && e.status === 404) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`The server has no record of clone job ${jobId}. It may have expired, or the server was `
|
|
325
|
+
+ "restarted mid-copy. Run `niro new-temp-project` again.");
|
|
326
|
+
}
|
|
327
|
+
if (Date.now() > deadline) throw e;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const state = job && job.state;
|
|
331
|
+
if (state === "SUCCEEDED") {
|
|
332
|
+
sp.stop();
|
|
333
|
+
return job.outcome;
|
|
334
|
+
}
|
|
335
|
+
if (state === "FAILED") {
|
|
336
|
+
sp.stop();
|
|
337
|
+
throw new Error(job.error || "The server could not create the temporary project.");
|
|
338
|
+
}
|
|
339
|
+
if (Date.now() > deadline) {
|
|
340
|
+
sp.stop();
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Still building after ${Math.round(GIVE_UP_MS / 60000)} minutes. The server may still finish; `
|
|
343
|
+
+ `check with \`niro status\` before running this again.`);
|
|
344
|
+
}
|
|
345
|
+
// Deliberately NOT re-labelled with the elapsed time. Without a TTY the spinner prints a fresh
|
|
346
|
+
// line every time the label CHANGES, so putting the seconds in it produced one line per poll:
|
|
347
|
+
// measured at 10 lines in 11 seconds. The spinner already shows elapsed time where it can.
|
|
348
|
+
}
|
|
349
|
+
} finally {
|
|
350
|
+
sp.stop();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sleep(ms) {
|
|
355
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = { takeover, requestClone};
|
package/src/lib/folderSync.js
CHANGED
|
@@ -126,8 +126,9 @@ const IGNORED_DIRS = new Set([
|
|
|
126
126
|
"dist", ".venv", "vendor", ".idea", ".gradle", "out",
|
|
127
127
|
]);
|
|
128
128
|
|
|
129
|
-
//
|
|
130
|
-
// /filter
|
|
129
|
+
// Fallback only, for a server too old to report its own limit. The real limit comes back from
|
|
130
|
+
// /filter as max_file_size_bytes, because a duplicated number is wrong the moment either side is
|
|
131
|
+
// configured: this constant said 256 KiB while a server raised to 2 MiB took the file happily.
|
|
131
132
|
const PER_FILE_CAP_BYTES = 262144;
|
|
132
133
|
|
|
133
134
|
const DEFAULT_CONCURRENCY = 4;
|
|
@@ -482,6 +483,46 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
|
|
|
482
483
|
return hashes;
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Tell the developer which files the server is going to drop for being too large.
|
|
489
|
+
*
|
|
490
|
+
* <p>This is not cosmetic. `niro new-temp-project` silently dropped a developer's three largest source
|
|
491
|
+
* files -- the ones the whole branch was about -- because the warning lived only in `niro init`. Niro
|
|
492
|
+
* then answered questions about those files from the shared project's older copy, confidently, with
|
|
493
|
+
* nothing on screen to say the working copy did not contain them.
|
|
494
|
+
*
|
|
495
|
+
* Uses the limit the SERVER reports, so it cannot drift from what the server actually did. Falls back
|
|
496
|
+
* to the built-in constant only when talking to a server too old to report one.
|
|
497
|
+
*
|
|
498
|
+
* Cross-referenced against excluded_paths where the server sends it, so a file dropped for some other
|
|
499
|
+
* reason (a doc, a lockfile, a property file) is not blamed on its size.
|
|
500
|
+
*
|
|
501
|
+
* @returns the number of files warned about
|
|
502
|
+
*/
|
|
503
|
+
function warnOversizeFiles(manifest, filterResult, log) {
|
|
504
|
+
const cap = Number(filterResult.max_file_size_bytes || filterResult.maxFileSizeBytes) || PER_FILE_CAP_BYTES;
|
|
505
|
+
// Over the limit AND a file that would otherwise have been indexed. The second half is what keeps
|
|
506
|
+
// this honest: a 900 KiB markdown file is over the limit too, but it was never going to be indexed,
|
|
507
|
+
// so blaming its size teaches the reader to ignore the warning.
|
|
508
|
+
//
|
|
509
|
+
// Deliberately NOT cross-referenced against excluded_paths, though that was the first attempt. It
|
|
510
|
+
// reads as the more precise test and is strictly worse: a file the server never saw is absent from
|
|
511
|
+
// that list, so a 3 MB source file went unwarned in testing. Size plus type needs no agreement
|
|
512
|
+
// between two lists to be right.
|
|
513
|
+
const hits = (manifest || []).filter((m) =>
|
|
514
|
+
m.size_bytes > cap && isUploadableName(m.path.split("/").pop()));
|
|
515
|
+
for (const m of hits) {
|
|
516
|
+
log(` \u26a0 ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) is over this server's `
|
|
517
|
+
+ `${Math.round(cap / 1024)} KiB per-file limit \u2014 it will NOT be indexed.`);
|
|
518
|
+
}
|
|
519
|
+
if (hits.length) {
|
|
520
|
+
log(` ${hits.length} file(s) will be missing from this project. Niro will answer about them `
|
|
521
|
+
+ "from whatever version it already has, if any.");
|
|
522
|
+
}
|
|
523
|
+
return hits.length;
|
|
524
|
+
}
|
|
525
|
+
|
|
485
526
|
module.exports = {
|
|
486
527
|
buildManifest,
|
|
487
528
|
walkDir,
|
|
@@ -500,4 +541,5 @@ module.exports = {
|
|
|
500
541
|
IGNORED_DIRS,
|
|
501
542
|
BATCH_SIZE,
|
|
502
543
|
PER_FILE_CAP_BYTES,
|
|
544
|
+
warnOversizeFiles,
|
|
503
545
|
};
|
package/src/lib/git.js
CHANGED
|
@@ -7,6 +7,25 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const { execSync } = require("child_process");
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The branch name from `git rev-parse --abbrev-ref HEAD`, or null when there is no branch.
|
|
12
|
+
*
|
|
13
|
+
* On a detached HEAD (a bisect, a tag checkout, a plain commit checkout) that command does not
|
|
14
|
+
* fail — it prints the literal string "HEAD". A caller that trusts it verbatim therefore reports a
|
|
15
|
+
* branch named "HEAD", which no repository has, and everything downstream reads the checkout as
|
|
16
|
+
* being on an unknown branch rather than on no branch at all.
|
|
17
|
+
*
|
|
18
|
+
* `inspect` below has always collapsed that to null, matching what the `.git/HEAD` file read
|
|
19
|
+
* returns. This is that one rule, shared, so a second caller cannot drift from it. One already had:
|
|
20
|
+
* the MCP bridge sent "HEAD" straight through, so the server's routing ladder took every detached
|
|
21
|
+
* checkout for a diverged branch and refused to answer it, even though the ladder has an explicit
|
|
22
|
+
* guard meant to route exactly that case to the indexed project.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeBranch(raw) {
|
|
25
|
+
const b = typeof raw === "string" ? raw.trim() : "";
|
|
26
|
+
return b && b !== "HEAD" ? b : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
function findGitDir(startDir) {
|
|
11
30
|
let dir = path.resolve(startDir);
|
|
12
31
|
const root = path.parse(dir).root;
|
|
@@ -93,12 +112,33 @@ function inspect(startDir) {
|
|
|
93
112
|
// unavailable the calls return null and behaviour is identical to before.
|
|
94
113
|
if (originUrl === null) originUrl = redactRemoteCreds(gitOut("git remote get-url origin", found.repoRoot));
|
|
95
114
|
if (branch === null) {
|
|
96
|
-
|
|
97
|
-
branch = b && b !== "HEAD" ? b : null; // "HEAD" = detached; keep null like the file read
|
|
115
|
+
branch = normalizeBranch(gitOut("git rev-parse --abbrev-ref HEAD", found.repoRoot));
|
|
98
116
|
}
|
|
99
117
|
return { repoRoot: found.repoRoot, originUrl, branch };
|
|
100
118
|
}
|
|
101
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The root of the MAIN working tree for `repoRoot`.
|
|
122
|
+
*
|
|
123
|
+
* A linked worktree (`git worktree add`) is its own folder on its own branch, but Niro onboarding is
|
|
124
|
+
* recorded against the folder that was actually onboarded, which is the main checkout. Without this a
|
|
125
|
+
* worktree has NO local identity: nothing in ~/.niro/projects.json is keyed by its path, so the repo it
|
|
126
|
+
* belongs to cannot be named at all. That is why `niro new-temp-project` could not be run from a
|
|
127
|
+
* worktree of a repo with no git remote, which is the layered store's main use case.
|
|
128
|
+
*
|
|
129
|
+
* Falls back to `repoRoot` itself when this IS the main working tree, when git cannot answer, or when
|
|
130
|
+
* the common dir is not the usual `<root>/.git` (a bare repo, or an unusual layout). That fallback is
|
|
131
|
+
* the behaviour that existed before this function, so it can only add an answer, never change one.
|
|
132
|
+
*/
|
|
133
|
+
function mainWorktreeRoot(repoRoot) {
|
|
134
|
+
if (!repoRoot) return null;
|
|
135
|
+
const common = gitOut("git rev-parse --git-common-dir", repoRoot);
|
|
136
|
+
if (!common) return repoRoot;
|
|
137
|
+
const abs = path.resolve(repoRoot, common);
|
|
138
|
+
if (path.basename(abs) !== ".git") return repoRoot;
|
|
139
|
+
return path.dirname(abs);
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
/**
|
|
103
143
|
* Run ONE `git check-ignore --stdin` in `repoRoot` and report BOTH the matched
|
|
104
144
|
* paths AND whether the check itself succeeded. Paths must be POSIX-relative to
|
|
@@ -187,4 +227,5 @@ function isTracked(repoRoot, relativePath) {
|
|
|
187
227
|
}
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
module.exports = { inspect, deriveRepoName, checkIgnored, checkIgnoredPaths,
|
|
230
|
+
module.exports = { inspect, mainWorktreeRoot, deriveRepoName, checkIgnored, checkIgnoredPaths,
|
|
231
|
+
redactRemoteCreds, isTracked, normalizeBranch };
|
|
@@ -27,9 +27,17 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Never logs secrets. Resolution failures degrade gracefully (state: "error")
|
|
29
29
|
* rather than throwing, so callers/agents can branch on `state`.
|
|
30
|
+
*
|
|
31
|
+
* A git URL is not an identity every repo has. `niro init` on a folder with no remote registers it
|
|
32
|
+
* with a synthetic `local://<name>` URL (see init.js / add-repo.js) that this module never sends back,
|
|
33
|
+
* and a folder that gains a remote AFTER onboarding keeps the URL it had at the time. Either way the
|
|
34
|
+
* URL join can never match, so when it comes back empty this falls back to the repo id recorded for
|
|
35
|
+
* the folder locally, and asks the server by that instead. See resolveByLocalGitId below.
|
|
30
36
|
*/
|
|
37
|
+
const path = require("path");
|
|
31
38
|
const git = require("./git");
|
|
32
39
|
const api = require("./api");
|
|
40
|
+
const watchState = require("./watchState");
|
|
33
41
|
|
|
34
42
|
async function resolveContext(startPath = process.cwd(), opts = {}) {
|
|
35
43
|
const { repoRoot, originUrl, branch } = git.inspect(startPath);
|
|
@@ -39,7 +47,11 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
if (!originUrl) {
|
|
42
|
-
|
|
50
|
+
// No remote to ask about, but the folder may still be a repo Niro knows: onboarding does not
|
|
51
|
+
// require one. Try the local identity before giving up.
|
|
52
|
+
return resolveByLocalGitId(repoRoot, branch, opts, {
|
|
53
|
+
gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [],
|
|
54
|
+
});
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
// Omit branch when null (detached HEAD) -> URL-only resolve. Do NOT normalize
|
|
@@ -62,13 +74,83 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
|
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
const projects = normalizeProjects(data);
|
|
65
|
-
|
|
77
|
+
const byUrl = {
|
|
66
78
|
gitRoot: repoRoot,
|
|
67
79
|
repoUrl: originUrl,
|
|
68
80
|
branch: branch || null,
|
|
69
81
|
state: projects.length ? "resolved" : "unresolved",
|
|
70
82
|
projects,
|
|
83
|
+
// Whether THIS server lets a developer hold more than one working copy of a project. It is a
|
|
84
|
+
// property of the server's configuration, not of the match, and it was being dropped here: the
|
|
85
|
+
// payload carries it on every resolve precisely so the CLI can stop applying a limit the server
|
|
86
|
+
// no longer enforces, and nothing in the CLI read it. The effect was that the server-side
|
|
87
|
+
// multi-worktree support could not be reached from any client.
|
|
88
|
+
multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
|
|
71
89
|
};
|
|
90
|
+
if (projects.length) return byUrl;
|
|
91
|
+
// The remote matched nothing. That is the normal answer for a folder onboarded before it had this
|
|
92
|
+
// remote, whose stored URL is the `local://` form. Same fallback as the no-origin case.
|
|
93
|
+
return resolveByLocalGitId(repoRoot, branch, opts, byUrl);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve by the repo id this machine already recorded for the folder, and return `unresolved`
|
|
98
|
+
* unchanged when that cannot be done. Only ever called after the URL join came back empty, so it can
|
|
99
|
+
* add a match but never replace one.
|
|
100
|
+
*
|
|
101
|
+
* The id is exact where a URL is a guess: `local://demo` is derived from the folder name, so two
|
|
102
|
+
* unrelated folders both called `demo` would resolve to each other, while the id names one repo.
|
|
103
|
+
*/
|
|
104
|
+
async function resolveByLocalGitId(repoRoot, branch, opts, unresolved) {
|
|
105
|
+
const apiUrl = opts.apiUrl || api.currentBaseUrl();
|
|
106
|
+
for (const gitId of localGitIdCandidates(repoRoot, apiUrl)) {
|
|
107
|
+
const query = { git_id: gitId };
|
|
108
|
+
if (branch) query.branch = branch;
|
|
109
|
+
let data;
|
|
110
|
+
try {
|
|
111
|
+
data = await api.get("/api/projects/resolve", { query, apiUrl: opts.apiUrl });
|
|
112
|
+
} catch {
|
|
113
|
+
// A server that predates `git_id` rejects the whole request, and would reject it for every
|
|
114
|
+
// candidate. Give back exactly what this folder resolved to before the parameter existed.
|
|
115
|
+
return unresolved;
|
|
116
|
+
}
|
|
117
|
+
const projects = normalizeProjects(data);
|
|
118
|
+
if (projects.length) {
|
|
119
|
+
return {
|
|
120
|
+
...unresolved,
|
|
121
|
+
state: "resolved",
|
|
122
|
+
projects,
|
|
123
|
+
multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return unresolved;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Repo ids worth asking the server about for this folder, most specific first.
|
|
132
|
+
*
|
|
133
|
+
* Two folders are consulted: this one, and the MAIN working tree when this is a linked worktree. The
|
|
134
|
+
* second is the one that matters. A worktree is never itself onboarded, so it has no entry of its own,
|
|
135
|
+
* and looking only at the folder we are standing in is why a worktree could not be resolved at all.
|
|
136
|
+
*
|
|
137
|
+
* A folder that has already been taken over holds the COPY's gitId in `gitId`; the repo to resolve is
|
|
138
|
+
* the source it was taken from, which is why `takeover.sourceGitId` wins where it is present. Without
|
|
139
|
+
* that a re-run would ask about a temporary project's own repo, which the server correctly refuses.
|
|
140
|
+
*/
|
|
141
|
+
function localGitIdCandidates(repoRoot, apiUrl) {
|
|
142
|
+
const folders = [path.resolve(repoRoot)];
|
|
143
|
+
const main = git.mainWorktreeRoot(repoRoot);
|
|
144
|
+
if (main && path.resolve(main) !== folders[0]) folders.push(path.resolve(main));
|
|
145
|
+
|
|
146
|
+
const ids = [];
|
|
147
|
+
for (const folder of folders) {
|
|
148
|
+
const entry = watchState.get(folder, apiUrl);
|
|
149
|
+
if (!entry) continue;
|
|
150
|
+
const id = (entry.takeover && entry.takeover.sourceGitId) || entry.gitId;
|
|
151
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
152
|
+
}
|
|
153
|
+
return ids;
|
|
72
154
|
}
|
|
73
155
|
|
|
74
156
|
/**
|
|
@@ -129,4 +211,4 @@ function projectIdFromCreateResponse(project) {
|
|
|
129
211
|
return id.trim();
|
|
130
212
|
}
|
|
131
213
|
|
|
132
|
-
module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse };
|
|
214
|
+
module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse, localGitIdCandidates };
|
package/src/lib/tempProject.js
CHANGED
|
@@ -34,4 +34,28 @@ async function currentBranch(tempProjectId, gitId, opts = {}) {
|
|
|
34
34
|
return match && match.branch ? match.branch : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* True when an error from a project read means "this project does not exist on this backend".
|
|
39
|
+
*
|
|
40
|
+
* Status alone cannot answer it. A deleted project fails its access check before anything looks for
|
|
41
|
+
* the row, and that check raises AccessDeniedException, which the backend maps to 403 — the SAME
|
|
42
|
+
* status it returns when the project is alive but belongs to someone else
|
|
43
|
+
* (AccessControlService.validateProjectAccess, GlobalExceptionHandler). Treating every 403 as "gone"
|
|
44
|
+
* would let a permissions problem wipe a healthy local registration, and treating none of them as
|
|
45
|
+
* gone is what left dead registrations behind in the first place. So the message decides: only a
|
|
46
|
+
* missing row produces "Project not found", while the other two say "User does not have access to
|
|
47
|
+
* project" and "User not found".
|
|
48
|
+
*
|
|
49
|
+
* A bare status is deliberately NOT enough, not even a 404. This route does not produce one today,
|
|
50
|
+
* so a 404 here means something other than the API answered: a proxy, or an HTML error page. Its
|
|
51
|
+
* sibling agentApi.isRepoNotFound does accept a bare 404, and can afford to — being wrong there
|
|
52
|
+
* costs a re-registration. Being wrong here destroys the record of how to undo a takeover, so this
|
|
53
|
+
* one fails toward keeping it: an unrecognised failure throws and changes nothing.
|
|
54
|
+
*/
|
|
55
|
+
function isProjectNotFound(err) {
|
|
56
|
+
if (!err) return false;
|
|
57
|
+
const text = `${err.message || ""} ${typeof err.body === "string" ? err.body : ""}`;
|
|
58
|
+
return /project not found/i.test(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { currentBranch, isProjectNotFound };
|
package/src/mcp/server.js
CHANGED
|
@@ -41,7 +41,7 @@ const path = require("path");
|
|
|
41
41
|
const readline = require("readline");
|
|
42
42
|
const { execSync } = require("child_process");
|
|
43
43
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
44
|
-
const { redactRemoteCreds } = require("../lib/git");
|
|
44
|
+
const { redactRemoteCreds, normalizeBranch } = require("../lib/git");
|
|
45
45
|
const receiptStore = require("../lib/receiptStore");
|
|
46
46
|
|
|
47
47
|
/** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
|
|
@@ -161,8 +161,13 @@ function detectGitContext(dir) {
|
|
|
161
161
|
if (dir) opts.cwd = dir;
|
|
162
162
|
const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
|
|
163
163
|
if (!remote) return null;
|
|
164
|
+
// normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
|
|
165
|
+
// failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
|
|
166
|
+
// Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
|
|
167
|
+
// diverged and refused to answer it — past its own guard for detached HEAD, which only fires on
|
|
168
|
+
// a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
|
|
164
169
|
let branch = null;
|
|
165
|
-
try { branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)
|
|
170
|
+
try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
|
|
166
171
|
// The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
|
|
167
172
|
// the bridge must report them — without git_commit the server-side check no-ops entirely.
|
|
168
173
|
let commit = null;
|