From 26c2c02c128e3808307e814b893a69f57bd9d44d Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 20:51:45 -0400 Subject: [PATCH 1/6] Added dry-run, tree view, and global catignore support for better development experience. --- src/PowerCat/PowerCat.psm1 | 62 ++++++++++++++++++++++++++++++----- tests/PowerCat.Tests.ps1 | 67 +++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/PowerCat/PowerCat.psm1 b/src/PowerCat/PowerCat.psm1 index b678d49..e1e388c 100644 --- a/src/PowerCat/PowerCat.psm1 +++ b/src/PowerCat/PowerCat.psm1 @@ -190,7 +190,11 @@ function Invoke-PowerCat { [string]$HeaderFormat = "Markdown", [Alias("sta")] - [switch]$Stats + [switch]$Stats, + + [switch]$WhatIf, + + [switch]$IncludeTree ) # Extend $Extensions based on switches @@ -283,24 +287,35 @@ function Invoke-PowerCat { # Read catignore patterns $IgnorePatterns = @() if (-not $NoCatIgnore) { - # Determine catignore file path + # 1. Global/User catignore + $userCatIgnore = Join-Path $HOME ".catignore" + if (Test-Path $userCatIgnore) { + $IgnorePatterns += Get-Content -Path $userCatIgnore | Where-Object { $_ -and -not $_.StartsWith('#') } | ForEach-Object { $_.Trim() } + } + # For testing in non-standard environments, allow override via env var + if ($env:CATIGNORE_PATH -and (Test-Path $env:CATIGNORE_PATH)) { + $IgnorePatterns += Get-Content -Path $env:CATIGNORE_PATH | Where-Object { $_ -and -not $_.StartsWith('#') } | ForEach-Object { $_.Trim() } + } + + # 2. Local catignore if (-not $CatIgnore) { $CatIgnore = Join-Path -Path $SourceDir -ChildPath "catignore" } else { - # Expand user-provided catignore path $CatIgnore = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CatIgnore) } - # Read patterns if catignore exists - if (Test-Path -Path $CatIgnore) { - $IgnorePatterns = @(Get-Content -Path $CatIgnore | + if (Test-Path $CatIgnore) { + $IgnorePatterns += Get-Content -Path $CatIgnore | Where-Object { $_ -and -not $_.StartsWith('#') } | - ForEach-Object { $_.Trim() }) + ForEach-Object { $_.Trim() } } - } + $IgnorePatterns = $IgnorePatterns | Select-Object -Unique + } + + # Get all files in the directory (single scan, filter by extension in PowerShell) + - # Get all files in the directory (single scan, filter by extension in PowerShell) $getChildItemParams = @{ Path = $SourceDir File = $true @@ -327,7 +342,11 @@ function Invoke-PowerCat { # Check catignore patterns $shouldIgnore = $false + $sourceDirPath = $SourceDir.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $relativePath = $file.FullName.Substring($sourceDirPath.Length).TrimStart('\', '/') + foreach ($pattern in $IgnorePatterns) { + # Check against relative path or filename if ($relativePath -like $pattern -or $file.Name -like $pattern) { $shouldIgnore = $true break @@ -371,6 +390,14 @@ function Invoke-PowerCat { $Files = $Files | Select-Object -Unique } + if ($WhatIf) { + Write-Output "Dry run: The following files would be bundled:" + foreach ($file in $Files) { + Write-Output $file.FullName + } + return + } + switch ($Sort) { "Name" { $Files = $Files | Sort-Object Name } "Extension" { $Files = $Files | Sort-Object Extension, Name } @@ -381,7 +408,24 @@ function Invoke-PowerCat { # Build output content in a string array for efficiency $OutputContent = @() + # Include Tree structure + if ($IncludeTree) { + $tree = Get-ChildItem -Path $SourceDir -Recurse | ForEach-Object { + $relativePath = $_.FullName.Substring($SourceDir.TrimEnd('\/').Length + 1) + $depth = ($relativePath.Split([System.IO.Path]::DirectorySeparatorChar).Count - 1) + if ($_.PSIsContainer) { + (" " * $depth) + "|-- " + $_.Name + "/" + } else { + (" " * $depth) + "|-- " + $_.Name + } + } + $OutputContent += "=== Directory Tree ===" + $OutputContent += $tree + $OutputContent += "" + } + foreach ($file in $Files) { + Write-Verbose "Processing file: $($file.FullName)" # Generate header based on format $header = switch ($HeaderFormat) { "JSON" { ConvertTo-Json @{ file = $file.Name } -Compress } diff --git a/tests/PowerCat.Tests.ps1 b/tests/PowerCat.Tests.ps1 index a7eb274..1a10a11 100644 --- a/tests/PowerCat.Tests.ps1 +++ b/tests/PowerCat.Tests.ps1 @@ -497,6 +497,71 @@ function HelloWorld { } } + Context "DX features (Dry-run and Verbose)" { + BeforeAll { + $script:tempDirDX = Join-Path ([System.IO.Path]::GetTempPath()) "PowerCatDXTest_$([System.Guid]::NewGuid())" + New-Item -ItemType Directory -Path $script:tempDirDX -Force | Out-Null + Set-Content -Path (Join-Path $script:tempDirDX "file1.ps1") -Value "Content1" + Set-Content -Path (Join-Path $script:tempDirDX "file2.ps1") -Value "Content2" + } + + AfterAll { + Remove-Item -Path $script:tempDirDX -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Lists files with -WhatIf and does not produce output" { + $result = Invoke-PowerCat -s $script:tempDirDX -Powershell -WhatIf | Out-String + $result | Should -Match "Dry run: The following files would be bundled" + $result | Should -Match "file1.ps1" + $result | Should -Match "file2.ps1" + # Should not produce file contents + $result | Should -Not -Match "Content1" + $result | Should -Not -Match "Content2" + } + + It "Outputs verbose info with -Verbose" { + $verboseResult = Invoke-PowerCat -s $script:tempDirDX -Powershell -Verbose 4>&1 | Out-String + $verboseResult | Should -Match "Processing file" + } + + It "Outputs directory tree with -IncludeTree" { + $result = Invoke-PowerCat -s $script:tempDirDX -Powershell -IncludeTree | Out-String + $result | Should -Match "=== Directory Tree ===" + $result | Should -Match "file1.ps1" + $result | Should -Match "file2.ps1" + } + } + + Context "Configuration and Context" { + It "Merges global and local catignore" { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "PowerCatIgnoreTest_$([System.Guid]::NewGuid())" + $catIgnorePath = Join-Path ([System.IO.Path]::GetTempPath()) ".catignore_test" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + $subDir = Join-Path $tempDir "sub" + New-Item -ItemType Directory -Path $subDir -Force | Out-Null + + $ignoreFile = Join-Path $subDir "ignoreMe.ps1" + Set-Content -Path $ignoreFile -Value "Ignore" + $keepFile = Join-Path $tempDir "keepMe.ps1" + Set-Content -Path $keepFile -Value "Keep" + + # Ignore by filename pattern + Set-Content -Path $catIgnorePath -Value "ignoreMe.ps1" + $env:CATIGNORE_PATH = $catIgnorePath + try { + $result = Invoke-PowerCat -s $tempDir -Recurse -Powershell | Out-String + $result | Should -Not -Match "ignoreMe.ps1" + $result.ToString() | Should -Match "keepMe.ps1" + } + + finally { + Remove-Item -Path $catIgnorePath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + $env:CATIGNORE_PATH = $null + } + } + } + Context "Statistics reporting" { BeforeAll { $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "PowerCatStatsTest_$([System.Guid]::NewGuid())" @@ -734,4 +799,4 @@ function HelloWorld { } } } -} \ No newline at end of file +} From d6a3698b8a614066a469b0fabc5b049e79f454db Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 20:58:44 -0400 Subject: [PATCH 2/6] Refactor tree generation to respect recursion flag and reuse enumeration --- src/PowerCat/PowerCat.psm1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PowerCat/PowerCat.psm1 b/src/PowerCat/PowerCat.psm1 index e1e388c..09a7bb5 100644 --- a/src/PowerCat/PowerCat.psm1 +++ b/src/PowerCat/PowerCat.psm1 @@ -410,7 +410,9 @@ function Invoke-PowerCat { # Include Tree structure if ($IncludeTree) { - $tree = Get-ChildItem -Path $SourceDir -Recurse | ForEach-Object { + # Generate tree from the full file list (directories + files) + $allFiles = Get-ChildItem @getChildItemParams + $tree = $allFiles | ForEach-Object { $relativePath = $_.FullName.Substring($SourceDir.TrimEnd('\/').Length + 1) $depth = ($relativePath.Split([System.IO.Path]::DirectorySeparatorChar).Count - 1) if ($_.PSIsContainer) { From 9ccf24cc5d335045b19911c4bfbeeb2b1f93ae80 Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 21:00:21 -0400 Subject: [PATCH 3/6] Clean up redundant path calculations in file filtering --- src/PowerCat/PowerCat.psm1 | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/PowerCat/PowerCat.psm1 b/src/PowerCat/PowerCat.psm1 index 09a7bb5..6c6adb0 100644 --- a/src/PowerCat/PowerCat.psm1 +++ b/src/PowerCat/PowerCat.psm1 @@ -335,16 +335,13 @@ function Invoke-PowerCat { # Filter out ignored files and by size if ($IgnorePatterns.Count -gt 0 -or $MinSize -gt 0 -or $MaxSize -gt 0) { + $sourceDirPath = $SourceDir.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $Files = $Files | Where-Object { $file = $_ - $sourceDirPath = $SourceDir.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $relativePath = $file.FullName.Substring($sourceDirPath.Length).TrimStart('\', '/') # Check catignore patterns $shouldIgnore = $false - $sourceDirPath = $SourceDir.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - $relativePath = $file.FullName.Substring($sourceDirPath.Length).TrimStart('\', '/') - foreach ($pattern in $IgnorePatterns) { # Check against relative path or filename if ($relativePath -like $pattern -or $file.Name -like $pattern) { From fba895a72cfeed4dd5a82523db68c5ded4c4c818 Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 21:02:48 -0400 Subject: [PATCH 4/6] Restore CATIGNORE_PATH environment variable in tests --- tests/PowerCat.Tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/PowerCat.Tests.ps1 b/tests/PowerCat.Tests.ps1 index 1a10a11..bdab3ae 100644 --- a/tests/PowerCat.Tests.ps1 +++ b/tests/PowerCat.Tests.ps1 @@ -547,6 +547,7 @@ function HelloWorld { # Ignore by filename pattern Set-Content -Path $catIgnorePath -Value "ignoreMe.ps1" + $oldCatIgnore = $env:CATIGNORE_PATH $env:CATIGNORE_PATH = $catIgnorePath try { $result = Invoke-PowerCat -s $tempDir -Recurse -Powershell | Out-String @@ -557,7 +558,7 @@ function HelloWorld { finally { Remove-Item -Path $catIgnorePath -Force -ErrorAction SilentlyContinue Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - $env:CATIGNORE_PATH = $null + $env:CATIGNORE_PATH = $oldCatIgnore } } } From af73e7386daa9feaf53774eb95504bee1d94c100 Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 21:04:32 -0400 Subject: [PATCH 5/6] Normalize path separators for robust catignore matching --- src/PowerCat/PowerCat.psm1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PowerCat/PowerCat.psm1 b/src/PowerCat/PowerCat.psm1 index 6c6adb0..8810029 100644 --- a/src/PowerCat/PowerCat.psm1 +++ b/src/PowerCat/PowerCat.psm1 @@ -338,13 +338,15 @@ function Invoke-PowerCat { $sourceDirPath = $SourceDir.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $Files = $Files | Where-Object { $file = $_ - $relativePath = $file.FullName.Substring($sourceDirPath.Length).TrimStart('\', '/') + $relativePath = $file.FullName.Substring($sourceDirPath.Length).Replace('\', '/').TrimStart('/') # Check catignore patterns $shouldIgnore = $false foreach ($pattern in $IgnorePatterns) { + # Normalize pattern to forward slashes for matching + $normalizedPattern = $pattern.Replace('\', '/') # Check against relative path or filename - if ($relativePath -like $pattern -or $file.Name -like $pattern) { + if ($relativePath -like $normalizedPattern -or $file.Name -like $normalizedPattern) { $shouldIgnore = $true break } From a6c403b40022fdbabed0a112c723ca13b9e74247 Mon Sep 17 00:00:00 2001 From: TheOnliestMattastic Date: Thu, 21 May 2026 21:22:21 -0400 Subject: [PATCH 6/6] Update comment-based help for new parameters and catignore behavior --- src/PowerCat/PowerCat.psm1 | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/PowerCat/PowerCat.psm1 b/src/PowerCat/PowerCat.psm1 index 8810029..cb03e53 100644 --- a/src/PowerCat/PowerCat.psm1 +++ b/src/PowerCat/PowerCat.psm1 @@ -65,6 +65,14 @@ Format for file headers: Markdown (default), JSON, or YAML. .PARAMETER Stats Display statistics: file count, character count, and estimated token usage (for AI context planning). +.PARAMETER WhatIf +Performs a dry run, listing all files that would be bundled without creating the output bundle. + + +.PARAMETER IncludeTree + +Prepends a directory structure tree to the output, showing the hierarchy of processed files. The tree respects the recursive scope defined by `-Recurse`. + .EXAMPLE Invoke-PowerCat "C:\Project" -o "C:\bundle.txt" Concatenates matching files from C:\Project into bundle.txt (use `-IncludeMarkdown` to include .md files). @@ -83,7 +91,13 @@ Displays file count, character count, and estimated token usage (useful for AI c .NOTES Author: Matthew Poole Chicano -License: GPL v3.0 +License: MIT + +CatIgnore behavior: POWERcat looks for ignores in the following order: +1. $HOME/.catignore (Global/User level) +2. Environment variable CATIGNORE_PATH (if set) +3. 'catignore' in the SourceDir (Project level) +Patterns should use forward slashes (/) for cross-platform compatibility. .LINK https://github.com/TheOnliestMattastic/PowerCat @@ -515,4 +529,4 @@ function Invoke-PowerCat { Set-Alias -Name PowerCat -Value Invoke-PowerCat Set-Alias -Name pcat -Value Invoke-PowerCat Set-Alias -Name concat -Value Invoke-PowerCat -Export-ModuleMember -Function Invoke-PowerCat -Alias PowerCat, pcat, concat \ No newline at end of file +Export-ModuleMember -Function Invoke-PowerCat -Alias PowerCat, pcat, concat