diff --git a/git_ignore_find2merge.js b/git_ignore_find2merge.js
new file mode 100644
index 0000000..21ad66d
--- /dev/null
+++ b/git_ignore_find2merge.js
@@ -0,0 +1,190 @@
+#!/usr/bin/env node
+// Cross-platform (node) port of git_ignore_find2merge.sh / .bat
+//
+// Search [ROOTPATH], merge [GLOBALPATH/.gitignore_global] and [ROOTPATH/
/.gitignore.local.txt]
+// into [ROOTPATH//.gitignore], for every that has a .gitignore.
+// (Judged from .gitignore, not from .git — some git repos keep privacy and hide .git.)
+//
+// usage:
+// node git_ignore_find2merge.js [rootPath] [globalPath] [y|other]
+// rootPath start of tree search, must be a directory (default: prompt, blank = current dir)
+// globalPath path to .gitignore_global, or its parent dir, or an http(s) URL
+// (default: https://git.tic.cc/opx/sysconfig/raw/branch/main/nixhome/.gitignore_global)
+// yesNo 'y' to write, anything else = dry-run (list candidates without writing)
+//
+// node git_ignore_find2merge.js # interactive, dry-run
+// node git_ignore_find2merge.js ~/Shared y
+// node git_ignore_find2merge.js /srv/projects /srv/proj/nixhome y
+//
+// Same fixes as seafile_ignore_find2merge.js:
+// - blank globalPath falls back to the default URL, as in the .sh
+// - ask() treats stdin EOF as blank, so non-interactive pipes don't hang
+// - the default global is a URL: fetched with fetch() at 30s timeout + one retry
+// (the .sh's `cat $GLOBALPATH` branch never ran for URLs — it went to the curl branch)
+// - the root dir itself is processed too (the .sh's find -name '[^.]*' never matched the root)
+// - non-'y' is a dry-run that still lists the candidate dirs (the .sh just exited silently)
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const readline = require('readline')
+
+const DEFAULT_GLOBAL_URL = 'https://git.tic.cc/opx/sysconfig/raw/branch/main/nixhome/.gitignore_global'
+const GLOBAL_FILE_NAME = '.gitignore_global'
+// same skip list as: grep -E -v 'node_modules|uni_modules|\.deploy_git|\.git|.svn|\.vscode|\.wrangler|unpackage|_webroot|_logstore|_datasotre|_archive|_filestore|_ssl'
+const SKIP_DIR_PATTERN = /node_modules|uni_modules|\.deploy_git|\.git|\.svn|\.vscode|\.wrangler|unpackage|_webroot|_logstore|_datasotre|_archive|_filestore|_ssl/
+const MAX_DEPTH = 3 // relative to ROOTPATH, matching find -mindepth 0 -maxdepth 3
+
+function resolveHome (p) {
+ if (p === '~') return os.homedir()
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2))
+ return p
+}
+
+async function ask (question) {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
+ const answer = await new Promise(resolve => {
+ let settled = false
+ const finish = value => { if (!settled) { settled = true; resolve(value) } }
+ rl.question(question, a => finish(a))
+ rl.on('close', () => finish('')) // stdin ended without input (EOF): treat as blank, like the .sh's `read`
+ })
+ rl.close()
+ return answer.trim()
+}
+
+// find . -mindepth 0 -maxdepth 3 -type d -name '[^.]*' (relative paths, root itself excluded)
+function collectCandidateDirs (rootPath) {
+ const candidates = []
+ function walk (dir, depth) {
+ if (depth > MAX_DEPTH) return
+ let entries
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true })
+ } catch {
+ return
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ if (entry.name.startsWith('.')) continue // -name '[^.]*' excludes hidden dirs
+ const absDir = path.join(dir, entry.name)
+ const relDir = path.relative(rootPath, absDir)
+ if (relDir.split(path.sep).length > MAX_DEPTH) continue
+ candidates.push(relDir)
+ walk(absDir, depth + 1)
+ }
+ }
+ walk(rootPath, 1)
+ return candidates
+}
+
+async function loadGlobalContent (globalPathInput) {
+ const globalPath = resolveHome(globalPathInput)
+ if (/^https?:\/\//i.test(globalPath)) {
+ console.log('√√√ GLOBALPATH (url) = [[' + globalPath + ']]')
+ // git.tic.cc's TCP connect can exceed node's default 10s connect timeout; allow 30s and retry once.
+ let lastError
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ try {
+ const response = await fetch(globalPath, { signal: AbortSignal.timeout(30000) })
+ if (!response.ok) throw new Error('HTTP ' + response.status)
+ return await response.text()
+ } catch (error) {
+ lastError = error
+ console.log('... fetch attempt ' + attempt + ' failed: ' + (error.cause?.code || error.message))
+ }
+ }
+ console.log('××× Fetch failed: ' + (lastError.cause?.code || lastError.message) + ' for [[' + globalPath + ']]. Exit now...')
+ process.exit(1)
+ }
+ let globalFile = globalPath
+ if (fs.existsSync(globalPath) && fs.statSync(globalPath).isDirectory()) {
+ globalFile = path.join(globalPath, GLOBAL_FILE_NAME)
+ }
+ if (!fs.existsSync(globalFile)) {
+ console.log('××× Not found [[' + globalFile + ']]. Exit now...')
+ process.exit(1)
+ }
+ console.log('√√√ GLOBALPATH = [[' + globalFile + ']]')
+ return fs.readFileSync(globalFile, 'utf8')
+}
+
+async function main () {
+ console.log('')
+ console.log('Search in [ROOTPATH], merge [GLOBALPATH/.gitignore_global] and [ROOTPATH/*/.gitignore.local.txt] files to .gitignore')
+ console.log('')
+
+ // --- ROOTPATH ---
+ let rootPath = process.argv[2]
+ const rootIsDir = p => {
+ try { return fs.statSync(p).isDirectory() } catch { return false }
+ }
+ if (!(rootPath && rootIsDir(resolveHome(rootPath)))) {
+ console.log('::*** Enter [root path] or [leave blank] for default [[' + process.cwd() + ']] to start tree search for git repositories')
+ rootPath = await ask('***:: ')
+ if (!rootPath) rootPath = process.cwd()
+ rootPath = path.resolve(resolveHome(rootPath))
+ } else {
+ rootPath = path.resolve(resolveHome(rootPath))
+ }
+ if (!rootIsDir(rootPath)) {
+ console.log('××× [[' + rootPath + ']] not exist! Exit now. ***')
+ process.exit(1)
+ }
+ console.log('√√√ ROOTPATH = [[' + rootPath + ']]')
+ console.log('')
+
+ // --- GLOBALPATH ---
+ let globalPathInput = process.argv[3]
+ if (!globalPathInput) {
+ console.log('::*** Enter [path to .gitignore_global] or [leave blank] for default [[' + DEFAULT_GLOBAL_URL + ']]')
+ globalPathInput = await ask('***:: ')
+ if (!globalPathInput) globalPathInput = DEFAULT_GLOBAL_URL // blank → default URL, as in the .sh
+ }
+ const globalContent = await loadGlobalContent(globalPathInput)
+ console.log('')
+
+ // --- y/N ---
+ let yesNo = process.argv[4]
+ if (!yesNo) {
+ console.log('::*** Enter [y] to start updating, or [anything else] for dry-run')
+ yesNo = await ask('***:: ')
+ }
+ const willWrite = yesNo === 'y'
+
+ console.log('*** Starting from [[' + rootPath + ']] ***')
+ console.log('')
+
+ // --- search + merge ---
+ // A dir is a candidate iff it has a .gitignore (judged from .gitignore, not from .git).
+ const candidateDirs = collectCandidateDirs(rootPath).sort()
+ // The root dir itself is also a candidate: the old .sh's find -name '[^.]*' never matched the root.
+ if (fs.existsSync(path.join(rootPath, '.gitignore'))) candidateDirs.unshift('.')
+
+ let updatedCount = 0
+ for (const relDir of candidateDirs) {
+ const absDir = relDir === '.' ? rootPath : path.join(rootPath, relDir)
+ if (relDir !== '.' && SKIP_DIR_PATTERN.test(relDir)) continue
+ const targetFile = path.join(absDir, '.gitignore')
+ const localFile = path.join(absDir, '.gitignore.local.txt')
+ if (!fs.existsSync(targetFile)) continue
+ console.log('---- updating [[' + relDir + '/.gitignore]] ----')
+ if (willWrite) {
+ let merged = globalContent
+ if (fs.existsSync(localFile)) merged += fs.readFileSync(localFile, 'utf8')
+ fs.writeFileSync(targetFile, merged)
+ updatedCount++
+ }
+ console.log('')
+ }
+ if (willWrite) {
+ console.log('*** done: ' + updatedCount + ' file(s) updated ***')
+ } else {
+ console.log('*** dry-run: no file written (enter y to update) ***')
+ }
+}
+
+main().catch(err => {
+ console.error(err)
+ process.exit(1)
+})