Files
sysconfig/git_ignore_find2merge.js
T
lukandAtomCode a9372a9742 新增 git_ignore_find2merge.js:Node 跨平台移植,与 seafile 版同套修复
- 忠实移植 .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) <[email protected]>
2026-10-07 10:40:17 +08:00

191 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
// Cross-platform (node) port of git_ignore_find2merge.sh / .bat
//
// Search [ROOTPATH], merge [GLOBALPATH/.gitignore_global] and [ROOTPATH/<dir>/.gitignore.local.txt]
// into [ROOTPATH/<dir>/.gitignore], for every <dir> 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)
})