Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 73 additions & 14 deletions src/PowerCat/PowerCat.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -190,7 +204,11 @@ function Invoke-PowerCat {
[string]$HeaderFormat = "Markdown",

[Alias("sta")]
[switch]$Stats
[switch]$Stats,

[switch]$WhatIf,

[switch]$IncludeTree
)

# Extend $Extensions based on switches
Expand Down Expand Up @@ -283,24 +301,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
Expand All @@ -320,15 +349,18 @@ 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('\', '/')
$relativePath = $file.FullName.Substring($sourceDirPath.Length).Replace('\', '/').TrimStart('/')

# Check catignore patterns
$shouldIgnore = $false
foreach ($pattern in $IgnorePatterns) {
if ($relativePath -like $pattern -or $file.Name -like $pattern) {
# Normalize pattern to forward slashes for matching
$normalizedPattern = $pattern.Replace('\', '/')
# Check against relative path or filename
if ($relativePath -like $normalizedPattern -or $file.Name -like $normalizedPattern) {
$shouldIgnore = $true
break
}
Expand Down Expand Up @@ -371,6 +403,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
}
Comment on lines +407 to +410

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Write-Output for dry-run status messages sends them to the success stream. This can pollute the output if the function is used in a pipeline or if the result is captured in a variable. It is better to use Write-Host or Write-Information for these messages so they don't pollute the data output.

    Write-Host "Dry run: The following files would be bundled:"
    foreach ($file in $Files) {
      Write-Host $file.FullName
    }

return
}

switch ($Sort) {
"Name" { $Files = $Files | Sort-Object Name }
"Extension" { $Files = $Files | Sort-Object Extension, Name }
Expand All @@ -381,7 +421,26 @@ function Invoke-PowerCat {
# Build output content in a string array for efficiency
$OutputContent = @()

# Include Tree structure
if ($IncludeTree) {
# 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) {
(" " * $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 }
Expand Down Expand Up @@ -470,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
Export-ModuleMember -Function Invoke-PowerCat -Alias PowerCat, pcat, concat
68 changes: 67 additions & 1 deletion tests/PowerCat.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,72 @@ 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"
$oldCatIgnore = $env:CATIGNORE_PATH
$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 = $oldCatIgnore
}
}
}

Context "Statistics reporting" {
BeforeAll {
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "PowerCatStatsTest_$([System.Guid]::NewGuid())"
Expand Down Expand Up @@ -734,4 +800,4 @@ function HelloWorld {
}
}
}
}
}
Loading