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)
Changes
@@@ -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]); },@@@ -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) {@@@ -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', stif (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();@@@ -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');
@@ -28,6 +28,8 @@ function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
@@ -28,6 +28,8 @@ function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
rmCached: async (file) => { await run(['rm', '--cached', '-q', '-r', '--', file]); },
rmCached: async (file) => { await run(['rm', '--cached', '-q', '-r', '--', file]); },
fetch: async () => { await run(['fetch', '--quiet', svc.remote, svc.branch]); },
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),
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),
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; },
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]); },
ffMerge: async (sha) => { await run(['merge', '--ff-only', '--quiet', sha]); },
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* - readiness is polled after the restart; if it fails the checkout goes back to the previous sha,
* - 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.
* dependencies are reinstalled if they differed, and the service is restarted again.
* - every attempt (including refusals) is appended to the release log.
* - 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
* - 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
* 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
* 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,
@@ -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,
// 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.
// 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 = [];
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 = [];
const installs = [];
if (svc.install.workspace) {
if (svc.install.workspace) {
@@ -229,9 +229,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -229,9 +229,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
}
case 'fetch': return ok();
case 'fetch': return ok();
case 'diff': {
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` : '');
return ok(names.length ? `${names.join('\n')}\n` : '');
}
}
case 'log': {
case 'log': {
@@ -265,6 +267,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -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;
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.`);
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.checkout(sha);
repo.indexRemoved.clear();
repo.indexRemoved.clear();
repo.skipWorktree.clear();
repo.skipWorktree.clear();
@@ -233,6 +233,29 @@ runTests([
@@ -233,6 +233,29 @@ runTests([
assert.strictEqual(again.code, 0, again.out);
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 () => {
test('sites: nothing new does nothing', async () => {
const host = sitesHost();
const host = sitesHost();
const r = await host.cli('deploy', 'sites');
const r = await host.cli('deploy', 'sites');