openvibers/openvibe.host Commits

An untracked file the release adds byte for byte is removed before the merge (Sites' generated openvibe.work vhost, once committed, blocked the deploy)

OP OpenVibers committed 3 Oct 2026 · a6f18af894 · 4 files, +45 −9


Changes

ddiff --git a/lib/git.js b/lib/git.js
iindex b29da33..45757ac 100644
---- a/lib/git.js
++++ b/lib/git.js
@@@ -28,6 +28,8 @@ function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
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),
++ /** Paths <to> has and <from> does not (in either direction: a rollback's target can be the older commit). */
++ addedFiles: async (from, to) => (await run(['diff', '--name-only', '--diff-filter=A', 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),
show: async (sha, file) => { const r = await run(['show', `${sha}:${file}`], { allowFail: true }); return r.code === 0 ? r.stdout : null; },
ffMerge: async (sha) => { await run(['merge', '--ff-only', '--quiet', sha]); },
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex d561640..5b63104 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -19,6 +19,8 @@
* - readiness is polled after the restart; if it fails the checkout goes back to the previous sha,
* dependencies are reinstalled if they differed, and the service is restarted again.
* - every attempt (including refusals) is appended to the release log.
++ * - an untracked file the incoming release adds with exactly the same bytes (a build output a later
++ * release commits) is removed before the merge; different bytes still refuse.
* - 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); a tracked path the
* release stops tracking (npm replaced the tracked node_modules symlink with a directory) is
@@@ -124,13 +126,14 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
}
// npm leaves an untracked lockfile in an app that did not track one; once the release tracks it,
// the merge refuses to overwrite it. Those files are generated: they are removed before the merge.
++ // So is any untracked file whose bytes are exactly what the release brings (nothing is lost): a
++ // build leaves its output untracked until a later release commits it (2026-10-03: Sites'
++ // deploy/nginx/openvibe.work.conf, generated on the host, then committed — the merge refused).
const untrackedLockfiles = [];
-- if (svc.removeUntrackedLockfiles) {
-- for (const f of changed) {
-- if (path.basename(f) !== svc.install.lockfile || deps.skipped(path.dirname(f), svc.skipPackages)) continue;
-- if (!(await exec.stat(path.join(svc.repo, f)))) continue;
-- if ((await g.show(from, f)) == null) untrackedLockfiles.push(f);
-- }
++ for (const f of from === target ? [] : await g.addedFiles(from, target)) {
++ if (!(await exec.stat(path.join(svc.repo, f)))) continue;
++ const lockfile = svc.removeUntrackedLockfiles && path.basename(f) === svc.install.lockfile && !deps.skipped(path.dirname(f), svc.skipPackages);
++ if (lockfile || (await exec.readFile(path.join(svc.repo, f))) === (await g.show(target, f))) untrackedLockfiles.push(f);
}
const installs = [];
if (svc.install.workspace) {
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex eb00996..70b9a48 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -229,9 +229,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
case 'fetch': return ok();
case 'diff': {
-- const a = repo.commits.get(resolveRef(repo, rest[1])).files;
-- const b = repo.commits.get(resolveRef(repo, rest[2])).files;
-- const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((f) => a[f] !== b[f]).sort();
++ const refs = rest.filter((x) => !x.startsWith('--'));
++ const a = repo.commits.get(resolveRef(repo, refs[0])).files;
++ const b = repo.commits.get(resolveRef(repo, refs[1])).files;
++ const added = rest.includes('--diff-filter=A');
++ const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((f) => (added ? !(f in a) && f in b : a[f] !== b[f])).sort();
return ok(names.length ? `${names.join('\n')}\n` : '');
}
case 'log': {
@@@ -265,6 +267,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
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.`);
}
++ // An untracked file where the incoming tree adds one blocks it too, even byte for byte the same
++ // (real git: Sites' deploy/nginx/openvibe.work.conf, 2026-10-03).
++ for (const f of Object.keys(nextFiles)) {
++ if (f in headFiles || host.read(path.join(repo.path, f)) == null) continue;
++ return fail(`error: The following untracked working tree files would be overwritten by merge:\n\t${f}\nPlease move or remove them before you merge.\nAborting`);
++ }
repo.checkout(sha);
repo.indexRemoved.clear();
repo.skipWorktree.clear();
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
iindex 9c83f86..3e630dc 100644
---- a/test/strategy-in-place.test.js
++++ b/test/strategy-in-place.test.js
@@@ -233,6 +233,29 @@ runTests([
assert.strictEqual(again.code, 0, again.out);
}),
++ test('sites: an untracked build output the release now tracks byte for byte is removed before the merge; a different one still refuses', async () => {
++ const host = sitesHost();
++ host.put('/opt/openvibe.sites/deploy/nginx/openvibe.work.conf', '# work v1', { owner: 'ubuntu' }); // the last build generated it
++ host.push({ 'deploy/nginx/openvibe.work.conf': '# work v1' }, 'track the work vhost');
++ const plan = await host.cli('plan', 'sites');
++ assert.match(plan.out, /untracked +deploy\/nginx\/openvibe\.work\.conf is removed before the merge/);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ const rm = host.calls.findIndex((c) => c.cmd === 'rm' && c.args.includes('/opt/openvibe.sites/deploy/nginx/openvibe.work.conf'));
++ const merge = host.calls.findIndex((c) => c.cmd === 'git' && c.args.includes('merge'));
++ assert.ok(rm >= 0 && rm < merge, 'removed before the merge'); assert.strictEqual(host.calls[rm].as, 'ubuntu');
++ assert.strictEqual(host.read('/opt/openvibe.sites/deploy/nginx/openvibe.work.conf'), '# work v1');
++ assert.deepStrictEqual((await lastRecord(host, 'sites')).removedUntracked, ['deploy/nginx/openvibe.work.conf']);
++ // Different bytes are someone's file: never removed, the merge refuses as before.
++ const h2 = sitesHost();
++ h2.put('/opt/openvibe.sites/deploy/nginx/local.conf', '# a hand edit', { owner: 'ubuntu' });
++ h2.push({ 'deploy/nginx/local.conf': '# from the repository' }, 'track local.conf');
++ const r2 = await h2.cli('deploy', 'sites');
++ assert.notStrictEqual(r2.code, 0, r2.out); assert.match(r2.out, /deploy\/nginx\/local\.conf \/ Please move or remove them before you merge/);
++ assert.strictEqual(h2.read('/opt/openvibe.sites/deploy/nginx/local.conf'), '# a hand edit');
++ assert.ok(!h2.calls.some((c) => c.cmd === 'rm' && c.args.includes('/opt/openvibe.sites/deploy/nginx/local.conf')));
++ }),
++
test('sites: nothing new does nothing', async () => {
const host = sitesHost();
const r = await host.cli('deploy', 'sites');