openvibers/openvibe.host Commits
Remove release-tracked untracked build output before deploy merge (#13)
Changes
@@@ -299,6 +299,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procout(` 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(' && ')}`);@@@ -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),@@@ -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@@@ -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;@@@ -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.
@@ -299,6 +299,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -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)`);
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)` : ''}`);
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.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 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'}`);
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.build && p.build.length) out(` build ${p.build.join(' && ')}`);
@@ -236,6 +236,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
@@ -236,6 +236,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
lockfileChanged: installs.some((i) => i.lockfileChanged),
lockfileChanged: installs.some((i) => i.lockfileChanged),
installs,
installs,
untrackedLockfiles: [],
untrackedLockfiles: [],
+ untrackedGenerated: [],
untrackedDeleted: [],
untrackedDeleted: [],
build: [],
build: [],
preflight: preflight.describe(svc),
preflight: preflight.describe(svc),
@@ -135,6 +135,22 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
@@ -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);
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);
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 = [];
const installs = [];
if (svc.install.workspace) {
if (svc.install.workspace) {
// A workspace (pnpm): one install at the root, needed when its lockfile or any package's
// 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,
@@ -218,6 +234,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
lockfileChanged: installs.some((i) => i.lockfileChanged),
lockfileChanged: installs.some((i) => i.lockfileChanged),
installs,
installs,
untrackedLockfiles,
untrackedLockfiles,
+ untrackedGenerated,
build: svc.build.map((b) => b.join(' ')),
build: svc.build.map((b) => b.join(' ')),
preflight: preflight.describe(svc),
preflight: preflight.describe(svc),
restartNeeded,
restartNeeded,
@@ -473,6 +490,11 @@ async function apply(ctx, svc, mode, opts = {}) {
@@ -473,6 +490,11 @@ async function apply(ctx, svc, mode, opts = {}) {
await exec.run('rm', ['-f', '--', path.join(svc.repo, f)], { as: svc.owner });
await exec.run('rm', ['-f', '--', path.join(svc.repo, f)], { as: svc.owner });
(record.removedUntracked = record.removedUntracked || []).push(f);
(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
// 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
// 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
// see a local change it would overwrite. Never `rm`: node_modules stays on disk. On a rollback
@@ -262,6 +262,10 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -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.
// the path has left the index (`git rm --cached`). skip-worktree does not help.
const headFiles = repo.commits.get(repo.head).files;
const headFiles = repo.commits.get(repo.head).files;
const nextFiles = repo.commits.get(sha).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)) {
for (const f of Object.keys(headFiles)) {
if (f in nextFiles || repo.indexRemoved.has(f)) continue;
if (f in nextFiles || repo.indexRemoved.has(f)) continue;
if (host.read(path.join(repo.path, f)) === headFiles[f]) continue;
if (host.read(path.join(repo.path, f)) === headFiles[f]) continue;
@@ -208,6 +208,99 @@ runTests([
@@ -208,6 +208,99 @@ runTests([
}),
}),
// ── static-build (Sites) ──
// ── 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 () => {
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());
const host = withEvents(sitesHost());
// The previous deploy's build left the tracked dist/ dirty (today's date): that never blocks a deploy.
// The previous deploy's build left the tracked dist/ dirty (today's date): that never blocks a deploy.