Last active
October 7, 2026 20:05
-
-
Save dominik-ba/c4c5a0e97777d8393eedc4e0a50de7f7 to your computer and use it in GitHub Desktop.
move repos
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
| <# | |
| .SYNOPSIS | |
| Inventories Git repositories' origin remotes or clones repositories from an inventory CSV. | |
| .DESCRIPTION | |
| By default, checks immediate subfolders of RootFolder for Git repositories with an | |
| origin remote and writes their paths, folder names, and origin URLs to a CSV file. | |
| Use -Clone to read an existing inventory CSV and clone its origin repositories into | |
| RootFolder. Existing destination folders are skipped. | |
| .PARAMETER RootFolder | |
| Folder whose immediate subfolders are inventoried, or destination folder for cloning. | |
| .PARAMETER OutputFile | |
| CSV file to write, or inventory CSV to read with -Clone. Defaults to | |
| git-remotes.csv in RootFolder. | |
| .PARAMETER Clone | |
| Clone repositories listed in the inventory CSV instead of generating an inventory. | |
| .PARAMETER Help | |
| Display this help text. The alias -h is also supported; PowerShell's -? help syntax | |
| is supported as well. | |
| .EXAMPLE | |
| .\git-remote-inventory.ps1 "C:\Repos" | |
| Creates C:\Repos\git-remotes.csv from immediate child repositories with an origin. | |
| .EXAMPLE | |
| .\git-remote-inventory.ps1 "D:\Repos" -Clone -OutputFile "D:\Transfer\git-remotes.csv" | |
| Clones origin repositories listed in the CSV into D:\Repos, skipping existing folders. | |
| .EXAMPLE | |
| .\git-remote-inventory.ps1 -Help | |
| Displays this help. | |
| #> | |
| param( | |
| [Parameter(Position = 0)] | |
| [string]$RootFolder, | |
| [Parameter(Position = 1)] | |
| [string]$OutputFile, | |
| [switch]$Clone, | |
| [Alias('h')] | |
| [switch]$Help | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| if ($Help) { | |
| Get-Help -Name $PSCommandPath -Detailed | Out-String | Write-Output | |
| return | |
| } | |
| if ([string]::IsNullOrWhiteSpace($RootFolder)) { | |
| throw "RootFolder is required. Run '$($MyInvocation.MyCommand.Name) -Help' for usage." | |
| } | |
| if (-not (Test-Path -LiteralPath $RootFolder -PathType Container)) { | |
| throw "Folder not found: $RootFolder" | |
| } | |
| $resolvedRoot = (Resolve-Path -LiteralPath $RootFolder).Path | |
| if ([string]::IsNullOrWhiteSpace($OutputFile)) { | |
| $OutputFile = Join-Path $resolvedRoot 'git-remotes.csv' | |
| } | |
| if ($Clone) { | |
| if (-not (Test-Path -LiteralPath $OutputFile -PathType Leaf)) { | |
| throw "Inventory CSV not found: $OutputFile" | |
| } | |
| $rows = Import-Csv -LiteralPath $OutputFile | |
| $cloneCount = 0 | |
| $skipCount = 0 | |
| foreach ($row in $rows) { | |
| if ($row.RemoteName -ne 'origin' -or [string]::IsNullOrWhiteSpace($row.RemoteUrl)) { | |
| continue | |
| } | |
| if ([string]::IsNullOrWhiteSpace($row.RepositoryName) -or | |
| $row.RepositoryName -in @('.', '..') -or | |
| $row.RepositoryName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 -or | |
| $row.RepositoryName.Contains('/') -or | |
| $row.RepositoryName.Contains('\')) { | |
| throw "Invalid repository name in inventory CSV: '$($row.RepositoryName)'" | |
| } | |
| $destination = Join-Path $resolvedRoot $row.RepositoryName | |
| if (Test-Path -LiteralPath $destination) { | |
| Write-Warning "Skipping '$($row.RepositoryName)': destination already exists: $destination" | |
| $skipCount++ | |
| continue | |
| } | |
| & git clone -- $row.RemoteUrl $destination | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Failed to clone '$($row.RepositoryName)' from '$($row.RemoteUrl)'" | |
| } | |
| $cloneCount++ | |
| } | |
| Write-Output "Cloned $cloneCount repositories into $resolvedRoot; skipped $skipCount existing destinations" | |
| return | |
| } | |
| $rows = [System.Collections.Generic.List[object]]::new() | |
| $repositoryCount = 0 | |
| foreach ($folder in Get-ChildItem -LiteralPath $resolvedRoot -Directory -Force) { | |
| $repositoryPath = $folder.FullName | |
| $topLevelOutput = & git -C $repositoryPath rev-parse --show-toplevel 2>$null | |
| if ($LASTEXITCODE -ne 0 -or -not $topLevelOutput) { | |
| continue | |
| } | |
| $topLevel = [System.IO.Path]::GetFullPath(($topLevelOutput | Select-Object -First 1).Trim()) | |
| $normalizedRepositoryPath = [System.IO.Path]::GetFullPath($repositoryPath) | |
| if (-not [string]::Equals( | |
| $topLevel.TrimEnd([char[]]@('\', '/')), | |
| $normalizedRepositoryPath.TrimEnd([char[]]@('\', '/')), | |
| [System.StringComparison]::OrdinalIgnoreCase | |
| )) { | |
| continue | |
| } | |
| $repositoryCount++ | |
| $originUrl = & git -C $repositoryPath remote get-url origin 2>$null | |
| if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($originUrl | Select-Object -First 1))) { | |
| continue | |
| } | |
| $rows.Add([pscustomobject]@{ | |
| RepositoryPath = $repositoryPath | |
| RepositoryName = $folder.Name | |
| RemoteName = 'origin' | |
| RemoteUrl = ($originUrl | Select-Object -First 1).Trim() | |
| }) | |
| } | |
| $rows | Export-Csv -LiteralPath $OutputFile -NoTypeInformation -Encoding UTF8 | |
| Write-Output "Found $repositoryCount repositories; wrote $($rows.Count) origin entries to $OutputFile" |
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
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| show_help() { | |
| cat <<EOF | |
| Git remote inventory and clone utility | |
| Purpose: | |
| Generate a CSV inventory of immediate child Git repositories that have an | |
| 'origin' remote, or clone origin repositories listed in an existing inventory. | |
| Inventory generation and cloning are separate operations. | |
| Usage: | |
| $0 [-h|--help] | |
| $0 ROOT_FOLDER [OUTPUT_CSV] | |
| $0 --clone ROOT_FOLDER [INPUT_CSV] | |
| Arguments: | |
| ROOT_FOLDER For inventory, folder whose immediate subfolders are checked. | |
| For cloning, destination folder for the cloned repositories. | |
| OUTPUT_CSV Optional inventory output path. Defaults to | |
| ROOT_FOLDER/git-remotes.csv. | |
| INPUT_CSV Optional inventory input path for --clone. Defaults to | |
| ROOT_FOLDER/git-remotes.csv. | |
| Options: | |
| --clone Clone the origin remotes listed in the CSV instead of generating | |
| an inventory. Existing destination folders are skipped. | |
| -h, --help Display this help text. | |
| Examples: | |
| $0 "/path/to/repos" | |
| Write /path/to/repos/git-remotes.csv. | |
| $0 "/path/to/repos" "/path/to/transfer/git-remotes.csv" | |
| Write the inventory to the specified CSV. | |
| $0 --clone "/path/to/repos" "/path/to/transfer/git-remotes.csv" | |
| Clone listed repositories into /path/to/repos, skipping existing folders. | |
| Requirements: | |
| Bash and Git. No Python dependency is required. | |
| EOF | |
| } | |
| usage_error() { | |
| printf 'Invalid usage.\n\n' >&2 | |
| show_help >&2 | |
| exit 2 | |
| } | |
| if [[ ${1:-} == -h || ${1:-} == --help ]]; then | |
| show_help | |
| exit 0 | |
| fi | |
| clone_mode=0 | |
| if [[ ${1:-} == --clone ]]; then | |
| clone_mode=1 | |
| shift | |
| fi | |
| [[ $# -ge 1 && $# -le 2 ]] || usage_error | |
| root=$1 | |
| if [[ ! -d "$root" ]]; then | |
| printf 'Folder not found: %s\n' "$root" >&2 | |
| exit 1 | |
| fi | |
| root=$(cd "$root" && pwd -P) | |
| if (( clone_mode )); then | |
| csv_file=${2:-"$root/git-remotes.csv"} | |
| if [[ ! -f "$csv_file" ]]; then | |
| printf 'Inventory CSV not found: %s\n' "$csv_file" >&2 | |
| exit 1 | |
| fi | |
| clone_entries=() | |
| header_seen=0 | |
| csv_fields=() | |
| csv_field='' | |
| in_quotes=0 | |
| while IFS= read -r csv_line || [[ -n "$csv_line" ]]; do | |
| if (( ! header_seen && ${#csv_fields[@]} == 0 )) && | |
| [[ "$csv_line" == $'\xef\xbb\xbf'* ]]; then | |
| csv_line=${csv_line#$'\xef\xbb\xbf'} | |
| fi | |
| [[ "$csv_line" != *$'\r' ]] || csv_line=${csv_line%$'\r'} | |
| for ((char_index = 0; char_index < ${#csv_line}; char_index++)); do | |
| char=${csv_line:char_index:1} | |
| if (( in_quotes )); then | |
| if [[ "$char" == '"' ]]; then | |
| if [[ ${csv_line:char_index+1:1} == '"' ]]; then | |
| csv_field+='"' | |
| char_index=$((char_index + 1)) | |
| else | |
| in_quotes=0 | |
| fi | |
| else | |
| csv_field+=$char | |
| fi | |
| else | |
| case "$char" in | |
| '"') | |
| if [[ -n "$csv_field" ]]; then | |
| printf 'Malformed CSV: unexpected quote in an unquoted field.\n' >&2 | |
| exit 1 | |
| fi | |
| in_quotes=1 | |
| ;; | |
| ',') | |
| csv_fields+=("$csv_field") | |
| csv_field='' | |
| ;; | |
| *) | |
| csv_field+=$char | |
| ;; | |
| esac | |
| fi | |
| done | |
| if (( in_quotes )); then | |
| csv_field+=$'\n' | |
| continue | |
| fi | |
| csv_fields+=("$csv_field") | |
| csv_field='' | |
| if (( ! header_seen )); then | |
| if (( ${#csv_fields[@]} == 4 )) && | |
| [[ ${csv_fields[0]} == RepositoryPath && | |
| ${csv_fields[1]:-} == RepositoryName && | |
| ${csv_fields[2]:-} == RemoteName && | |
| ${csv_fields[3]:-} == RemoteUrl ]]; then | |
| header_seen=1 | |
| else | |
| printf 'Unrecognized inventory CSV header in: %s\n' "$csv_file" >&2 | |
| exit 1 | |
| fi | |
| elif (( ${#csv_fields[@]} != 4 )); then | |
| printf 'Malformed CSV row: expected 4 columns, found %s.\n' "${#csv_fields[@]}" >&2 | |
| exit 1 | |
| elif [[ ${csv_fields[2]} == origin && -n ${csv_fields[3]} ]]; then | |
| repository_name=${csv_fields[1]} | |
| if [[ -z "$repository_name" || "$repository_name" == . || "$repository_name" == .. || | |
| "$repository_name" == */* || "$repository_name" == *\\* ]]; then | |
| printf 'Invalid repository name in inventory CSV: %q\n' "$repository_name" >&2 | |
| exit 1 | |
| fi | |
| clone_entries+=("$repository_name" "${csv_fields[3]}") | |
| fi | |
| csv_fields=() | |
| done < "$csv_file" | |
| if (( in_quotes )); then | |
| printf 'Malformed CSV: unterminated quoted field in %s\n' "$csv_file" >&2 | |
| exit 1 | |
| fi | |
| if (( ! header_seen )); then | |
| printf 'Inventory CSV is empty or has no header: %s\n' "$csv_file" >&2 | |
| exit 1 | |
| fi | |
| clone_count=0 | |
| skip_count=0 | |
| for ((entry_index = 0; entry_index < ${#clone_entries[@]}; entry_index += 2)); do | |
| repository_name=${clone_entries[entry_index]} | |
| remote_url=${clone_entries[entry_index + 1]} | |
| destination="$root/$repository_name" | |
| if [[ -e "$destination" ]]; then | |
| printf 'Skipping %s: clone destination already exists: %s\n' "$repository_name" "$destination" >&2 | |
| skip_count=$((skip_count + 1)) | |
| continue | |
| fi | |
| if ! git clone -- "$remote_url" "$destination"; then | |
| printf "Failed to clone '%s' from '%s'\n" "$repository_name" "$remote_url" >&2 | |
| exit 1 | |
| fi | |
| clone_count=$((clone_count + 1)) | |
| done | |
| printf 'Cloned %s repositories into %s; skipped %s existing destinations\n' "$clone_count" "$root" "$skip_count" | |
| exit 0 | |
| fi | |
| output=${2:-"$root/git-remotes.csv"} | |
| csv_quote() { | |
| local value=${1//\"/\"\"} | |
| printf '"%s"' "$value" | |
| } | |
| printf '"RepositoryPath","RepositoryName","RemoteName","RemoteUrl"\n' > "$output" | |
| repository_count=0 | |
| row_count=0 | |
| while IFS= read -r -d '' repository_path; do | |
| top_level=$(git -C "$repository_path" rev-parse --show-toplevel 2>/dev/null) || continue | |
| top_level=$(cd "$top_level" && pwd -P) | |
| [[ "$top_level" == "$repository_path" ]] || continue | |
| repository_count=$((repository_count + 1)) | |
| repository_name=${repository_path##*/} | |
| remote_url=$(git -C "$repository_path" remote get-url origin 2>/dev/null) || continue | |
| [[ -n "$remote_url" ]] || continue | |
| { | |
| csv_quote "$repository_path"; printf ',' | |
| csv_quote "$repository_name"; printf ',' | |
| csv_quote origin; printf ',' | |
| csv_quote "$remote_url"; printf '\n' | |
| } >> "$output" | |
| row_count=$((row_count + 1)) | |
| done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -print0) | |
| printf 'Found %s repositories; wrote %s origin entries to %s\n' "$repository_count" "$row_count" "$output" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment