Created
February 18, 2026 21:24
-
-
Save sametcn99/e646f9164ae0a0c1aedcc03d6e009f35 to your computer and use it in GitHub Desktop.
GitHub Repo TUI Cloner (PowerShell)
This file contains hidden or 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
| <# | |
| GitHub Repo TUI Cloner (PowerShell) | |
| What this script does: | |
| - Fetches your owned repositories from the GitHub API using a token. | |
| - Shows an interactive terminal UI so you can pick exactly which repos to clone. | |
| - Clones only selected repositories into a destination folder. | |
| Authentication: | |
| - Preferred: set `$env:GITHUB_TOKEN` in your shell. | |
| - Alternative: place your token in `$HARDCODED_GITHUB_TOKEN` below. | |
| Default clone behavior: | |
| - Uses shallow clone to get only the latest commit from the default branch: | |
| `git clone --depth 1 --single-branch --branch <default_branch> <clone_url> <target>` | |
| - Use `-FullClone` to clone full history. | |
| Usage: | |
| - Run without parameters: | |
| `powershell -NoProfile -ExecutionPolicy Bypass -File .\clone-tui.ps1` | |
| - Edit the CONFIG section in this file to change behavior. | |
| TUI controls: | |
| - Up/Down: move cursor | |
| - Space: toggle selection for current repo | |
| - A: select all repos | |
| - N: clear all selections | |
| - R: toggle all archived repos (select all archived if any are unselected, otherwise unselect all archived) | |
| - F: toggle all forked repos (select all forks if any are unselected, otherwise unselect all forks) | |
| - Enter: clone selected repos | |
| - Q or Esc: quit without cloning | |
| Requirements: | |
| - PowerShell 5.1+ or PowerShell 7+ | |
| - Git installed and available in PATH | |
| - Network access to api.github.com and github.com | |
| #> | |
| Set-StrictMode -Version Latest | |
| # For local-only use, you can hardcode your token here. | |
| # Safer option: use $env:GITHUB_TOKEN instead. | |
| $HARDCODED_GITHUB_TOKEN = "REPLACE-TOKEN" | |
| # Internal configuration (parameterless mode). | |
| $CONFIG = @{ | |
| Dest = "." | |
| IncludeArchived = $true | |
| FullClone = $false | |
| ApiTimeoutSec = 25 | |
| } | |
| function Show-Help { | |
| Write-Host @" | |
| GitHub Repo TUI Cloner (PowerShell) | |
| Usage: | |
| .\clone-tui.ps1 | |
| Configuration: | |
| Edit the CONFIG hashtable in this script: | |
| - Dest | |
| - IncludeArchived | |
| - FullClone | |
| Controls: | |
| Up/Down Move in list | |
| Space Toggle selection | |
| A Select all | |
| N Clear selection | |
| R Toggle all archived repos | |
| F Toggle all forked repos | |
| Enter Clone selected repositories | |
| Q / Esc Exit | |
| Notes: | |
| - Default mode performs shallow clone (latest commit only). | |
| - Use -FullClone to clone complete history. | |
| - API timeout is controlled by CONFIG.ApiTimeoutSec. | |
| "@ | |
| } | |
| function Get-Token { | |
| if (-not [string]::IsNullOrWhiteSpace($HARDCODED_GITHUB_TOKEN)) { | |
| return $HARDCODED_GITHUB_TOKEN | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { | |
| return $env:GITHUB_TOKEN | |
| } | |
| throw "Token not found. Set HARDCODED_GITHUB_TOKEN or GITHUB_TOKEN." | |
| } | |
| function Get-OwnedRepos { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Token, | |
| [switch]$IncludeArchived, | |
| [int]$TimeoutSec = 25 | |
| ) | |
| $headers = @{ | |
| Accept = "application/vnd.github+json" | |
| Authorization = "Bearer $Token" | |
| "X-GitHub-Api-Version" = "2022-11-28" | |
| "User-Agent" = "powershell-tui-clone" | |
| } | |
| $repos = @() | |
| $page = 1 | |
| Write-Host "Fetching repositories from GitHub API..." -ForegroundColor DarkGray | |
| while ($true) { | |
| $url = "https://api.github.com/user/repos?affiliation=owner&per_page=100&page=$page" | |
| Write-Host (" Requesting page {0}..." -f $page) -ForegroundColor DarkGray | |
| try { | |
| $rawData = Invoke-RestMethod -Uri $url -Headers $headers -Method Get -TimeoutSec $TimeoutSec | |
| } | |
| catch { | |
| throw ( | |
| "GitHub API request failed on page {0}. " + | |
| "Check internet/proxy/token. Inner error: {1}" | |
| ) -f $page, $_.Exception.Message | |
| } | |
| if ($null -eq $rawData) { | |
| break | |
| } | |
| $data = @($rawData) | |
| # GitHub API errors can arrive as an object (e.g. rate limit), not as repo array. | |
| if ($data.Count -gt 0) { | |
| $first = $data[0] | |
| $messageProp = $first.PSObject.Properties["message"] | |
| $nameProp = $first.PSObject.Properties["name"] | |
| if ($null -ne $messageProp -and $null -eq $nameProp) { | |
| throw ("GitHub API returned an error payload: {0}" -f $messageProp.Value) | |
| } | |
| } | |
| if ($data.Count -eq 0) { | |
| break | |
| } | |
| if ($IncludeArchived) { | |
| $repos += $data | |
| } | |
| else { | |
| $repos += ( | |
| $data | Where-Object { | |
| $archivedProp = $_.PSObject.Properties["archived"] | |
| if ($null -eq $archivedProp) { | |
| return $true | |
| } | |
| return -not [bool]$archivedProp.Value | |
| } | |
| ) | |
| } | |
| $page += 1 | |
| } | |
| return @($repos | Sort-Object -Property name) | |
| } | |
| function Draw-RepoList { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [array]$Repos, | |
| [Parameter(Mandatory = $true)] | |
| [bool[]]$Selected, | |
| [int]$Cursor = 0, | |
| [int]$Top = 0 | |
| ) | |
| Clear-Host | |
| Write-Host "GitHub Repository Picker (TUI)" | |
| Write-Host "Space: toggle | A: all | N: none | R: archived | F: forks | Enter: clone | Q/Esc: quit" | |
| Write-Host "" | |
| $h = [Math]::Max(5, [Console]::WindowHeight - 6) | |
| $end = [Math]::Min($Repos.Count - 1, $Top + $h - 1) | |
| for ($i = $Top; $i -le $end; $i++) { | |
| $mark = if ($Selected[$i]) { "[x]" } else { "[ ]" } | |
| $pointer = if ($i -eq $Cursor) { ">" } else { " " } | |
| $line = "{0} {1} {2}" -f $pointer, $mark, $Repos[$i].name | |
| if ($i -eq $Cursor) { | |
| Write-Host $line -ForegroundColor Cyan | |
| } | |
| else { | |
| Write-Host $line | |
| } | |
| } | |
| Write-Host "" | |
| $selectedCount = @($Selected | Where-Object { $_ }).Count | |
| Write-Host ("Total: {0} | Selected: {1}" -f $Repos.Count, $selectedCount) | |
| } | |
| function Toggle-SelectionByFlag { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [array]$Repos, | |
| [Parameter(Mandatory = $true)] | |
| [bool[]]$Selected, | |
| [Parameter(Mandatory = $true)] | |
| [string]$FlagName | |
| ) | |
| $matchingIndexes = @() | |
| for ($i = 0; $i -lt $Repos.Count; $i++) { | |
| $prop = $Repos[$i].PSObject.Properties[$FlagName] | |
| if ($null -ne $prop -and [bool]$prop.Value) { | |
| $matchingIndexes += $i | |
| } | |
| } | |
| if ($matchingIndexes.Count -eq 0) { | |
| return | |
| } | |
| $allSelected = $true | |
| foreach ($idx in $matchingIndexes) { | |
| if (-not $Selected[$idx]) { | |
| $allSelected = $false | |
| break | |
| } | |
| } | |
| $nextValue = -not $allSelected | |
| foreach ($idx in $matchingIndexes) { | |
| $Selected[$idx] = $nextValue | |
| } | |
| } | |
| function Select-ReposTui { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [array]$Repos | |
| ) | |
| if ($Repos.Count -eq 0) { | |
| return @() | |
| } | |
| $selected = New-Object bool[] $Repos.Count | |
| $cursor = 0 | |
| $top = 0 | |
| while ($true) { | |
| $viewHeight = [Math]::Max(5, [Console]::WindowHeight - 6) | |
| if ($cursor -lt $top) { | |
| $top = $cursor | |
| } | |
| if ($cursor -ge ($top + $viewHeight)) { | |
| $top = $cursor - $viewHeight + 1 | |
| } | |
| Draw-RepoList -Repos $Repos -Selected $selected -Cursor $cursor -Top $top | |
| $key = [Console]::ReadKey($true) | |
| if ($key.Key -eq [ConsoleKey]::UpArrow -and $cursor -gt 0) { | |
| $cursor -= 1 | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::DownArrow -and $cursor -lt ($Repos.Count - 1)) { | |
| $cursor += 1 | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::Spacebar) { | |
| $selected[$cursor] = -not $selected[$cursor] | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::A) { | |
| for ($i = 0; $i -lt $selected.Length; $i++) { | |
| $selected[$i] = $true | |
| } | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::N) { | |
| for ($i = 0; $i -lt $selected.Length; $i++) { | |
| $selected[$i] = $false | |
| } | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::R) { | |
| Toggle-SelectionByFlag -Repos $Repos -Selected $selected -FlagName "archived" | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::F) { | |
| Toggle-SelectionByFlag -Repos $Repos -Selected $selected -FlagName "fork" | |
| continue | |
| } | |
| if ($key.Key -eq [ConsoleKey]::Enter) { | |
| $result = @() | |
| for ($i = 0; $i -lt $Repos.Count; $i++) { | |
| if ($selected[$i]) { | |
| $result += $Repos[$i] | |
| } | |
| } | |
| return $result | |
| } | |
| if ($key.Key -eq [ConsoleKey]::Q -or $key.Key -eq [ConsoleKey]::Escape) { | |
| return @() | |
| } | |
| } | |
| } | |
| function Clone-Repos { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [array]$Repos, | |
| [Parameter(Mandatory = $true)] | |
| [string]$Dest, | |
| [switch]$FullClone | |
| ) | |
| if (-not (Test-Path -LiteralPath $Dest)) { | |
| New-Item -ItemType Directory -Path $Dest -Force | Out-Null | |
| } | |
| foreach ($repo in $Repos) { | |
| $target = Join-Path -Path $Dest -ChildPath $repo.name | |
| if (Test-Path -LiteralPath $target) { | |
| Write-Host ("Skipped (already exists): {0}" -f $repo.name) -ForegroundColor Yellow | |
| continue | |
| } | |
| Write-Host ("Cloning: {0}" -f $repo.name) -ForegroundColor Green | |
| if ($FullClone) { | |
| & git clone $repo.clone_url $target | |
| } | |
| else { | |
| $defaultBranchProp = $repo.PSObject.Properties["default_branch"] | |
| $defaultBranch = $null | |
| if ($null -ne $defaultBranchProp) { | |
| $defaultBranch = $defaultBranchProp.Value | |
| } | |
| if ([string]::IsNullOrWhiteSpace($defaultBranch)) { | |
| $defaultBranch = "main" | |
| } | |
| & git clone --depth 1 --single-branch --branch $defaultBranch $repo.clone_url $target | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Host ("Clone failed: {0}" -f $repo.name) -ForegroundColor Red | |
| } | |
| } | |
| } | |
| try { | |
| [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | |
| Show-Help | |
| Write-Host "" | |
| Write-Host "Starting with current CONFIG values..." -ForegroundColor DarkGray | |
| Write-Host (" Dest: {0}" -f $CONFIG.Dest) -ForegroundColor DarkGray | |
| Write-Host (" IncludeArchived: {0}" -f $CONFIG.IncludeArchived) -ForegroundColor DarkGray | |
| Write-Host (" FullClone: {0}" -f $CONFIG.FullClone) -ForegroundColor DarkGray | |
| Write-Host (" ApiTimeoutSec: {0}" -f $CONFIG.ApiTimeoutSec) -ForegroundColor DarkGray | |
| Write-Host "" | |
| $token = Get-Token | |
| $repos = @( | |
| Get-OwnedRepos -Token $token -IncludeArchived:$CONFIG.IncludeArchived -TimeoutSec $CONFIG.ApiTimeoutSec | |
| ) | |
| if ($repos.Count -eq 0) { | |
| Write-Host "No repositories found." | |
| exit 0 | |
| } | |
| $selectedRepos = @(Select-ReposTui -Repos $repos) | |
| if ($selectedRepos.Count -eq 0) { | |
| Write-Host "No selection made or operation cancelled." | |
| exit 0 | |
| } | |
| Write-Host ("Selected repositories: {0}" -f $selectedRepos.Count) | |
| Clone-Repos -Repos $selectedRepos -Dest $CONFIG.Dest -FullClone:$CONFIG.FullClone | |
| Write-Host "Done." | |
| } | |
| catch { | |
| Write-Host ("Error: {0}" -f $_.Exception.Message) -ForegroundColor Red | |
| exit 1 | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment