rust-switcher 1.0.4

Windows keyboard layout switcher and text conversion utility
Documentation
# bundle.ps1
# Single-file, double-click runnable PowerShell bundler for a monorepo.
#
# What it does:
# - Creates bundle.zip in the repo root (then renames to bundle_<md5-8>.zip)
# - Includes tracked + untracked files (i.e., “working tree”), but respects ALL nested .gitignore rules
# - Smart recursion: prunes ignored directories (does not traverse them)
# - Hard excludes anywhere: .git/, target/, bundle*.zip
# - Skips reparse points (junctions/symlinks) to avoid recursion traps
#
# Usage:
# - Double click bundle.ps1 (PowerShell will prompt/run depending on your policy)
# - Or run in a terminal:  powershell -ExecutionPolicy Bypass -File .\bundle.ps1

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Assert-GitPresent {
  if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
    throw "git not found in PATH"
  }
}

function Get-RepoRoot {
  $root = (& git rev-parse --show-toplevel 2>$null)
  if (-not $root) { throw "not a git repository (git rev-parse failed)" }
  $root = $root.Trim()
  if (-not (Test-Path -LiteralPath $root -PathType Container)) {
    throw "repo root does not exist: $root"
  }
  return (Resolve-Path -LiteralPath $root).Path
}

function New-TempBundlePath {
  param([string]$Root)
  return (Join-Path $Root 'bundle.zip')
}

function Remove-IfExists {
  param([string]$Path)
  if (Test-Path -LiteralPath $Path) {
    Remove-Item -LiteralPath $Path -Force
  }
}

function Get-RelativePath {
  param([string]$Root, [string]$FullPath)
  $rel = [System.IO.Path]::GetRelativePath($Root, $FullPath)
  if ([string]::IsNullOrWhiteSpace($rel) -or $rel -eq '.') { return '' }

  # было (плохо для zip):
  # return ($rel -replace '/', '\')

  # должно быть (zip standard, кроссплатформа):
  return ($rel -replace '\\', '/')
}


function Test-HardExclude {
  param([string]$RelPath)

  if ([string]::IsNullOrWhiteSpace($RelPath)) { return $false }

  $r = $RelPath -replace '/', '\'
  $parts = $r -split '\\'
  foreach ($p in $parts) {
    if ($p -ieq '.git')   { return $true }
    if ($p -ieq 'target') { return $true }
  }

  $leaf = [System.IO.Path]::GetFileName($r)
  if ($leaf -ilike 'bundle*.zip') { return $true }

  return $false
}

function Test-GitIgnored {
  param(
    [Parameter(Mandatory=$true)][string]$Root,
    [Parameter(Mandatory=$true)][string]$RelPath
  )

  if ([string]::IsNullOrWhiteSpace($RelPath)) { return $false }

  # Critical: pass RELATIVE path to git, not absolute.
  & git -C $Root check-ignore -q -- $RelPath | Out-Null
  return ($LASTEXITCODE -eq 0)
}

function Test-ReparsePoint {
  param([System.IO.FileSystemInfo]$Item)
  return (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
}

function Open-ZipForCreate {
  param([string]$OutPath)
  Add-Type -AssemblyName System.IO.Compression
  Add-Type -AssemblyName System.IO.Compression.FileSystem
  return [System.IO.Compression.ZipFile]::Open($OutPath, [System.IO.Compression.ZipArchiveMode]::Create)
}

function Add-FileToZip {
  param(
    [Parameter(Mandatory=$true)][System.IO.Compression.ZipArchive]$Zip,
    [Parameter(Mandatory=$true)][string]$FullPath,
    [Parameter(Mandatory=$true)][string]$RelPath
  )
  if (-not (Test-Path -LiteralPath $FullPath -PathType Leaf)) { return }

  [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
    $Zip,
    $FullPath,
    $RelPath,
    [System.IO.Compression.CompressionLevel]::Optimal
  ) | Out-Null
}

function Walk-And-Pack {
  param(
    [Parameter(Mandatory=$true)][string]$Root,
    [Parameter(Mandatory=$true)][string]$Dir,
    [Parameter(Mandatory=$true)][System.IO.Compression.ZipArchive]$Zip
  )

  $items = Get-ChildItem -LiteralPath $Dir -Force -ErrorAction Stop
  foreach ($item in $items) {
    $full = $item.FullName
    $rel  = Get-RelativePath -Root $Root -FullPath $full

    if (Test-HardExclude -RelPath $rel) { continue }
    if (Test-GitIgnored -Root $Root -RelPath $rel) { continue }

    if ($item.PSIsContainer) {
      if (Test-ReparsePoint $item) { continue }
      Walk-And-Pack -Root $Root -Dir $full -Zip $Zip
      continue
    }

    Add-FileToZip -Zip $Zip -FullPath $full -RelPath $rel
  }
}

function Get-ShortMd5 {
  param([string]$Path)
  $h = (Get-FileHash -Algorithm MD5 -LiteralPath $Path).Hash
  if (-not $h) { throw "failed to hash: $Path" }
  return $h.Substring(0,8).ToLower()
}

function Rename-WithHash {
  param(
    [Parameter(Mandatory=$true)][string]$ZipPath
  )

  $dir  = Split-Path -Parent $ZipPath
  $base = [System.IO.Path]::GetFileNameWithoutExtension($ZipPath)
  $ext  = [System.IO.Path]::GetExtension($ZipPath)

  $hash = Get-ShortMd5 -Path $ZipPath
  $dst  = Join-Path $dir ("{0}_{1}{2}" -f $base, $hash, $ext)

  Remove-IfExists -Path $dst
  Move-Item -LiteralPath $ZipPath -Destination $dst -Force
  return $dst
}

function Main {
  Assert-GitPresent
  $root = Get-RepoRoot

  $out = New-TempBundlePath -Root $root
  Remove-IfExists -Path $out

  $zip = Open-ZipForCreate -OutPath $out
  try {
    Walk-And-Pack -Root $root -Dir $root -Zip $zip
  } finally {
    $zip.Dispose()
  }

  if (-not (Test-Path -LiteralPath $out -PathType Leaf)) {
    throw "bundle was not created: $out"
  }

  $final = Rename-WithHash -ZipPath $out
  Write-Host ("OK: {0}" -f $final)
}

try {
  Main
} catch {
  Write-Host ("ERROR: {0}" -f $_.Exception.Message)
  exit 1
}