Last active
February 21, 2025 07:59
-
-
Save melbourne2991/78bb9ae76c7e03cc34ef8cd09abd3696 to your computer and use it in GitHub Desktop.
Code Grabber
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
/** | |
* This script: | |
* - Accepts a file or directory path as its first argument. | |
* - Recursively collects files with code-related extensions. | |
* - Ignores specified directories and files, and also obeys .gitignore rules | |
* on a per-branch basis: if a .gitignore file is encountered in a folder, | |
* its rules apply for that folder and its descendants. | |
* - Concatenates the contents of all found files into a Markdown-formatted string. | |
* - Copies the result to the clipboard using pbcopy (macOS). | |
* | |
* Note: The .gitignore support here is basic and only supports simple wildcard patterns. | |
*/ | |
const fs = require('fs'); | |
const path = require('path'); | |
const { exec } = require('child_process'); | |
// ------ Global Ignore Lists ------ | |
// Code-related file extensions (adjust as needed) | |
const codeExtensions = new Set([ | |
'.ts', '.tsx', '.js', '.jsx', '.json', '.py' | |
]); | |
// Directories to ignore globally | |
const ignoredDirs = new Set([ | |
'node_modules', | |
'dist', | |
'build', | |
'out', | |
'coverage', | |
'.git', | |
'.svn', | |
'.hg', | |
'.vscode', | |
'.idea', | |
'__pycache__', | |
'.pytest_cache', | |
'.next', | |
'.nuxt', | |
'.expo', | |
'tmp', | |
'temp', | |
'.cache', | |
'logs', | |
'.terraform', | |
'.gradle', | |
'venv', | |
'env', | |
'.tox', | |
'Pods', | |
'DerivedData', | |
'.android', | |
'.expo-shared' | |
]); | |
// Files to ignore globally | |
const ignoredFiles = new Set([ | |
'package-lock.json', | |
'pnpm-lock.yaml', | |
'yarn.lock', | |
'composer.lock', | |
'Pipfile.lock', | |
'Gemfile.lock', | |
'Cargo.lock', | |
'.env', | |
'.env.local', | |
'.env.production', | |
'.DS_Store', | |
'Thumbs.db', | |
'desktop.ini', | |
'npm-debug.log', | |
'yarn-error.log', | |
'pnpm-debug.log', | |
'error.log', | |
'debug.log', | |
'coverage-final.json' | |
]); | |
// ------ Basic .gitignore Parser ------ | |
/** | |
* Parses a .gitignore file content into an array of RegExp objects. | |
* This simplistic parser: | |
* - Trims each line and ignores empty lines or comments. | |
* - Supports simple wildcards (*) but not negation (!) rules. | |
* - If a pattern starts with a '/', it is anchored to the beginning. | |
* | |
* Patterns are intended to match a file path relative to the .gitignore file’s folder. | |
*/ | |
function parseGitignore(content) { | |
return content | |
.split('\n') | |
.map(line => line.trim()) | |
.filter(line => line && !line.startsWith('#')) | |
.filter(line => !line.startsWith('!')) // skip negation rules for simplicity | |
.map(pattern => { | |
let anchored = false; | |
if (pattern.startsWith('/')) { | |
anchored = true; | |
pattern = pattern.substring(1); | |
} | |
// Escape regex special characters (except for *) | |
let regexStr = pattern.replace(/([.+^${}()|[\]\\])/g, '\\$1'); | |
// Replace '*' with '.*' | |
regexStr = regexStr.replace(/\*/g, '.*'); | |
if (anchored) { | |
regexStr = '^' + regexStr; | |
} | |
return new RegExp(regexStr); | |
}); | |
} | |
/** | |
* Determines whether a given full path should be ignored based on an array | |
* of ignore objects. Each ignore object has a 'base' directory and an array | |
* of regex patterns (from a .gitignore found in that directory). | |
* | |
* For each ignore rule, we compute the relative path from the rule’s base | |
* and test the regex patterns. | |
*/ | |
function isIgnoredByGit(fullPath, ignoreObjs) { | |
for (const ignoreObj of ignoreObjs) { | |
// Compute the relative path from the directory where the .gitignore was found. | |
const relPath = path.relative(ignoreObj.base, fullPath); | |
// On Windows, convert backslashes to forward slashes for matching. | |
const posixPath = relPath.split(path.sep).join('/'); | |
for (const pattern of ignoreObj.patterns) { | |
if (pattern.test(posixPath)) { | |
return true; | |
} | |
} | |
} | |
return false; | |
} | |
// ------ Utility Functions ------ | |
/** Returns true if the file has one of the desired code-related extensions. */ | |
function isCodeFile(filePath) { | |
return codeExtensions.has(path.extname(filePath)); | |
} | |
/** | |
* Recursively collects files starting at targetPath. | |
* | |
* @param {string} targetPath - The file or directory path to process. | |
* @param {Array} ignoreObjs - Array of objects { base, patterns } representing | |
* .gitignore rules in effect for this branch. | |
* @returns {Array} Array of objects { path, content }. | |
*/ | |
function readFilesRecursively(targetPath, ignoreObjs = []) { | |
let filesData = []; | |
let stat; | |
try { | |
stat = fs.statSync(targetPath); | |
} catch (err) { | |
console.error(`Error reading path: ${targetPath}\n`, err); | |
return filesData; | |
} | |
// Before processing, check if the current targetPath is ignored by any .gitignore rule. | |
if (isIgnoredByGit(targetPath, ignoreObjs)) { | |
return filesData; | |
} | |
if (stat.isFile()) { | |
// Check global ignored files. | |
if (ignoredFiles.has(path.basename(targetPath))) { | |
return filesData; | |
} | |
if (isCodeFile(targetPath)) { | |
filesData.push({ | |
path: targetPath, | |
content: fs.readFileSync(targetPath, 'utf8') | |
}); | |
} | |
} else if (stat.isDirectory()) { | |
// Start with the existing ignore objects. | |
let newIgnoreObjs = ignoreObjs.slice(); | |
// If this directory contains a .gitignore, parse it and add its rules. | |
const gitignorePath = path.join(targetPath, '.gitignore'); | |
if (fs.existsSync(gitignorePath)) { | |
try { | |
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8'); | |
const patterns = parseGitignore(gitignoreContent); | |
newIgnoreObjs.push({ base: targetPath, patterns }); | |
} catch (err) { | |
console.error(`Error reading .gitignore at ${gitignorePath}\n`, err); | |
} | |
} | |
// Read directory items. | |
let items; | |
try { | |
items = fs.readdirSync(targetPath); | |
} catch (err) { | |
console.error(`Error reading directory: ${targetPath}\n`, err); | |
return filesData; | |
} | |
for (const item of items) { | |
// Apply global directory ignores (by name). | |
if (ignoredDirs.has(item)) continue; | |
const fullPath = path.join(targetPath, item); | |
filesData = filesData.concat(readFilesRecursively(fullPath, newIgnoreObjs)); | |
} | |
} | |
return filesData; | |
} | |
/** Formats the collected files into Markdown with code blocks. */ | |
function formatToMarkdown(filesData) { | |
let markdown = ''; | |
filesData.forEach(file => { | |
markdown += `### ${file.path}\n\n`; | |
markdown += "```\n"; | |
markdown += `${file.content}\n`; | |
markdown += "```\n\n"; | |
}); | |
return markdown; | |
} | |
/** Copies the given text to the clipboard using pbcopy (macOS). */ | |
function copyToClipboard(text) { | |
const child = exec('pbcopy'); | |
child.stdin.write(text); | |
child.stdin.end(); | |
} | |
// ------ Main Execution ------ | |
const targetArg = process.argv[2]; | |
if (!targetArg) { | |
console.error('Usage: concat-code.js <path>'); | |
process.exit(1); | |
} | |
const absolutePath = path.resolve(targetArg); | |
const filesData = readFilesRecursively(absolutePath); | |
const markdownOutput = formatToMarkdown(filesData); | |
// Copy the final Markdown string to the clipboard. | |
copyToClipboard(markdownOutput); | |
console.log('Content copied to clipboard.'); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment