5555 */
5656
5757import { execFileSync } from 'node:child_process' ;
58- import { appendFileSync , existsSync , mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
58+ import { appendFileSync , existsSync , mkdirSync , mkdtempSync , readFileSync , realpathSync , rmSync , writeFileSync } from 'node:fs' ;
5959import { tmpdir } from 'node:os' ;
60- import { dirname , join , resolve } from 'node:path' ;
60+ import { dirname , join , relative , resolve } from 'node:path' ;
6161import { fileURLToPath } from 'node:url' ;
6262
63- import { NOT_DRIVER_MANAGED , PENDING_MARKER , REGEN_ARTIFACTS , entryForPath } from './regen-artifacts.mjs' ;
63+ import {
64+ DRIVER_NAME ,
65+ GIT_SETTINGS ,
66+ NOT_DRIVER_MANAGED ,
67+ PENDING_MARKER ,
68+ REGEN_ARTIFACTS ,
69+ entryForPath ,
70+ } from './regen-artifacts.mjs' ;
6471
6572const REPO_ROOT = resolve ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
6673
@@ -193,6 +200,117 @@ function hookIsExecutable() {
193200 }
194201}
195202
203+ /**
204+ * The driver registered in THIS clone must resolve — here, now (#4868).
205+ *
206+ * Every other check in this self-test builds a throwaway repo and registers its own
207+ * driver into it, so all of them stayed green for weeks while the real
208+ * `merge.os-regen.driver` in the shared `.git/config` pointed at a DELETED worktree
209+ * and every real merge of a `merge=os-regen` path died with MODULE_NOT_FOUND. The
210+ * self-test and the live merge path were simply not the same path. This check reads
211+ * the live one, which is the only reason it can catch that class of failure.
212+ *
213+ * It fails in three distinguishable ways, all of which have happened or are one
214+ * `pnpm install` away:
215+ * - the script the value names does not exist (the dangling-worktree bug);
216+ * - it exists but lives outside this worktree (bound to someone else's worktree —
217+ * green for whoever installed last, broken for everyone else, so this is the
218+ * check that catches the bug *before* the other worktree is removed);
219+ * - the value has drifted from what `setup-git-hooks.mjs` registers.
220+ */
221+ function registeredDriverResolves ( ) {
222+ const { key, value : expected } = GIT_SETTINGS . find ( ( s ) => s . key === `merge.${ DRIVER_NAME } .driver` ) ;
223+
224+ let actual = '' ;
225+ try {
226+ actual = execFileSync ( 'git' , [ 'config' , '--get' , key ] , {
227+ cwd : REPO_ROOT ,
228+ encoding : 'utf8' ,
229+ stdio : [ 'ignore' , 'pipe' , 'ignore' ] ,
230+ } ) . trim ( ) ;
231+ } catch {
232+ actual = '' ; // unset — `git config --get` exits 1
233+ }
234+
235+ if ( ! actual ) {
236+ // A supported state, not a failure: git falls back to a text merge, which is
237+ // exactly the pre-#4675 behaviour. `pnpm install` registers it.
238+ console . log ( `✓ ${ key } is unregistered — merges text-merge as they did before #4675` ) ;
239+ return true ;
240+ }
241+
242+ const expansion = expandDriverScript ( actual ) ;
243+ if ( expansion . skip ) {
244+ console . log ( `✓ ${ key } not checked for resolution (${ expansion . skip } )` ) ;
245+ return true ;
246+ }
247+ if ( expansion . error ) return fail ( `could not expand ${ key } ("${ actual } "): ${ expansion . error } ` ) ;
248+
249+ const script = expansion . path ;
250+ if ( ! existsSync ( script ) ) {
251+ return fail ( `${ key } names a script that does not exist:\n`
252+ + ` ${ script } \n`
253+ + ` Registered value: ${ actual } \n`
254+ + ' Every merge touching a merge=os-regen path in this clone dies with MODULE_NOT_FOUND,\n'
255+ + ' and git leaves the path CONFLICTED with ours in it and no conflict markers.\n'
256+ + ' Fix: pnpm install (re-registers the driver for this worktree)' ) ;
257+ }
258+
259+ const root = realpath ( REPO_ROOT ) ;
260+ if ( relative ( root , realpath ( script ) ) . startsWith ( '..' ) ) {
261+ return fail ( `${ key } points OUTSIDE this worktree:\n`
262+ + ` ${ script } \n`
263+ + ` Linked worktrees share one .git/config, so this is bound to another worktree and\n`
264+ + ' breaks for everyone the moment that one is removed.\n'
265+ + ' Fix: pnpm install (re-registers the driver for this worktree)' ) ;
266+ }
267+
268+ if ( actual !== expected ) {
269+ return fail ( `${ key } has drifted from what setup-git-hooks.mjs registers.\n`
270+ + ` registered: ${ actual } \n`
271+ + ` expected: ${ expected } \n`
272+ + ' Fix: pnpm install' ) ;
273+ }
274+
275+ console . log ( `✓ merge.${ DRIVER_NAME } .driver resolves in THIS worktree (${ relative ( root , realpath ( script ) ) } )` ) ;
276+ return true ;
277+ }
278+
279+ /**
280+ * Expand the driver value's script path the way git will: git hands a merge driver
281+ * command to a shell, so `$(git rev-parse --show-toplevel)` is only meaningful once
282+ * a shell has run it, from inside the worktree being merged.
283+ */
284+ function expandDriverScript ( value ) {
285+ // Drop the trailing %O %A %B %P placeholders; what remains is `node <script>`.
286+ const command = value . replace ( / ( \s + % [ A - Z a - z ] ) + \s * $ / , '' ) ;
287+ const expr = / ^ \s * n o d e \s + ( \S .* ) $ / . exec ( command ) ?. [ 1 ] ;
288+ if ( ! expr ) return { skip : `not a \`node <script>\` command: "${ value } "` } ;
289+ try {
290+ return {
291+ path : execFileSync ( 'sh' , [ '-c' , `printf '%s' ${ expr } ` ] , {
292+ cwd : REPO_ROOT ,
293+ encoding : 'utf8' ,
294+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
295+ } ) . trim ( ) ,
296+ } ;
297+ } catch ( err ) {
298+ // No POSIX shell (some Windows setups). Git could not run the driver either,
299+ // so there is nothing this check could assert that would still be true.
300+ if ( err ?. code === 'ENOENT' ) return { skip : 'no POSIX shell available to expand it' } ;
301+ return { error : err ?. stderr ?. toString ( ) . trim ( ) || err ?. message || String ( err ) } ;
302+ }
303+ }
304+
305+ /** Best-effort realpath: symlinked checkouts otherwise read as "outside the worktree". */
306+ function realpath ( p ) {
307+ try {
308+ return realpathSync ( p ) ;
309+ } catch {
310+ return p ;
311+ }
312+ }
313+
196314/**
197315 * Prove the driver end to end against real git: a conflicting change on both
198316 * sides of a mapped path must come out resolved, marker-free, and recorded.
@@ -207,7 +325,12 @@ function endToEnd() {
207325 git ( 'config' , 'user.email' , 'selftest@objectstack.ai' ) ;
208326 git ( 'config' , 'user.name' , 'self-test' ) ;
209327 git ( 'config' , 'merge.os-regen.name' , 'regenerate instead of text-merging' ) ;
210- git ( 'config' , 'merge.os-regen.driver' , `node ${ join ( REPO_ROOT , 'scripts/git-merge-regen.mjs' ) } %O %A %B %P` ) ;
328+ // Absolute on purpose, and NOT the value we register in a real clone: this temp
329+ // repo is not the ObjectStack worktree, so the registered
330+ // `$(git rev-parse --show-toplevel)` would resolve to `dir` — which has no
331+ // scripts/. Here we want the driver under test, i.e. this clone's copy.
332+ // Checking the value real clones get is `registeredDriverResolves()`'s job (#4868).
333+ git ( 'config' , 'merge.os-regen.driver' , `node "${ join ( REPO_ROOT , 'scripts/git-merge-regen.mjs' ) } " %O %A %B %P` ) ;
211334
212335 const target = REGEN_ARTIFACTS [ 0 ] . path ;
213336 mkdirSync ( join ( dir , dirname ( target ) ) , { recursive : true } ) ;
@@ -245,7 +368,13 @@ function endToEnd() {
245368
246369if ( process . argv . includes ( '--self-test' ) ) {
247370 console . log ( 'git-merge-regen --self-test\n' ) ;
248- const results = [ reconcileAttributes ( ) , reconcileScripts ( ) , hookIsExecutable ( ) , endToEnd ( ) ] ;
371+ const results = [
372+ reconcileAttributes ( ) ,
373+ reconcileScripts ( ) ,
374+ hookIsExecutable ( ) ,
375+ registeredDriverResolves ( ) ,
376+ endToEnd ( ) ,
377+ ] ;
249378 console . log (
250379 results . every ( Boolean )
251380 ? `\n✓ merge driver wiring is consistent (${ NOT_DRIVER_MANAGED . length } path(s) deliberately excluded).`
0 commit comments