From a9372a974229ff33aa48e50532274277ae3879d5 Mon Sep 17 00:00:00 2001 From: luk Date: Wed, 7 Oct 2026 10:40:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20git=5Fignore=5Ffind2merge.?= =?UTF-8?q?js=EF=BC=9ANode=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=8E=20seafile=20=E7=89=88=E5=90=8C=E5=A5=97?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 忠实移植 .sh 语义:以 .gitignore 存在为候选判定(非 .git,部分仓库隐藏 .git 保隐私)、深度 3 树搜索、跳过 node_modules 等目录、global + .gitignore.local.txt 合并生成 .gitignore - 同套修复:留空 globalPath 回退默认 URL;ask() 处理 stdin EOF 不挂起; 默认 URL 用 fetch(30s 超时 + 重试一次),.sh 对 URL 走的是 curl 分支; 根目录自身参与合并(.sh 的 find -name '[^.]*' 匹配不到根) - 非 y 为 dry-run 并列出候选(.sh 直接静默退出) - 验证:/tmp 夹具树非根目录产物与 .sh 逐字节一致(根目录差异为已知修正项); productX 全树 dry-run 候选 115 条与 .sh 等价 find 管道完全一致 Co-Authored-By: AtomCode (qwen3.8-27b) --- git_ignore_find2merge.js | 190 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 git_ignore_find2merge.js 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) +})