openvibers/openvibe.host Commits

Remove release-tracked untracked build output before deploy merge (#13)

OP OpenVibers committed 3 Oct 2026 · d65e687343 · 5 files, +121 −0

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by Codex GPT-6 Sol · 1 fix round

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

Changes

ddiff --git a/lib/cli.js b/lib/cli.js
iindex ee81b88..7f27852 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.untrackedGenerated || []) out(` untracked ${f} is build output, 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(' && ')}`);
ddiff --git a/lib/release-layout.js b/lib/release-layout.js
iindex ea63518..ca6cd50 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: [],
++ untrackedGenerated: [],
untrackedDeleted: [],
build: [],
preflight: preflight.describe(svc),
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex 5b63104..34a2bad 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -135,6 +135,22 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
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);
}
++ // A previous build can leave a file that the incoming release starts tracking. Git refuses to
++ // overwrite it, so remove only known generated output or a non-skipped vhost in the declared repo glob.
++ const untrackedGenerated = [];
++ const repoVhosts = svc.nginx?.repoVhosts;
++ if (svc.generated.length || repoVhosts) {
++ for (const f of changed) {
++ const buildOutput = matchesPath(f, svc.generated);
++ const repoVhost = repoVhosts && (repoVhosts.endsWith('*.conf')
++ ? path.dirname(f) === path.dirname(repoVhosts) && path.basename(f).endsWith('.conf')
++ : f === repoVhosts);
++ if (repoVhost && svc.nginx.skipVhosts?.includes(path.basename(f))) continue;
++ if (!buildOutput && !repoVhost) continue;
++ if ((await g.show(from, f)) != null || (await g.show(target, f)) == null) continue;
++ if (await exec.stat(path.join(svc.repo, f))) untrackedGenerated.push(f);
++ }
++ }
const installs = [];
if (svc.install.workspace) {
// A workspace (pnpm): one install at the root, needed when its lockfile or any package's
@@@ -218,6 +234,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
lockfileChanged: installs.some((i) => i.lockfileChanged),
installs,
untrackedLockfiles,
++ untrackedGenerated,
build: svc.build.map((b) => b.join(' ')),
preflight: preflight.describe(svc),
restartNeeded,
@@@ -473,6 +490,11 @@ 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);
}
++ for (const f of plan.untrackedGenerated) {
++ log(`removing untracked build output ${f} (the release tracks it)`);
++ 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
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 70b9a48..70f9946 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -262,6 +262,10 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
// 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(nextFiles)) {
++ if (f in headFiles || !files.has(path.join(repo.path, f))) 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.`);
++ }
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;
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
iindex 3e630dc..fb75636 100644
---- a/test/strategy-in-place.test.js
++++ b/test/strategy-in-place.test.js
@@@ -208,6 +208,99 @@ runTests([
}),
// ── static-build (Sites) ──
++ test('sites: an untracked repo vhost the release tracks is removed before the merge', async () => {
++ const host = sitesHost();
++ const file = 'deploy/nginx/openvibe.work.conf';
++ const local = 'server { # built on host }\n';
++ const incoming = 'server { # tracked by release }\n';
++ host.put(`/opt/openvibe.sites/${file}`, local, { owner: 'ubuntu' });
++ const to = host.push({ [file]: incoming }, 'track work vhost');
++ const plan = JSON.parse((await host.cli('plan', 'sites', '--json')).out);
++ assert.deepStrictEqual(plan.untrackedGenerated, [file]);
++ assert.match((await host.cli('plan', 'sites')).out, /untracked +deploy\/nginx\/openvibe\.work\.conf is build output, removed before the merge/);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ const rm = host.calls.findIndex((c) => c.cmd === 'rm' && c.args.includes(`/opt/openvibe.sites/${file}`));
++ 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.match(r.out, /removing untracked build output deploy\/nginx\/openvibe\.work\.conf \(the release tracks it\)/);
++ assert.strictEqual(host.read(`/opt/openvibe.sites/${file}`), incoming);
++ assert.deepStrictEqual((await lastRecord(host, 'sites')).removedUntracked, [file]);
++ }),
++
++ test('sites: a skipped untracked repo vhost is left for its owning service', async () => {
++ const host = sitesHost();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ raw.services.sites.nginx = { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true, skipVhosts: ['openvibe.bot.conf'] };
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ const file = 'deploy/nginx/openvibe.bot.conf';
++ const from = host.repo.head;
++ host.put(`/opt/openvibe.sites/${file}`, 'server { # Bot-owned }\n', { owner: 'ubuntu' });
++ host.push({ [file]: 'server { # incoming Sites copy }\n' }, 'track bot vhost');
++ const plan = JSON.parse((await host.cli('plan', 'sites', '--json')).out);
++ assert.deepStrictEqual(plan.untrackedGenerated, []);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /untracked working tree files would be overwritten by merge/);
++ assert.strictEqual(host.repo.head, from);
++ assert.strictEqual(host.read(`/opt/openvibe.sites/${file}`), 'server { # Bot-owned }\n');
++ assert.ok(!host.calls.some((c) => c.cmd === 'rm' && c.args.includes(`/opt/openvibe.sites/${file}`)));
++ assert.ok(!(await lastRecord(host, 'sites')).removedUntracked);
++ }),
++
++ test('sites: an untracked dist page the release tracks is removed before the merge', async () => {
++ const host = sitesHost();
++ const file = 'dist/openvibe.work/index.html';
++ const incoming = '<h1>Work</h1>';
++ host.put(`/opt/openvibe.sites/${file}`, '<h1>Built locally</h1>', { owner: 'ubuntu' });
++ const to = host.push({ [file]: incoming }, 'track work page');
++ const plan = JSON.parse((await host.cli('plan', 'sites', '--json')).out);
++ assert.deepStrictEqual(plan.untrackedGenerated, [file]);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ const rm = host.calls.findIndex((c) => c.cmd === 'rm' && c.args.includes(`/opt/openvibe.sites/${file}`));
++ 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/${file}`), incoming);
++ assert.deepStrictEqual((await lastRecord(host, 'sites')).removedUntracked, [file]);
++ }),
++
++ test('sites: an unrelated untracked file the release tracks still blocks the merge', async () => {
++ const host = sitesHost();
++ const file = 'notes.txt';
++ const from = host.repo.head;
++ host.put(`/opt/openvibe.sites/${file}`, 'local notes', { owner: 'ubuntu' });
++ host.push({ [file]: 'release notes' }, 'track notes');
++ const plan = JSON.parse((await host.cli('plan', 'sites', '--json')).out);
++ assert.deepStrictEqual(plan.untrackedGenerated, []);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /untracked working tree files would be overwritten by merge/);
++ assert.strictEqual(host.repo.head, from);
++ assert.strictEqual(host.read(`/opt/openvibe.sites/${file}`), 'local notes');
++ assert.ok(!host.calls.some((c) => c.cmd === 'rm' && c.args.includes(`/opt/openvibe.sites/${file}`)));
++ assert.ok(!(await lastRecord(host, 'sites')).removedUntracked);
++ }),
++
++ test('sites: a nested vhost outside the repo glob still blocks the merge', async () => {
++ const host = sitesHost();
++ const file = 'deploy/nginx/nested/openvibe.work.conf';
++ const from = host.repo.head;
++ host.put(`/opt/openvibe.sites/${file}`, 'local vhost', { owner: 'ubuntu' });
++ host.push({ [file]: 'release vhost' }, 'track nested vhost');
++ const plan = JSON.parse((await host.cli('plan', 'sites', '--json')).out);
++ assert.deepStrictEqual(plan.untrackedGenerated, []);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /untracked working tree files would be overwritten by merge/);
++ assert.strictEqual(host.repo.head, from);
++ assert.strictEqual(host.read(`/opt/openvibe.sites/${file}`), 'local vhost');
++ }),
++
test('sites: build output from the last build is restored first, npm ci + build, vhosts installed behind nginx -t, one notification per placeholder', async () => {
const host = withEvents(sitesHost());
// The previous deploy's build left the tracked dist/ dirty (today's date): that never blocks a deploy.