diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..309da3f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalise line endings for all shell-ish files (keeps Bats happy) +*.sh text eol=lf +*.bash text eol=lf +*.bats text eol=lf + +# (Optional) keep CRLF for Windows-native scripts +*.ps1 text eol=crlf + +# Fall-back: let Git auto-detect for everything else +* text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c8900..306eea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,29 +2,41 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] jobs: - test-bash: - runs-on: ubuntu-latest + tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + steps: - - uses: actions/checkout@v3 - - name: Install dependencies - run: sudo apt update && sudo apt install -y jq xclip bats - - name: Run Bash tests + - name: Checkout (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Linux test deps + if: runner.os == 'Linux' + run: | + sudo apt update + sudo apt install -y bats jq xclip + - name: Run Bash test-suite (Bats) + if: runner.os == 'Linux' run: bats tests/bash/rpcp.bats - pester-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Install Pester - shell: pwsh + - name: Install Windows test deps + if: runner.os == 'Windows' run: | - Install-Module Pester -Force -Scope CurrentUser - - name: Run PowerShell tests + choco install bats --version=1.10.0 -y + choco install jq -y + - name: Run PowerShell test-suite (Pester) + if: runner.os == 'Windows' shell: pwsh run: | + Install-Module Pester -Force -Scope CurrentUser Invoke-Pester -Path tests/powershell -CI -Output Detailed diff --git a/.gitmodules b/.gitmodules index 367a773..565e2dc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "tests/test_helper/bats-assert"] path = tests/test_helper/bats-assert url = https://github.com/bats-core/bats-assert +[submodule "tests/bash/test_helper/bats-support"] + path = tests/bash/test_helper/bats-support + url = https://github.com/bats-core/bats-support +[submodule "tests/bash/test_helper/bats-assert"] + path = tests/bash/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert diff --git a/LICENSE.md b/LICENSE.md index 8648ca5..7566204 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2025 dickymoore - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2025 dickymoore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a492d0b..9db1bd0 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,170 @@ -# repocopy (rpcp) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Release](https://img.shields.io/github/v/release/dickymoore/repocopy)](https://github.com/dickymoore/repocopy/releases) -[![CI](https://github.com/dickymoore/repocopy/actions/workflows/ci.yml/badge.svg)](https://github.com/dickymoore/repocopy/actions/workflows/ci.yml) - -### What is it? -A lightweight utility to copy the file contents of a local directory to the clipboard. - -### Is that all it does? -It has the ability to filter out files matched on name or type, or to redact text patterns. - -### But why? -In case you've got sensitive information or huge irrelevant files in the repo - -### No, I mean why copy your repo at all? -Various reasons, but it can be useful for when using using web-based tooling to help with development or debugging, such as when giving an LLM like ChatGPT the full context to a coding issue. - -### Why would I want to do that? -I dunno mate. You might not. Go do something more fun. - ---- - -## 🎯 Use Case - -When working with AI-assisted development ("vibe coding"), you often need to provide the AI with the exact context of your repository or a specific directory. With **repocopy**, you can quickly copy all relevant files (excluding things like `.git`, `node_modules`, large binaries, etc.) into your clipboard in one shot. Then just paste that into your AI tool to give it full awareness of your codebase structure and content. - ---- - -## πŸš€ Features - -- **Config-driven**: Exclude folders, files, set max file size, and define token replacements via `config.json`. -- **One-command operation**: Clone the repo, add the script directory to your `PATH`, then run `rpcp` to copy contents of the current folder. -- **Automatic replacements**: Swap tokens (e.g. `PARENT_COMPANY`, `CLIENT_NAME`) as defined in your config. -- **Verbose mode**: See exactly which files were included or excluded and why. -- **Show summary**: Optionally list which files got copied, either via CLI or config. -- **Cross-platform support**: Run on Windows PowerShell or macOS/Linux Bash. - ---- - -## πŸ“¦ Installation - -1. **Clone the repocopy repository** - - ```bash - git clone https://github.com//repocopy.git - ``` - -2. **Add to your PATH** - - - **Windows (PowerShell)**: - 1. Locate the folder where you cloned `repocopy` (e.g. `C:\tools\repocopy`). - 2. Open System Settings β†’ Environment Variables β†’ User Variables β†’ `Path` β†’ Edit β†’ New. - 3. Paste the full path to your `repocopy` folder and click OK. - 4. Restart your PowerShell session. - - - **macOS/Linux (Bash)**: - 1. Ensure you have [PowerShell Core](https://github.com/PowerShell/PowerShell) or use the Bash script directly. - 2. Add the folder to your `PATH`: - ```bash - export PATH="$PATH:/path/to/repocopy" - ``` - 3. Add that line to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.profile`). - -3. **Optionally create an alias** - - - **PowerShell** (in your PowerShell profile): - ```powershell - Set-Alias rpcp "$(Join-Path 'C:\tools\repocopy' 'rpcp.ps1')" - ``` - - **Bash** (in your shell profile): - ```bash - alias rpcp='repocopy.sh' - ``` - -Now you can run: - -```bash -rpcp -``` - ---- - -## βš™οΈ Configuration (`config.json`) - -Place a `config.json` alongside `rpcp.ps1` or `repocopy.sh`. Example: - -```json -{ - "repoPath": ".", - "maxFileSize": 204800, - "ignoreFolders": [ - ".git", ".github", ".terraform", "node_modules", - "plugin-cache", "terraform-provider*", "logo-drafts", - "build", ".archive" - ], - "ignoreFiles": [ - "manifest.json", "package-lock.json" - ], - "replacements": { - "PARENT_COMPANY": "pca", - "CLIENT_NAME": "ClientName", - "PROJECT_ACRONYM": "wla" - }, - "showCopiedFiles": true -} -``` - -- **repoPath**: Default folder to scan (`.` = current directory). -- **maxFileSize**: Max bytes per file (0 = no limit). -- **ignoreFolders**: Wildcard patterns of folder names to skip. -- **ignoreFiles**: Exact file names to skip. -- **replacements**: Key/value pairs to replace in file contents. -- **showCopiedFiles**: `true` to list included files after copying. - ---- - -## πŸ’» Usage - -### PowerShell (Windows or Core) - -```powershell -# Copy current directory -rpcp - -# Override max size, suppress summary: -rpcp -MaxFileSize 0 -ShowCopiedFiles:$false - -# Scan a different directory: -rpcp -RepoPath 'C:\Projects\MyApp' - -# Verbose debugging: -rpcp -Verbose -``` - -### Bash (macOS/Linux) - -```bash -# Copy current directory -rpcp - -# Override max size, suppress summary: -repocopy.sh --max-file-size 0 --show-copied-files=false - -# Scan a different directory: -repocopy.sh --repo-path /path/to/project - -# Verbose debugging: -repocopy.sh --verbose -``` - ---- - -## 🎯 Vibe Coding - -After running `rpcp`, your clipboard contains all relevant files with context. Paste directly into your AI tool (ChatGPT, Copilot Chat, etc.) to provide the full structure and content, no manual file hunting required. - ---- - -## πŸ§ͺ Testing & Linting - -# Running tests locally - -## Bash (Bats) - -```bash -sudo apt install bats jq xclip # or the equivalent for your OS -bats tests/bash/repocopy.bats -``` - -## PowerShell (Pester) - -``` -Install-Module Pester -Force -Scope CurrentUser # once -Invoke-Pester -Path tests/powershell -Output Detailed -``` - - ---- - -## πŸ“„ License - -MIT License Β· Β© 2025 Your Name +# repocopy (rpcp) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/dickymoore/repocopy)](https://github.com/dickymoore/repocopy/releases) +[![CI](https://github.com/dickymoore/repocopy/actions/workflows/ci.yml/badge.svg)](https://github.com/dickymoore/repocopy/actions/workflows/ci.yml) + +> **TL;DR** – `rpcp` is a one‑shot clipboard copier for *codebases*. +> It walks a folder, excludes junk (`.git`, `node_modules`, binaries, etc.), +> redacts secrets you define, then puts the result on your clipboard +> ready to paste into an AI assistant or chat. + +--- + +## ✨ Why might I need this? + +Sometimes you’re pair‑programming with an LLM (ChatGPT, Claude, Copilotβ€―Chat etc.) +and you need to give it your *entire* repo or a large sub‑directory for context. +Copy‑pasting file‑by‑file gets old fast – **repocopy** does it in a single command +while letting you: + +* redact sensitive tokens (`ACME` β†’ `ClientName`) +* skip whole directories or globs +* honour a max file‑size +* view exactly which files were included + +--- + +## πŸš€ Quick‑start + +### PowerShellΒ 7+ (Windows, macOS, Linux) + +```powershell +# 1. Clone with submodules & jump in +git clone --recurse-submodules https://github.com/dickymoore/repocopy.git +cd repocopy + +# 2. Add rpcp to your PATH (current session) +$env:PATH += ';' + (Get-Location) + +# 3. Copy the *current* folder (default) +rpcp + +# 4. Copy another repo, verbose +rpcp -RepoPath 'C:\src\my-project' -Verbose +``` + +> **Clipboard helper** – uses the built‑in **Set‑Clipboard** cmdlet (no extra installs). + +--- + +### Bash / Zsh (macOS, Linux, WSL) + +```bash +# 1. Clone with submodules & add rpcp to your PATH +git clone --recurse-submodules https://github.com/dickymoore/repocopy.git +export PATH="$PATH:$(pwd)/repocopy" + +# 2. Copy the *current* folder (default) +rpcp + +# 3. Copy another repo, verbosely +rpcp --repo-path ~/src/my-project --verbose +``` + +> **Clipboard helpers** – +> β€’ Linux: requires `xclip` +> β€’ macOS: uses `pbcopy` +> β€’ WSL: uses `clip.exe` + +--- + +## πŸ“¦ Requirements + +* **Bash**Β 4+ **or** **PowerShell**Β 7+ +* `jq` – for the Bash version (auto‑installed if `autoInstallDeps = true`) +* A clipboard tool (`pbcopy`, `xclip`, `clip.exe`, or `pwsh`) + +--- + +## βš™οΈ Configuration (`config.json`) + +```jsonc +{ + // folder to scan – β€œ.” = working directory + "repoPath": ".", + + // ignore folders / files (globs ok) + "ignoreFolders": ["build", ".git", "node_modules"], + "ignoreFiles": ["manifest.json", "*.png"], + + // max bytes per file (0 = unlimited) + "maxFileSize": 204800, + + // string replacements applied to every file + "replacements": { + "ClientName": "ACME" + }, + + // print a summary afterwards? + "showCopiedFiles": true, + + // let rpcp.sh auto‑install jq if missing + "autoInstallDeps": true +} +``` + +Every CLI switch overrides the matching JSON field – handy when you just +need to bump `--max-file-size` for one run. + +--- + +## πŸ’» Usage snippets + +### PowerShell + +```powershell +# basic +rpcp + +# disable size filter +rpcp -MaxFileSize 0 + +# different folder, quiet output +rpcp -RepoPath C:\Code\Foo -Verbose:$false +``` + +### Bash + +```bash +# basic +rpcp + +# disable size filter & summary +rpcp --max-file-size 0 --show-copied-files=false + +# different folder +rpcp --repo-path ~/Code/Foo +``` + +--- + +## πŸ§ͺ Running tests locally + +```bash +# one‑time: fetch the helper submodules +git submodule update --init --recursive + +# Bash tests +sudo apt install bats jq xclip # linux example +bats tests/bash + +# PowerShell tests +pwsh -Command 'Install-Module Pester -Scope CurrentUser -Force' +pwsh -Command 'Invoke-Pester tests/powershell' +``` + +--- + +## πŸ“ Development notes + +* Shell files are expected to use **LF** endings. + A `.gitattributes` is provided so Windows Git converts on checkout. +* CI runs both Pester (PowerShell) and Bats (Bash) on + **ubuntu‑latest** and **windows‑latest** runners. +* Want to contribute? See **CONTRIBUTING.md**. + +--- + +## πŸ“„ License + +MIT Β© 2025 DickyΒ Moore diff --git a/config.json b/config.json index 66d820b..c4c8354 100644 --- a/config.json +++ b/config.json @@ -1,10 +1,9 @@ -{ - "repoPath": ".", - "maxFileSize": 204800, - "ignoreFolders": [".git", ".github", ".terraform", "node_modules","plugin-cache", "terraform-provider*", "logo-drafts","build", ".archive","test_helper"], - "ignoreFiles": ["manifest.json", "package-lock.json","*.png","*.jpg","*.jpeg","*.gif","*.svg","*.zip","*.tar.gz","*.tgz","*.tfstate", "*.tfstate.backup", "*.tfvars", "*.tfvars.json", "*.tfplan", "*.tfplan.json","*.avif","*.webp"], - "replacements": { "PARENT_COMPANY":"pca","Bob":"Redacted_name","PROJECT_ACRONYM":"wla" }, - "replacements": { "PARENT_COMPANY":"pca","CLIENT_NAME":"ClientName","PROJECT_ACRONYM":"wla" }, - "showCopiedFiles": true, - "autoInstallDeps": true +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [".git", ".github", ".terraform", "node_modules","plugin-cache", "terraform-provider*", "logo-drafts","build", ".archive","test_helper"], + "ignoreFiles": ["manifest.json", "package-lock.json","*.png","*.jpg","*.jpeg","*.gif","*.svg","*.zip","*.tar.gz","*.tgz","*.tfstate", "*.tfstate.backup", "*.tfvars", "*.tfvars.json", "*.tfplan", "*.tfplan.json","*.avif","*.webp"], + "replacements": { "PARENT_COMPANY":"pca","Bob":"Redacted_name","PROJECT_ACRONYM":"wla" }, + "showCopiedFiles": true, + "autoInstallDeps": true } \ No newline at end of file diff --git a/pre-commit-config.yaml b/pre-commit-config.yaml new file mode 100644 index 0000000..56294a0 --- /dev/null +++ b/pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.2.5 + hooks: + - id: prettier + name: prettier‑sh + additional_dependencies: ["@prettier/plugin-sh"] + types: [shell] + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.9.0.4 + hooks: + - id: shellcheck + args: ["--severity", "warning"] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: mixed-line-ending + args: [--fix=lf] diff --git a/rpcp.ps1 b/rpcp.ps1 index 5385fbd..8ac4b26 100644 --- a/rpcp.ps1 +++ b/rpcp.ps1 @@ -1,272 +1,272 @@ -<# -.SYNOPSIS - Copy filtered parts of a repo to the clipboard according to config.json. - -.DESCRIPTION - Loads configuration from a JSON file (by default in the same folder as the script), - enumerates all files under the target folder, excludes or includes based on: - β€’ ignoreFolders (wildcards OK) - β€’ ignoreFiles (exact names) - β€’ maxFileSize (bytes, 0 = no limit) - Always applies token replacements from the config. Copies the final concatenated - content to the clipboard. Command-line parameters can override any config value, - and a JSON setting β€œshowCopiedFiles” will automatically list the files copied - unless overridden on the CLI. - -.PARAMETER RepoPath - Path of the repository to scan. Defaults to the current directory. - Must be an existing folder. - -.PARAMETER ConfigFile - Path to the JSON configuration file. Defaults to "config.json" in the script’s folder. - Must be an existing file. - -.PARAMETER MaxFileSize - Maximum file size in bytes to include; set to 0 to disable size filtering. - Overrides the value in the config file if specified. - -.PARAMETER IgnoreFolders - Array of folder name patterns to ignore (supports wildcards). - Overrides the config file’s ignoreFolders when specified. - -.PARAMETER IgnoreFiles - Array of file names to ignore (exact matches). - Overrides the config file’s ignoreFiles when specified. - -.PARAMETER Replacements - Hashtable of token β†’ replacement pairs. - Overrides the config file’s replacements when specified. - -.PARAMETER ShowCopiedFiles - If specified (or if β€œshowCopiedFiles” is true in config.json), after copying - to clipboard the script will list every file that was included. - -.PARAMETER Verbose - Standard PowerShell -Verbose switch. When used, logs every file’s include/exclude - decision and the reason. - -.EXAMPLE - .\rpcp.ps1 - # Uses config.json, applies its settings. - -.EXAMPLE - .\rpcp.ps1 -MaxFileSize 0 -ShowCopiedFiles:$false - # Disables size filtering; suppresses the copied-file list. - -.EXAMPLE - .\rpcp.ps1 -RepoPath 'C:\MyProject' -ConfigFile '.\myconfig.json' -#> -[CmdletBinding()] -Param( - [Parameter()] - [ValidateScript({ Test-Path $_ -PathType Container })] - [string] $RepoPath = '.', - - [Parameter()] - [ValidateScript({ Test-Path $_ -PathType Leaf })] - [string] $ConfigFile, - - [Parameter()] - [ValidateRange(0, [long]::MaxValue)] - [long] $MaxFileSize, - - [Parameter()] - [AllowNull()] - [string[]] $IgnoreFolders, - - [Parameter()] - [AllowNull()] - [string[]] $IgnoreFiles, - - [Parameter()] - [AllowNull()] - - [hashtable]$Replacements, - - [Parameter()] - [switch] $ShowCopiedFiles -) - -function Get-Config { - [CmdletBinding()] - Param( - [Parameter(Mandatory)] - [ValidateScript({ Test-Path $_ -PathType Leaf })] - [string] $ConfigFilePath - ) - try { - $text = Get-Content -Raw -Path $ConfigFilePath -ErrorAction Stop - return $text | ConvertFrom-Json -ErrorAction Stop - } catch { - Write-Error "Failed to load config from '$ConfigFilePath': $_" -ErrorAction Stop - } -} - -function Get-FilesToInclude { - [CmdletBinding()] - Param( - [Parameter()] - [ValidateScript({ Test-Path $_ -PathType Container })] - [string] $RepoRoot, - - # ↓↓↓ CHANGE #1 – remove Mandatory, give default @() - [Parameter()] - [string[]] $IgnoreFolders = @(), - - # ↓↓↓ CHANGE #2 – remove Mandatory, give default @() - [Parameter()] - [string[]] $IgnoreFiles = @(), - - [Parameter()] - - [ValidateRange(0, [long]::MaxValue)] - [long] $MaxFileSize - ) - $all = Get-ChildItem -Path (Join-Path $RepoRoot '*') -Recurse -File -ErrorAction Stop - $result = [System.Collections.Generic.List[System.IO.FileInfo]]::new() - - foreach ($f in $all) { - $reason = $null - # Folder pattern check - $dirs = $f.DirectoryName.Split([IO.Path]::DirectorySeparatorChar) - foreach ($pat in $IgnoreFolders) { - $sepRegex = [Regex]::Escape([IO.Path]::DirectorySeparatorChar) - $segments = $f.DirectoryName -split $sepRegex # safe on Win & *nix - - foreach ($pat in $IgnoreFolders) { - if ($segments -like $pat) { - $reason = "matched ignore-folder '$pat'" - break - } - - } - } - # File name check - if (-not $reason) { - foreach ($pattern in $IgnoreFiles) { - if ($f.Name -like $pattern) { - $reason = "filename '$($f.Name)' matches ignore pattern '$pattern'" - break - } - } - } - - # Size check - if (-not $reason -and $MaxFileSize -gt 0 -and $f.Length -gt $MaxFileSize) { - $reason = "exceeds maxFileSize ($MaxFileSize bytes)" - } - - if ($reason) { - Write-Verbose "EXCLUDING: $($f.FullName) β†’ $reason" - } else { - Write-Verbose "INCLUDING: $($f.FullName)" - $result.Add($f) - } - } - - return $result -} - -function Build-ClipboardContent { - [CmdletBinding()] - Param( - [Parameter(Mandatory)] - [System.Collections.Generic.List[System.IO.FileInfo]] $Files, - - [Parameter()] - [hashtable] $Replacements - ) - $sb = [System.Text.StringBuilder]::new() - foreach ($f in $Files) { - $sb.AppendLine("File: $($f.FullName)") | Out-Null - $sb.AppendLine(('-' * 60)) | Out-Null - $text = Get-Content -Raw -LiteralPath $f.FullName -ErrorAction Stop - - foreach ($token in $Replacements.Keys) { - $val = $Replacements[$token] - $text = $text -replace ([Regex]::Escape($token)), $val - } - - $sb.AppendLine($text) | Out-Null - $sb.AppendLine() | Out-Null - } - return $sb.ToString() -} - -function Copy-ToClipboard { - [CmdletBinding()] - Param( - [Parameter(Mandatory)] - [string] $Content, - - [Parameter()] - [switch] $ShowList, - - [Parameter()] - [System.Collections.Generic.List[System.IO.FileInfo]] $Files - ) - # Pass the entire string as a single Value, rather than via the pipeline - Set-Clipboard -Value $Content - - Write-Host "βœ… Copied $($Files.Count) file(s) to clipboard." - if ($ShowList) { - Write-Host "`nFiles included:" - foreach ($f in $Files) { - Write-Host " - $($f.FullName)" - } - } -} - -#–– Begin script logic –– - -if (-not $PSBoundParameters.ContainsKey('ConfigFile')) { - # Are we running from a script file? - if ($MyInvocation.MyCommand.Path) { - # Use the folder the script lives in - $scriptFolder = Split-Path -Parent $MyInvocation.MyCommand.Path - } - else { - # Interactive / dot-sourced: use the cwd - $scriptFolder = (Get-Location).ProviderPath - } - - $ConfigFile = Join-Path $scriptFolder 'config.json' -} - - -# Load and merge config -$config = Get-Config -ConfigFilePath $ConfigFile - -# Merge CLI parameters over config values -$rp = if ($PSBoundParameters.ContainsKey('RepoPath')) { $RepoPath } else { $config.repoPath } -$mf = if ($PSBoundParameters.ContainsKey('MaxFileSize')) { $MaxFileSize } else { [long]$config.maxFileSize } -$if = if ($PSBoundParameters.ContainsKey('IgnoreFolders') -and $IgnoreFolders) { $IgnoreFolders } else { @($config.ignoreFolders) } -$ifl = if ($PSBoundParameters.ContainsKey('IgnoreFiles') -and $IgnoreFiles) { $IgnoreFiles } else { @($config.ignoreFiles) } -$rep = if ($PSBoundParameters.ContainsKey('Replacements')) { $Replacements } else { - $h = @{}; foreach ($p in $config.replacements.PSObject.Properties) { $h[$p.Name] = $p.Value }; $h -} -$scf = if ($PSBoundParameters.ContainsKey('ShowCopiedFiles')) { - $ShowCopiedFiles.IsPresent - } else { - [bool]$config.showCopiedFiles - } - -if ($null -eq $if) { $if = @() } -if ($null -eq $ifl) { $ifl = @() } - -# Gather, filter, and log -$filesToCopy = Get-FilesToInclude ` - -RepoRoot $rp ` - -IgnoreFolders $if ` - -IgnoreFiles $ifl ` - -MaxFileSize $mf - - -if ($filesToCopy.Count -eq 0) { - Write-Warning 'No files passed the filters; nothing to copy.' - return -} - -# Build content & copy -$content = Build-ClipboardContent -Files $filesToCopy -Replacements $rep -Copy-ToClipboard -Content $content -ShowList:$scf -Files $filesToCopy +<# +.SYNOPSIS + Copy filtered parts of a repo to the clipboard according to config.json. + +.DESCRIPTION + Loads configuration from a JSON file (by default in the same folder as the script), + enumerates all files under the target folder, excludes or includes based on: + β€’ ignoreFolders (wildcards OK) + β€’ ignoreFiles (exact names) + β€’ maxFileSize (bytes, 0 = no limit) + Always applies token replacements from the config. Copies the final concatenated + content to the clipboard. Command-line parameters can override any config value, + and a JSON setting β€œshowCopiedFiles” will automatically list the files copied + unless overridden on the CLI. + +.PARAMETER RepoPath + Path of the repository to scan. Defaults to the current directory. + Must be an existing folder. + +.PARAMETER ConfigFile + Path to the JSON configuration file. Defaults to "config.json" in the script’s folder. + Must be an existing file. + +.PARAMETER MaxFileSize + Maximum file size in bytes to include; set to 0 to disable size filtering. + Overrides the value in the config file if specified. + +.PARAMETER IgnoreFolders + Array of folder name patterns to ignore (supports wildcards). + Overrides the config file’s ignoreFolders when specified. + +.PARAMETER IgnoreFiles + Array of file names to ignore (exact matches). + Overrides the config file’s ignoreFiles when specified. + +.PARAMETER Replacements + Hashtable of token β†’ replacement pairs. + Overrides the config file’s replacements when specified. + +.PARAMETER ShowCopiedFiles + If specified (or if β€œshowCopiedFiles” is true in config.json), after copying + to clipboard the script will list every file that was included. + +.PARAMETER Verbose + Standard PowerShell -Verbose switch. When used, logs every file’s include/exclude + decision and the reason. + +.EXAMPLE + .\rpcp.ps1 + # Uses config.json, applies its settings. + +.EXAMPLE + .\rpcp.ps1 -MaxFileSize 0 -ShowCopiedFiles:$false + # Disables size filtering; suppresses the copied-file list. + +.EXAMPLE + .\rpcp.ps1 -RepoPath 'C:\MyProject' -ConfigFile '.\myconfig.json' +#> +[CmdletBinding()] +Param( + [Parameter()] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string] $RepoPath = '.', + + [Parameter()] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string] $ConfigFile, + + [Parameter()] + [ValidateRange(0, [long]::MaxValue)] + [long] $MaxFileSize, + + [Parameter()] + [AllowNull()] + [string[]] $IgnoreFolders, + + [Parameter()] + [AllowNull()] + [string[]] $IgnoreFiles, + + [Parameter()] + [AllowNull()] + + [hashtable]$Replacements, + + [Parameter()] + [switch] $ShowCopiedFiles +) + +function Get-Config { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string] $ConfigFilePath + ) + try { + $text = Get-Content -Raw -Path $ConfigFilePath -ErrorAction Stop + return $text | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Error "Failed to load config from '$ConfigFilePath': $_" -ErrorAction Stop + } +} + +function Get-FilesToInclude { + [CmdletBinding()] + Param( + [Parameter()] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string] $RepoRoot, + + # ↓↓↓ CHANGE #1 – remove Mandatory, give default @() + [Parameter()] + [string[]] $IgnoreFolders = @(), + + # ↓↓↓ CHANGE #2 – remove Mandatory, give default @() + [Parameter()] + [string[]] $IgnoreFiles = @(), + + [Parameter()] + + [ValidateRange(0, [long]::MaxValue)] + [long] $MaxFileSize + ) + $all = Get-ChildItem -Path (Join-Path $RepoRoot '*') -Recurse -File -ErrorAction Stop + $result = [System.Collections.Generic.List[System.IO.FileInfo]]::new() + + foreach ($f in $all) { + $reason = $null + # Folder pattern check + $dirs = $f.DirectoryName.Split([IO.Path]::DirectorySeparatorChar) + foreach ($pat in $IgnoreFolders) { + $sepRegex = [Regex]::Escape([IO.Path]::DirectorySeparatorChar) + $segments = $f.DirectoryName -split $sepRegex # safe on Win & *nix + + foreach ($pat in $IgnoreFolders) { + if ($segments -like $pat) { + $reason = "matched ignore-folder '$pat'" + break + } + + } + } + # File name check + if (-not $reason) { + foreach ($pattern in $IgnoreFiles) { + if ($f.Name -like $pattern) { + $reason = "filename '$($f.Name)' matches ignore pattern '$pattern'" + break + } + } + } + + # Size check + if (-not $reason -and $MaxFileSize -gt 0 -and $f.Length -gt $MaxFileSize) { + $reason = "exceeds maxFileSize ($MaxFileSize bytes)" + } + + if ($reason) { + Write-Verbose "EXCLUDING: $($f.FullName) β†’ $reason" + } else { + Write-Verbose "INCLUDING: $($f.FullName)" + $result.Add($f) + } + } + + return $result +} + +function Build-ClipboardContent { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [System.Collections.Generic.List[System.IO.FileInfo]] $Files, + + [Parameter()] + [hashtable] $Replacements + ) + $sb = [System.Text.StringBuilder]::new() + foreach ($f in $Files) { + $sb.AppendLine("File: $($f.FullName)") | Out-Null + $sb.AppendLine(('-' * 60)) | Out-Null + $text = Get-Content -Raw -LiteralPath $f.FullName -ErrorAction Stop + + foreach ($token in $Replacements.Keys) { + $val = $Replacements[$token] + $text = $text -replace ([Regex]::Escape($token)), $val + } + + $sb.AppendLine($text) | Out-Null + $sb.AppendLine() | Out-Null + } + return $sb.ToString() +} + +function Copy-ToClipboard { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [string] $Content, + + [Parameter()] + [switch] $ShowList, + + [Parameter()] + [System.Collections.Generic.List[System.IO.FileInfo]] $Files + ) + # Pass the entire string as a single Value, rather than via the pipeline + Set-Clipboard -Value $Content + + Write-Host "βœ… Copied $($Files.Count) file(s) to clipboard." + if ($ShowList) { + Write-Host "`nFiles included:" + foreach ($f in $Files) { + Write-Host " - $($f.FullName)" + } + } +} + +#–– Begin script logic –– + +if (-not $PSBoundParameters.ContainsKey('ConfigFile')) { + # Are we running from a script file? + if ($MyInvocation.MyCommand.Path) { + # Use the folder the script lives in + $scriptFolder = Split-Path -Parent $MyInvocation.MyCommand.Path + } + else { + # Interactive / dot-sourced: use the cwd + $scriptFolder = (Get-Location).ProviderPath + } + + $ConfigFile = Join-Path $scriptFolder 'config.json' +} + + +# Load and merge config +$config = Get-Config -ConfigFilePath $ConfigFile + +# Merge CLI parameters over config values +$rp = if ($PSBoundParameters.ContainsKey('RepoPath')) { $RepoPath } else { $config.repoPath } +$mf = if ($PSBoundParameters.ContainsKey('MaxFileSize')) { $MaxFileSize } else { [long]$config.maxFileSize } +$if = if ($PSBoundParameters.ContainsKey('IgnoreFolders') -and $IgnoreFolders) { $IgnoreFolders } else { @($config.ignoreFolders) } +$ifl = if ($PSBoundParameters.ContainsKey('IgnoreFiles') -and $IgnoreFiles) { $IgnoreFiles } else { @($config.ignoreFiles) } +$rep = if ($PSBoundParameters.ContainsKey('Replacements')) { $Replacements } else { + $h = @{}; foreach ($p in $config.replacements.PSObject.Properties) { $h[$p.Name] = $p.Value }; $h +} +$scf = if ($PSBoundParameters.ContainsKey('ShowCopiedFiles')) { + $ShowCopiedFiles.IsPresent + } else { + [bool]$config.showCopiedFiles + } + +if ($null -eq $if) { $if = @() } +if ($null -eq $ifl) { $ifl = @() } + +# Gather, filter, and log +$filesToCopy = Get-FilesToInclude ` + -RepoRoot $rp ` + -IgnoreFolders $if ` + -IgnoreFiles $ifl ` + -MaxFileSize $mf + + +if ($filesToCopy.Count -eq 0) { + Write-Warning 'No files passed the filters; nothing to copy.' + return +} + +# Build content & copy +$content = Build-ClipboardContent -Files $filesToCopy -Replacements $rep +Copy-ToClipboard -Content $content -ShowList:$scf -Files $filesToCopy diff --git a/rpcp.sh b/rpcp.sh index a578989..ff50b02 100644 --- a/rpcp.sh +++ b/rpcp.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # -# repocopy.sh β€” Copy filtered parts of a repo to the clipboard according to config.json +# rpcp.sh β€” Copy filtered parts of a repo to the clipboard according to config.json # # Dependencies: # - jq (for JSON parsing) # - pbcopy (macOS), xclip (Linux), clip.exe or powershell.exe (WSL) for clipboard # # Usage: -# repocopy.sh [--repo-path path] [--config-file file] +# rpcp.sh [--repo-path path] [--config-file file] # [--max-file-size bytes] [--ignore-folders pat1,pat2] # [--ignore-files f1,f2] [--replacements '{"T":"v",...}'] # [--show-copied-files] [--verbose] # # Example: -# repocopy.sh --verbose +# rpcp.sh --verbose # set -euo pipefail diff --git a/tests/bash/rpcp.bats b/tests/bash/rpcp.bats index 2421898..6bef195 100644 --- a/tests/bash/rpcp.bats +++ b/tests/bash/rpcp.bats @@ -1,111 +1,111 @@ -#!/usr/bin/env bats -# -# End-to-end tests for the Bash version of repocopy (rpcp.sh) -# ───────────────────────────────────────────────────────────── -# β€’ Spins-up a temp repo each run (safe & hermetic) -# β€’ Stubs xclip/pbcopy so we can inspect what hits the clipboard -# β€’ Verifies: -# 1. happy-path copy & token replacement -# 2. max-file-size exclusion -# 3. override of max-file-size via CLI -# 4. folder-ignore pattern ("build") -# 5. behaviour when --show-copied-files is used -# -export BATS_LIB_PATH="$PWD/tests/test_helper" -load 'test_helper/bats-support/load' -load 'test_helper/bats-assert/load' - -setup() { - # ── β‘  disposable sandbox repo ────────────────────────────── - TMP_REPO="$(mktemp -d)" - mkdir -p "$TMP_REPO/src" "$TMP_REPO/build" - - # source files - printf 'hello ClientName\n' >"$TMP_REPO/src/include.txt" - printf 'ignore me\n' >"$TMP_REPO/manifest.json" - head -c 10 "$TMP_REPO/image.png" - - # a 300-KiB file to test the size filter - dd if=/dev/zero of="$TMP_REPO/src/big.bin" bs=1k count=300 2>/dev/null - - # something inside an ignored folder - printf 'ignore me\n' >"$TMP_REPO/build/output.txt" - - # config.json that matches the Pester suite - cat >"$TMP_REPO/config.json" <<'JSON' -{ - "repoPath": ".", - "maxFileSize": 204800, - "ignoreFolders": [ "build" ], - "ignoreFiles": [ "manifest.json", "*.png", "config.json" ], - "replacements": { "ClientName": "Bob" }, - "showCopiedFiles": false, - "autoInstallDeps": false -} -JSON - - # ── β‘‘ stub clipboard (xclip) ─────────────────────────────── - CLIP_FILE="$(mktemp)" - STUB_DIR="$(mktemp -d)" - cat >"$STUB_DIR/xclip" < "$CLIP_FILE" -STUB - chmod +x "$STUB_DIR/xclip" - PATH="$STUB_DIR:$PATH" -} - -teardown() { - rm -rf "$TMP_REPO" "$CLIP_FILE" "$STUB_DIR" -} - -# helper to run rpcp.sh and slurp clipboard into $CLIP variable -run_rpcp() { - run bash ./rpcp.sh "$@" - # copy the clipboard text into a shell variable for assertions - CLIP="$(cat "$CLIP_FILE")" -} - -# ───────────────────────────────────────────────────────────── -@test "default run: copies only permitted files & replaces tokens" { - run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" - - assert_success - assert_line --partial "βœ… Copied" # sanity - - assert_regex "$CLIP" "include\\.txt" - assert_regex "$CLIP" "hello Bob" - - refute_regex "$CLIP" "manifest\\.json" - refute_regex "$CLIP" "image\\.png" - refute_regex "$CLIP" "config\\.json" - refute_regex "$CLIP" "output\\.txt" - refute_regex "$CLIP" "big\\.bin" -} - -@test "size filter: big.bin is excluded by default" { - run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" - refute_regex "$CLIP" "big\\.bin" -} - -@test "size override: big.bin appears when --max-file-size 0 is used" { - run_rpcp --repo-path "$TMP_REPO" \ - --config-file "$TMP_REPO/config.json" \ - --max-file-size 0 - assert_regex "$CLIP" "big\\.bin" -} - -@test "folder ignore: anything under build/ is skipped" { - run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" - refute_regex "$CLIP" "build/output\\.txt" -} - -@test "--show-copied-files does not affect clipboard content" { - run_rpcp --repo-path "$TMP_REPO" \ - --config-file "$TMP_REPO/config.json" \ - --show-copied-files - - # The script prints the file list to stdout; - # we just need to ensure normal data is still on the clipboard - assert_regex "$CLIP" "include\\.txt" -} +#!/usr/bin/env bats +# +# End-to-end tests for the Bash version of repocopy (rpcp.sh) +# ───────────────────────────────────────────────────────────── +# β€’ Spins-up a temp repo each run (safe & hermetic) +# β€’ Stubs xclip/pbcopy so we can inspect what hits the clipboard +# β€’ Verifies: +# 1. happy-path copy & token replacement +# 2. max-file-size exclusion +# 3. override of max-file-size via CLI +# 4. folder-ignore pattern ("build") +# 5. behaviour when --show-copied-files is used +# +export BATS_LIB_PATH="$PWD/tests/test_helper" +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +setup() { + # ── β‘  disposable sandbox repo ────────────────────────────── + TMP_REPO="$(mktemp -d)" + mkdir -p "$TMP_REPO/src" "$TMP_REPO/build" + + # source files + printf 'hello ClientName\n' >"$TMP_REPO/src/include.txt" + printf 'ignore me\n' >"$TMP_REPO/manifest.json" + head -c 10 "$TMP_REPO/image.png" + + # a 300-KiB file to test the size filter + dd if=/dev/zero of="$TMP_REPO/src/big.bin" bs=1k count=300 2>/dev/null + + # something inside an ignored folder + printf 'ignore me\n' >"$TMP_REPO/build/output.txt" + + # config.json that matches the Pester suite + cat >"$TMP_REPO/config.json" <<'JSON' +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles": [ "manifest.json", "*.png", "config.json" ], + "replacements": { "ClientName": "Bob" }, + "showCopiedFiles": false, + "autoInstallDeps": false +} +JSON + + # ── β‘‘ stub clipboard (xclip) ─────────────────────────────── + CLIP_FILE="$(mktemp)" + STUB_DIR="$(mktemp -d)" + cat >"$STUB_DIR/xclip" < "$CLIP_FILE" +STUB + chmod +x "$STUB_DIR/xclip" + PATH="$STUB_DIR:$PATH" +} + +teardown() { + rm -rf "$TMP_REPO" "$CLIP_FILE" "$STUB_DIR" +} + +# helper to run rpcp.sh and slurp clipboard into $CLIP variable +run_rpcp() { + run bash ./rpcp.sh "$@" + # copy the clipboard text into a shell variable for assertions + CLIP="$(cat "$CLIP_FILE")" +} + +# ───────────────────────────────────────────────────────────── +@test "default run: copies only permitted files & replaces tokens" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + + assert_success + assert_line --partial "βœ… Copied" # sanity + + assert_regex "$CLIP" "include\\.txt" + assert_regex "$CLIP" "hello Bob" + + refute_regex "$CLIP" "manifest\\.json" + refute_regex "$CLIP" "image\\.png" + refute_regex "$CLIP" "config\\.json" + refute_regex "$CLIP" "output\\.txt" + refute_regex "$CLIP" "big\\.bin" +} + +@test "size filter: big.bin is excluded by default" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + refute_regex "$CLIP" "big\\.bin" +} + +@test "size override: big.bin appears when --max-file-size 0 is used" { + run_rpcp --repo-path "$TMP_REPO" \ + --config-file "$TMP_REPO/config.json" \ + --max-file-size 0 + assert_regex "$CLIP" "big\\.bin" +} + +@test "folder ignore: anything under build/ is skipped" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + refute_regex "$CLIP" "build/output\\.txt" +} + +@test "--show-copied-files does not affect clipboard content" { + run_rpcp --repo-path "$TMP_REPO" \ + --config-file "$TMP_REPO/config.json" \ + --show-copied-files + + # The script prints the file list to stdout; + # we just need to ensure normal data is still on the clipboard + assert_regex "$CLIP" "include\\.txt" +} diff --git a/tests/test_helper/bats-assert b/tests/bash/test_helper/bats-assert similarity index 100% rename from tests/test_helper/bats-assert rename to tests/bash/test_helper/bats-assert diff --git a/tests/test_helper/bats-support b/tests/bash/test_helper/bats-support similarity index 100% rename from tests/test_helper/bats-support rename to tests/bash/test_helper/bats-support diff --git a/tests/fixtures/sample-repo/build/output.txt b/tests/fixtures/sample-repo/build/output.txt index f709266..592fd25 100644 --- a/tests/fixtures/sample-repo/build/output.txt +++ b/tests/fixtures/sample-repo/build/output.txt @@ -1 +1 @@ -ignore me +ignore me diff --git a/tests/fixtures/sample-repo/config.json b/tests/fixtures/sample-repo/config.json index c891671..e3e4a22 100644 --- a/tests/fixtures/sample-repo/config.json +++ b/tests/fixtures/sample-repo/config.json @@ -1,8 +1,8 @@ -{ - "repoPath": ".", - "maxFileSize": 204800, - "ignoreFolders": [ "build" ], - "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], - "replacements" : { "ClientName": "Bob" }, - "showCopiedFiles": false -} +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], + "replacements" : { "ClientName": "Bob" }, + "showCopiedFiles": false +} diff --git a/tests/fixtures/sample-repo/manifest.json b/tests/fixtures/sample-repo/manifest.json index c1c52bb..d9dcd53 100644 --- a/tests/fixtures/sample-repo/manifest.json +++ b/tests/fixtures/sample-repo/manifest.json @@ -1 +1 @@ -{ "note": "this file should be ignored by rpcp" } +{ "note": "this file should be ignored by rpcp" } diff --git a/tests/fixtures/sample-repo/src/include.txt b/tests/fixtures/sample-repo/src/include.txt index cc9d96b..bacd588 100644 --- a/tests/fixtures/sample-repo/src/include.txt +++ b/tests/fixtures/sample-repo/src/include.txt @@ -1 +1 @@ -hello Bob +hello Bob diff --git a/tests/powershell/rpcp.tests.ps1 b/tests/powershell/rpcp.tests.ps1 index 8c876e3..a7b5193 100644 --- a/tests/powershell/rpcp.tests.ps1 +++ b/tests/powershell/rpcp.tests.ps1 @@ -1,146 +1,146 @@ -# Pester 5 tests for repocopy (PowerShell edition) -# ────────────────────────────────────────────────────────────── -Import-Module Pester -ErrorAction Stop -Import-Module Microsoft.PowerShell.Management -Force # Set-Clipboard - -$ErrorActionPreference = 'Stop' - -Describe 'rpcp.ps1 end-to-end behaviour (fixture repo)' { - - # ────────────────────────────────────────────────────────── - # ❢ Shared one-time setup - # ────────────────────────────────────────────────────────── - BeforeAll { - $ProjectRoot = (Resolve-Path "$PSScriptRoot/../../").Path - $Script = Join-Path $ProjectRoot 'rpcp.ps1' - $FixtureRoot = Join-Path $ProjectRoot 'tests/fixtures/sample-repo' - - # -- ensure fixture tree exists (idempotent) ------------------------ - if (-not (Test-Path $FixtureRoot)) { - New-Item -ItemType Directory -Path "$FixtureRoot/src" -Force | Out-Null - } - - # minimal source file & image (re-create only if missing) - if (-not (Test-Path "$FixtureRoot/src/include.txt")) { - 'hello ClientName' | Set-Content "$FixtureRoot/src/include.txt" - } - if (-not (Test-Path "$FixtureRoot/manifest.json")) { - '{ "note":"ignore" }' | Set-Content "$FixtureRoot/manifest.json" - } - if (-not (Test-Path "$FixtureRoot/image.png")) { - [IO.File]::WriteAllBytes("$FixtureRoot/image.png",[byte[]](0..9)) - } - @' -{ - "repoPath": ".", - "maxFileSize": 204800, - "ignoreFolders": [ "build" ], - "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], - "replacements" : { "ClientName": "Bob" }, - "showCopiedFiles": false -} -'@ | Set-Content "$FixtureRoot/config.json" - # -- extra files for later tests ----------------------------------- - if (-not (Test-Path "$FixtureRoot/src/big.bin")) { - $size = 300kb # 300 KiB > maxFileSize - $bytes = New-Object byte[] $size - [System.Random]::new().NextBytes($bytes) - [IO.File]::WriteAllBytes("$FixtureRoot/src/big.bin", $bytes) - } - - if (-not (Test-Path "$FixtureRoot/build")) { - New-Item -ItemType Directory -Path "$FixtureRoot/build" | Out-Null - 'ignore me' | Set-Content "$FixtureRoot/build/output.txt" - } - - # -- helper: run rpcp & capture what it puts on the clipboard ------- - # ── helper: run rpcp & capture what it puts on the clipboard ───────────── -function Invoke-Rpcp { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Param - ) - - Mock Set-Clipboard { - param($Value) - # flatten any array β†’ single string so tests are simpler - Set-Variable -Name CapturedClipboard ` - -Value ($Value -join "`n") ` - -Scope Global - } - - & $Script @Param | Out-Null - - # copy-out *before* we purge the global - $result = $CapturedClipboard - Remove-Variable -Name CapturedClipboard -Scope Global -ErrorAction SilentlyContinue - return $result -} - - } - - # ────────────────────────────────────────────────────────── - Context 'Default run (config.json only)' { - It 'copies only permitted files and performs replacements' { - $copied = Invoke-Rpcp -Param @{ - RepoPath = $FixtureRoot - ConfigFile = "$FixtureRoot/config.json" - } - - $copied | Should -Match 'include\.txt' - $copied | Should -Match 'hello Bob' - - $copied | Should -Not -Match 'manifest\.json' - $copied | Should -Not -Match 'image\.png' - $copied | Should -Not -Match 'config\.json' - $copied | Should -Not -Match 'output\.txt' - $copied | Should -Not -Match 'big\.bin' - } - } - - Context 'Max file-size filter' { - It 'excludes files bigger than maxFileSize' { - $copied = Invoke-Rpcp -Param @{ - RepoPath = $FixtureRoot - ConfigFile = "$FixtureRoot/config.json" # 204 KB limit - } - - $copied | Should -Not -Match 'big\.bin' - } - - It 'includes big file when CLI overrides maxFileSize' { - $copied = Invoke-Rpcp -Param @{ - RepoPath = $FixtureRoot - ConfigFile = "$FixtureRoot/config.json" - MaxFileSize = 0 # disable size filtering - } - - $copied | Should -Match 'big\.bin' - } - } - - Context 'Folder ignore pattern' { - It 'skips anything inside folders named "build"' { - $copied = Invoke-Rpcp -Param @{ - RepoPath = $FixtureRoot - ConfigFile = "$FixtureRoot/config.json" - } - - $pattern = [regex]::Escape('build/output.txt') - $copied | Should -Not -Match $pattern - } - } - - Context 'ShowCopiedFiles switch' { - It 'still copies correctly when -ShowCopiedFiles is used' { - $copied = Invoke-Rpcp -Param @{ - RepoPath = $FixtureRoot - ConfigFile = "$FixtureRoot/config.json" - ShowCopiedFiles = $true - } - - $copied | Should -Match 'include\.txt' # sanity check - } - } -} +# Pester 5 tests for repocopy (PowerShell edition) +# ────────────────────────────────────────────────────────────── +Import-Module Pester -ErrorAction Stop +Import-Module Microsoft.PowerShell.Management -Force # Set-Clipboard + +$ErrorActionPreference = 'Stop' + +Describe 'rpcp.ps1 end-to-end behaviour (fixture repo)' { + + # ────────────────────────────────────────────────────────── + # ❢ Shared one-time setup + # ────────────────────────────────────────────────────────── + BeforeAll { + $ProjectRoot = (Resolve-Path "$PSScriptRoot/../../").Path + $Script = Join-Path $ProjectRoot 'rpcp.ps1' + $FixtureRoot = Join-Path $ProjectRoot 'tests/fixtures/sample-repo' + + # -- ensure fixture tree exists (idempotent) ------------------------ + if (-not (Test-Path $FixtureRoot)) { + New-Item -ItemType Directory -Path "$FixtureRoot/src" -Force | Out-Null + } + + # minimal source file & image (re-create only if missing) + if (-not (Test-Path "$FixtureRoot/src/include.txt")) { + 'hello ClientName' | Set-Content "$FixtureRoot/src/include.txt" + } + if (-not (Test-Path "$FixtureRoot/manifest.json")) { + '{ "note":"ignore" }' | Set-Content "$FixtureRoot/manifest.json" + } + if (-not (Test-Path "$FixtureRoot/image.png")) { + [IO.File]::WriteAllBytes("$FixtureRoot/image.png",[byte[]](0..9)) + } + @' +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], + "replacements" : { "ClientName": "Bob" }, + "showCopiedFiles": false +} +'@ | Set-Content "$FixtureRoot/config.json" + # -- extra files for later tests ----------------------------------- + if (-not (Test-Path "$FixtureRoot/src/big.bin")) { + $size = 300kb # 300 KiB > maxFileSize + $bytes = New-Object byte[] $size + [System.Random]::new().NextBytes($bytes) + [IO.File]::WriteAllBytes("$FixtureRoot/src/big.bin", $bytes) + } + + if (-not (Test-Path "$FixtureRoot/build")) { + New-Item -ItemType Directory -Path "$FixtureRoot/build" | Out-Null + 'ignore me' | Set-Content "$FixtureRoot/build/output.txt" + } + + # -- helper: run rpcp & capture what it puts on the clipboard ------- + # ── helper: run rpcp & capture what it puts on the clipboard ───────────── +function Invoke-Rpcp { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Param + ) + + Mock Set-Clipboard { + param($Value) + # flatten any array β†’ single string so tests are simpler + Set-Variable -Name CapturedClipboard ` + -Value ($Value -join "`n") ` + -Scope Global + } + + & $Script @Param | Out-Null + + # copy-out *before* we purge the global + $result = $CapturedClipboard + Remove-Variable -Name CapturedClipboard -Scope Global -ErrorAction SilentlyContinue + return $result +} + + } + + # ────────────────────────────────────────────────────────── + Context 'Default run (config.json only)' { + It 'copies only permitted files and performs replacements' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + } + + $copied | Should -Match 'include\.txt' + $copied | Should -Match 'hello Bob' + + $copied | Should -Not -Match 'manifest\.json' + $copied | Should -Not -Match 'image\.png' + $copied | Should -Not -Match 'config\.json' + $copied | Should -Not -Match 'output\.txt' + $copied | Should -Not -Match 'big\.bin' + } + } + + Context 'Max file-size filter' { + It 'excludes files bigger than maxFileSize' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" # 204 KB limit + } + + $copied | Should -Not -Match 'big\.bin' + } + + It 'includes big file when CLI overrides maxFileSize' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + MaxFileSize = 0 # disable size filtering + } + + $copied | Should -Match 'big\.bin' + } + } + + Context 'Folder ignore pattern' { + It 'skips anything inside folders named "build"' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + } + + $pattern = [regex]::Escape('build/output.txt') + $copied | Should -Not -Match $pattern + } + } + + Context 'ShowCopiedFiles switch' { + It 'still copies correctly when -ShowCopiedFiles is used' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + ShowCopiedFiles = $true + } + + $copied | Should -Match 'include\.txt' # sanity check + } + } +}