openvibers/openvibe.host Commits

Drop replaced tracked paths from the index before the deploy merge (#9)

OP OpenVibers committed 2 Oct 2026 · 7fa9a874a1 · 6 files, +142 −6

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Host/pull/9

Changes

ddiff --git a/lib/cli.js b/lib/cli.js
iindex 63e98ba..ee81b88 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -299,6 +299,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
out(` files changed ${p.changedFiles} (${p.codeChanged} outside the no-restart paths)`);
if (p.layout !== 'release') out(` tracked changes ${p.dirty.length ? `${p.dirty.join(', ')} — deploy will refuse` : 'none'}${p.generatedDirty && p.generatedDirty.length ? ` (${p.generatedDirty.length} build output file(s) restored from git first)` : ''}`);
for (const f of p.untrackedLockfiles || []) out(` untracked ${f} is removed before the merge (the release tracks it)`);
++ for (const f of p.untrackedDeleted || []) out(` untracked ${f} is untracked before the merge (the release stops tracking it)`);
for (const i of p.installs) out(` ${i.pkg.padEnd(18)} ${i.install ? `install (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'deps changed', i.missingNodeModules && 'no node_modules'].filter(Boolean).join(', ') || 'always'})` : i.reuse ? `dependencies unchanged — node_modules ${i.reuse === 'its own' ? 'kept (its own)' : `hard-linked from ${i.reuse}`}` : 'dependencies unchanged'}`);
if (p.build && p.build.length) out(` build ${p.build.join(' && ')}`);
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
ddiff --git a/lib/git.js b/lib/git.js
iindex 96f452a..b29da33 100644
---- a/lib/git.js
++++ b/lib/git.js
@@@ -20,6 +20,12 @@ function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
hasCommit: async (sha) => (await run(['cat-file', '-e', `${sha}^{commit}`], { allowFail: true })).code === 0,
/** Tracked changes only; untracked files (data/, local notes) never block a deploy. */
dirty: async () => (await run(['status', '--porcelain', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map((l) => l.slice(3)),
++ /** Paths the index marks skip-worktree (ls-files -v prints 'S'); git status hides them. */
++ skipWorktree: async () => (await run(['ls-files', '-v'])).stdout.split('\n').filter((l) => l.startsWith('S ')).map((l) => l.slice(2)),
++ /** Drop the skip-worktree bit so the path can leave the index; a no-op when it was not set. */
++ noSkipWorktree: async (file) => { await run(['update-index', '--no-skip-worktree', '--', file], { allowFail: true }); },
++ /** Remove a path from the INDEX only: the working-tree files (node_modules) stay on disk. */
++ rmCached: async (file) => { await run(['rm', '--cached', '-q', '-r', '--', file]); },
fetch: async () => { await run(['fetch', '--quiet', svc.remote, svc.branch]); },
changedFiles: async (from, to) => (await run(['diff', '--name-only', from, to])).stdout.split('\n').filter(Boolean),
log: async (from, to) => (await run(['log', '--oneline', '--no-decorate', `${from}..${to}`], { allowFail: true })).stdout.split('\n').filter(Boolean),
ddiff --git a/lib/release-layout.js b/lib/release-layout.js
iindex 3aeda41..ea63518 100644
---- a/lib/release-layout.js
++++ b/lib/release-layout.js
@@@ -236,6 +236,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
lockfileChanged: installs.some((i) => i.lockfileChanged),
installs,
untrackedLockfiles: [],
++ untrackedDeleted: [],
build: [],
preflight: preflight.describe(svc),
restartNeeded,
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex ec0e17b..d561640 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -20,8 +20,11 @@
* dependencies are reinstalled if they differed, and the service is restarted again.
* - every attempt (including refusals) is appended to the release log.
* - multi-app (Tools): apps/_* are packages, not apps (skipPackages); an untracked lockfile the
-- * incoming release tracks is removed before the merge (it would block it); preflight checks run
-- * in each app before anything restarts; every unit file matching unitsMatch is restarted.
++ * incoming release tracks is removed before the merge (it would block it); a tracked path the
++ * release stops tracking (npm replaced the tracked node_modules symlink with a directory) is
++ * dropped from the index first, so the merge has nothing local to overwrite — the files stay on
++ * disk; preflight checks run in each app before anything restarts; every unit file matching
++ * unitsMatch is restarted.
* - static-build (Sites) and pnpm-build (Games): tracked build output (`generated`) is put back
* from git before the checkout moves, so a previous build never blocks a deploy; when anything
* fails the checkout is restored AND rebuilt, since a build writes what is being served.
@@@ -92,6 +95,26 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const changed = from === target ? [] : await g.changedFiles(from, target);
const commits = from === target ? [] : mode === 'deploy' ? await g.log(from, target) : await g.log(target, from);
++ // A host tool can replace a tracked path with something else: npm turns the tracked node_modules
++ // symlink into a real directory. When the release stops tracking that path, `git merge --ff-only`
++ // refuses ("Please commit your changes or stash them") because it would delete a local change —
++ // even with the skip-worktree bit set, which is why those paths are listed separately. They are
++ // dropped from the index before the merge (the files on disk stay), but only when EVERY dirty or
++ // skip-worktree path is one the release no longer tracks: any other dirty path is a real local
++ // change and still refuses, exactly as before.
++ const skipWorktree = await g.skipWorktree();
++ const dirtyOrSkipped = [...new Set([...dirty, ...skipWorktree.filter((f) => !matchesPath(f, svc.generated))])];
++ let untrackedDeleted = [];
++ if (dirtyOrSkipped.length) {
++ const stoppedTracking = [];
++ for (const f of dirtyOrSkipped) {
++ if ((await g.show(from, f)) == null) continue;
++ if ((await g.show(target, f)) == null) stoppedTracking.push(f);
++ }
++ if (stoppedTracking.length === dirtyOrSkipped.length) untrackedDeleted = stoppedTracking;
++ }
++ const dirtyFinal = dirty.filter((f) => !untrackedDeleted.includes(f));
++
const pkgDirs = await deps.expandPackages(exec, svc);
for (const f of changed) {
if (path.basename(f) !== 'package.json') continue;
@@@ -182,7 +205,8 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
from,
to: target,
upToDate: from === target,
-- dirty,
++ dirty: dirtyFinal,
++ untrackedDeleted,
generatedDirty,
commits,
changedFiles: changed.length,
@@@ -446,6 +470,16 @@ async function apply(ctx, svc, mode, opts = {}) {
await exec.run('rm', ['-f', '--', path.join(svc.repo, f)], { as: svc.owner });
(record.removedUntracked = record.removedUntracked || []).push(f);
}
++ // A path the release stops tracking (the tracked node_modules symlink npm replaced with a
++ // directory): clear the skip-worktree bit and take it out of the INDEX so the merge does not
++ // see a local change it would overwrite. Never `rm`: node_modules stays on disk. On a rollback
++ // to `from` git's reset --hard puts the path back into the index (and the working tree).
++ for (const f of plan.untrackedDeleted || []) {
++ log(`dropping ${f} from the index (the release stops tracking it; the files on disk stay)`);
++ await g.noSkipWorktree(f);
++ await g.rmCached(f);
++ (record.droppedFromIndex = record.droppedFromIndex || []).push(f);
++ }
if (!plan.upToDate) {
if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 56233d5..eb00996 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -123,7 +123,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
// ── git ──
function newSha(seed) { seq += 1; return crypto.createHash('sha1').update(`${seq}:${seed}`).digest('hex'); }
host.createRepo = (repoPath, { owner = 'ubuntu', branch = 'main', remote = 'origin', worktree = true } = {}) => {
-- const repo = { path: repoPath, owner, branch, remote, commits: new Map(), head: null, remoteRefs: {}, worktrees: new Set() };
++ const repo = { path: repoPath, owner, branch, remote, commits: new Map(), head: null, remoteRefs: {}, worktrees: new Set(), skipWorktree: new Set(), indexRemoved: new Set() };
repos.set(repoPath, repo);
ensureDir(repoPath, owner);
files.get(path.resolve(repoPath)).owner = owner;
@@@ -147,7 +147,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
};
/** `git worktree add --detach <dir> <sha>`: a checkout sharing the clone's commits. */
function addWorktree(parent, dir, sha, owner) {
-- const wt = { path: dir, owner, branch: 'HEAD', remote: parent.remote, commits: parent.commits, head: null, remoteRefs: parent.remoteRefs, worktreeOf: parent.path };
++ const wt = { path: dir, owner, branch: 'HEAD', remote: parent.remote, commits: parent.commits, head: null, remoteRefs: parent.remoteRefs, worktreeOf: parent.path, skipWorktree: new Set(), indexRemoved: new Set() };
wt.checkout = (s2) => {
const old = wt.head ? wt.commits.get(wt.head).files : {};
const next = wt.commits.get(s2).files;
@@@ -210,9 +210,23 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
case 'status': {
const head = repo.commits.get(repo.head).files;
-- const dirty = Object.entries(head).filter(([f, c]) => host.read(path.join(repo.path, f)) !== c).map(([f]) => ` M ${f}`);
++ const dirty = Object.entries(head)
++ .filter(([f, c]) => !repo.skipWorktree.has(f) && !repo.indexRemoved.has(f) && host.read(path.join(repo.path, f)) !== c)
++ .map(([f]) => ` M ${f}`);
return ok(dirty.length ? `${dirty.join('\n')}\n` : '');
}
++ case 'ls-files': {
++ // `-v`: the skip-worktree bit is the uppercase 'S' tag (others are lowercase).
++ const head = repo.commits.get(repo.head).files;
++ const lines = Object.keys(head).filter((f) => !repo.indexRemoved.has(f)).map((f) => `${repo.skipWorktree.has(f) ? 'S' : 'H'} ${f}`);
++ return ok(lines.length ? `${lines.join('\n')}\n` : '');
++ }
++ case 'update-index': {
++ const skip = rest.includes('--skip-worktree') ? true : rest.includes('--no-skip-worktree') ? false : null;
++ if (skip == null) return fail('fake git: update-index supports --skip-worktree/--no-skip-worktree');
++ for (const f of rest.slice(rest.indexOf('--') + 1)) { if (skip) repo.skipWorktree.add(f); else repo.skipWorktree.delete(f); }
++ return ok();
++ }
case 'fetch': return ok();
case 'diff': {
const a = repo.commits.get(resolveRef(repo, rest[1])).files;
@@@ -232,10 +246,28 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const c = repo.commits.get(resolveRef(repo, ref));
return c && file in c.files ? ok(c.files[file]) : fail(`fatal: path '${file}' does not exist`);
}
++ case 'rm': {
++ // `git rm --cached -q -r -- <path>`: the index only, the working tree is untouched.
++ if (!rest.includes('--cached')) return fail('fake git: rm without --cached would remove the working tree');
++ for (const f of rest.slice(rest.indexOf('--') + 1)) { repo.indexRemoved.add(f); repo.skipWorktree.delete(f); }
++ return ok();
++ }
case 'merge': {
const sha = resolveRef(repo, rest[rest.length - 1]);
if (!isAncestor(repo, repo.head, sha)) return fail('fatal: Not possible to fast-forward, aborting.');
++ // A local change to a path the incoming tree DELETES blocks the merge — the real git
++ // behavior behind the tracked node_modules symlink npm replaced with a directory — unless
++ // the path has left the index (`git rm --cached`). skip-worktree does not help.
++ const headFiles = repo.commits.get(repo.head).files;
++ const nextFiles = repo.commits.get(sha).files;
++ for (const f of Object.keys(headFiles)) {
++ if (f in nextFiles || repo.indexRemoved.has(f)) continue;
++ if (host.read(path.join(repo.path, f)) === headFiles[f]) continue;
++ return fail(`error: Your local changes to the following files would be overwritten by merge:\n\t${f}\nPlease commit your changes or stash them before you merge.`);
++ }
repo.checkout(sha);
++ repo.indexRemoved.clear();
++ repo.skipWorktree.clear();
return ok();
}
case 'reset': repo.checkout(resolveRef(repo, rest[rest.length - 1])); return ok();
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
iindex 78bf09b..9c83f86 100644
---- a/test/strategy-in-place.test.js
++++ b/test/strategy-in-place.test.js
@@@ -22,6 +22,20 @@ function withEvents(host) {
return host;
}
++/** The running commit tracks node_modules as a symlink; npm on the host replaced it with a real
++ * directory; the incoming release deletes node_modules. Returns the setup so a test can add the
++ * skip-worktree bit before deploying. */
++function nodeModulesReplaced() {
++ const host = toolsHost();
++ const link = host.repo.commit({ node_modules: 'shared/node_modules' }, { message: 'track the node_modules symlink' });
++ host.repo.checkout(link);
++ host.exec.removeFile('/opt/openvibe.tools/node_modules');
++ host.put('/opt/openvibe.tools/node_modules/express/package.json', JSON.stringify({ name: 'express' }), { owner: 'ubuntu' });
++ const to = host.repo.commit({ node_modules: null }, { parent: link, message: 'stop tracking node_modules' });
++ host.repo.publish(to);
++ return { host, link, to };
++}
++
runTests([
// ── multi-app (Tools) ──
test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {
@@@ -80,6 +94,54 @@ runTests([
assert.deepStrictEqual((await lastRecord(host, 'tools')).removedUntracked, ['apps/maps/package-lock.json']);
}),
++ // ── a tracked path the release stops tracking (npm replaced the node_modules symlink) ──
++
++ test('tools: a tracked node_modules symlink npm replaced with a directory is dropped from the index before the merge; the directory stays', async () => {
++ const { host, to } = nodeModulesReplaced();
++ const plan = await host.cli('plan', 'tools');
++ assert.match(plan.out, /tracked changes\s+none/, plan.out);
++ assert.match(plan.out, /untracked +node_modules is untracked before the merge \(the release stops tracking it\)/);
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ const git = (s) => host.calls.findIndex((c) => c.cmd === 'git' && c.args.includes(s));
++ const idx = host.calls.findIndex((c) => c.cmd === 'git' && c.args.includes('update-index'));
++ const rm = host.calls.findIndex((c) => c.cmd === 'git' && c.args.includes('rm') && c.args.includes('--cached'));
++ const merge = git('merge');
++ assert.ok(idx >= 0 && rm >= 0 && idx < merge && rm < merge, 'skip-worktree cleared and the path removed from the index before the merge');
++ assert.strictEqual(host.calls[idx].as, 'ubuntu');
++ assert.strictEqual(host.calls[rm].as, 'ubuntu');
++ assert.ok(host.read('/opt/openvibe.tools/node_modules/express/package.json'), 'node_modules is never deleted from disk');
++ assert.deepStrictEqual((await lastRecord(host, 'tools')).droppedFromIndex, ['node_modules']);
++ }),
++
++ test('tools: the same with skip-worktree set — git status hides it, the merge still refuses unless it leaves the index', async () => {
++ const { host, to } = nodeModulesReplaced();
++ // No --force: the real merge fails on the local typechange even under skip-worktree, so the
++ // deploy only succeeds because the path leaves the index first.
++ await host.exec.run('git', ['-C', '/opt/openvibe.tools', 'update-index', '--skip-worktree', '--', 'node_modules'], { as: 'ubuntu' });
++ const plan = await host.cli('plan', 'tools');
++ assert.match(plan.out, /untracked +node_modules is untracked before the merge/);
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ assert.ok(host.read('/opt/openvibe.tools/node_modules/express/package.json'));
++ assert.deepStrictEqual((await lastRecord(host, 'tools')).droppedFromIndex, ['node_modules']);
++ }),
++
++ test('tools: a dirty tracked file the release still tracks is still refused, nothing dropped from the index', async () => {
++ const host = toolsHost();
++ host.put('/opt/openvibe.tools/apps/img/server/index.js', 'locally-edited();', { owner: 'ubuntu' });
++ host.push({ 'apps/img/server/index.js': 'img2();' }, 'img change');
++ const plan = await host.cli('plan', 'tools');
++ assert.match(plan.out, /tracked changes\s+apps\/img\/server\/index\.js — deploy will refuse/);
++ assert.doesNotMatch(plan.out, /untracked +apps/);
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /tracked local changes \(apps\/img\/server\/index\.js\)/);
++ assert.ok(!host.calls.some((c) => c.cmd === 'git' && (c.args.includes('rm') || c.args.includes('update-index'))), 'the index is untouched');
++ }),
++
test('tools: a preflight that fails (the jobs runtime does not load) aborts before any restart and restores the checkout', async () => {
const host = toolsHost();
const from = host.repo.head;