Skip to content

Instantly share code, notes, and snippets.

@dominik-ba
Last active October 7, 2026 20:05
Show Gist options
  • Select an option

  • Save dominik-ba/c4c5a0e97777d8393eedc4e0a50de7f7 to your computer and use it in GitHub Desktop.

Select an option

Save dominik-ba/c4c5a0e97777d8393eedc4e0a50de7f7 to your computer and use it in GitHub Desktop.
move repos
<#
.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"
#!/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