openvibers/openvibe.host Commits
Drop replaced tracked paths from the index before the deploy merge (#9)
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.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('; ')}`);@@@ -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),@@@ -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,@@@ -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);@@@ -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', stconst 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();@@@ -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;
@@ -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.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(' && ')}`);
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
@@ -20,6 +20,12 @@ function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
@@ -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,
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. */
/** 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)),
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]); },
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),
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),
@@ -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: [],
+ untrackedDeleted: [],
build: [],
build: [],
preflight: preflight.describe(svc),
preflight: preflight.describe(svc),
restartNeeded,
restartNeeded,
@@ -20,8 +20,11 @@
@@ -20,8 +20,11 @@
* 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.
* - 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); 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
* - 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
* 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.
* 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,
@@ -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 changed = from === target ? [] : await g.changedFiles(from, target);
const commits = from === target ? [] : mode === 'deploy' ? await g.log(from, target) : await g.log(target, from);
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);
const pkgDirs = await deps.expandPackages(exec, svc);
for (const f of changed) {
for (const f of changed) {
if (path.basename(f) !== 'package.json') continue;
if (path.basename(f) !== 'package.json') continue;
@@ -182,7 +205,8 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
@@ -182,7 +205,8 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
from,
from,
to: target,
to: target,
upToDate: from === target,
upToDate: from === target,
- dirty,
+ dirty: dirtyFinal,
+ untrackedDeleted,
generatedDirty,
generatedDirty,
commits,
commits,
changedFiles: changed.length,
changedFiles: changed.length,
@@ -446,6 +470,16 @@ async function apply(ctx, svc, mode, opts = {}) {
@@ -446,6 +470,16 @@ 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);
}
}
+ // 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 (!plan.upToDate) {
if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);
if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);
@@ -123,7 +123,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -123,7 +123,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
// ── git ──
// ── git ──
function newSha(seed) { seq += 1; return crypto.createHash('sha1').update(`${seq}:${seed}`).digest('hex'); }
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 } = {}) => {
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);
repos.set(repoPath, repo);
ensureDir(repoPath, owner);
ensureDir(repoPath, owner);
files.get(path.resolve(repoPath)).owner = owner;
files.get(path.resolve(repoPath)).owner = owner;
@@ -147,7 +147,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -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. */
/** `git worktree add --detach <dir> <sha>`: a checkout sharing the clone's commits. */
function addWorktree(parent, dir, sha, owner) {
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) => {
wt.checkout = (s2) => {
const old = wt.head ? wt.commits.get(wt.head).files : {};
const old = wt.head ? wt.commits.get(wt.head).files : {};
const next = wt.commits.get(s2).files;
const next = wt.commits.get(s2).files;
@@ -210,9 +210,23 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -210,9 +210,23 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
}
case 'status': {
case 'status': {
const head = repo.commits.get(repo.head).files;
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` : '');
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 'fetch': return ok();
case 'diff': {
case 'diff': {
const a = repo.commits.get(resolveRef(repo, rest[1])).files;
const a = repo.commits.get(resolveRef(repo, rest[1])).files;
@@ -232,10 +246,28 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -232,10 +246,28 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const c = repo.commits.get(resolveRef(repo, ref));
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`);
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': {
case 'merge': {
const sha = resolveRef(repo, rest[rest.length - 1]);
const sha = resolveRef(repo, rest[rest.length - 1]);
if (!isAncestor(repo, repo.head, sha)) return fail('fatal: Not possible to fast-forward, aborting.');
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.checkout(sha);
+ repo.indexRemoved.clear();
+ repo.skipWorktree.clear();
return ok();
return ok();
}
}
case 'reset': repo.checkout(resolveRef(repo, rest[rest.length - 1])); return ok();
case 'reset': repo.checkout(resolveRef(repo, rest[rest.length - 1])); return ok();
@@ -22,6 +22,20 @@ function withEvents(host) {
@@ -22,6 +22,20 @@ function withEvents(host) {
return 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([
runTests([
// ── multi-app (Tools) ──
// ── 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 () => {
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([
@@ -80,6 +94,54 @@ runTests([
assert.deepStrictEqual((await lastRecord(host, 'tools')).removedUntracked, ['apps/maps/package-lock.json']);
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 () => {
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 host = toolsHost();
const from = host.repo.head;
const from = host.repo.head;