Skip to content

Instantly share code, notes, and snippets.

@imsmith
Created April 15, 2026 18:19
Show Gist options
  • Select an option

  • Save imsmith/3f3cfa1033311e27bb638bb67330d13f to your computer and use it in GitHub Desktop.

Select an option

Save imsmith/3f3cfa1033311e27bb638bb67330d13f to your computer and use it in GitHub Desktop.
a tcl script that produces a report that tells you the status of each repository in a directory of repositories with respect to github -- does the repo exist in both places? if so, are they in sync? etc.
#!/usr/bin/env tclsh
#
# repo-report.tcl — Compare ~/github with GitHub remote repos
# Requires: gh (authenticated), git, tclsh 8.6+
#
package require Tcl 8.6
set LOCAL_DIR [file normalize "~/github"]
set GH_USER "imsmith"
# ── ANSI colors ──────────────────────────────────────────────────────
proc color {code text} { return "\033\[${code}m${text}\033\[0m" }
proc bold {t} { color "1" $t }
proc red {t} { color "1;31" $t }
proc green {t} { color "1;32" $t }
proc yellow {t} { color "1;33" $t }
proc cyan {t} { color "1;36" $t }
proc dim {t} { color "2" $t }
# ── Helpers ──────────────────────────────────────────────────────────
proc run_quiet {args} {
try {
set result [exec {*}$args 2>/dev/null]
} on error {msg} {
set result ""
}
return $result
}
proc truncate {str maxlen} {
if {[string length $str] <= $maxlen} { return $str }
return "[string range $str 0 $maxlen-4]..."
}
proc format_date {iso} {
if {[regexp {^(\d{4}-\d{2}-\d{2})} $iso -> date]} { return $date }
return $iso
}
proc hr {} { puts [dim [string repeat "\u2500" 90]] }
# ── Gather remote repos from GitHub (tab-separated via jq) ──────────
puts stderr [dim "Fetching repos from GitHub..."]
set jq_expr {.[] | [.name, .description, (if .isFork then "yes" else "no" end), .pushedAt] | @tsv}
set gh_raw [run_quiet gh repo list $GH_USER --limit 500 --json name,description,isFork,pushedAt --jq $jq_expr]
set remote_dict [dict create]
foreach line [split $gh_raw "\n"] {
set line [string trim $line]
if {$line eq ""} continue
set parts [split $line "\t"]
set name [lindex $parts 0]
set desc [lindex $parts 1]
set fork [lindex $parts 2]
set pushed [lindex $parts 3]
dict set remote_dict $name [dict create \
name $name description $desc is_fork $fork pushed $pushed]
}
set remote_names [lsort [dict keys $remote_dict]]
# ── Gather local repos from ~/github ─────────────────────────────────
puts stderr [dim "Scanning local repos..."]
set local_dict [dict create]
foreach entry [lsort [glob -nocomplain -tails -directory $LOCAL_DIR *]] {
set full [file join $LOCAL_DIR $entry]
if {![file isdirectory $full]} continue
set is_git [file isdirectory [file join $full .git]]
set d [dict create name $entry is_git $is_git]
if {$is_git} {
set last_commit [run_quiet git -C $full log -1 --format=%aI]
dict set d last_commit [format_date $last_commit]
set remote_url [run_quiet git -C $full remote get-url origin]
dict set d remote_url $remote_url
set gh_name ""
if {[regexp {github\.com[:/]([^/]+)/([^/.]+)} $remote_url -> owner repo_name]} {
set gh_name $repo_name
}
dict set d gh_name $gh_name
} else {
dict set d last_commit ""
dict set d remote_url ""
dict set d gh_name ""
}
dict set local_dict $entry $d
}
set local_names [lsort [dict keys $local_dict]]
# ── Classify repos ──────────────────────────────────────────────────
set local_only {}
set remote_only {}
set both {}
dict for {lname linfo} $local_dict {
set is_git [dict get $linfo is_git]
set gh_name [dict get $linfo gh_name]
if {!$is_git} {
lappend local_only $lname
} elseif {$gh_name eq "" || ![dict exists $remote_dict $gh_name]} {
if {[dict exists $remote_dict $lname]} {
lappend both [list $lname $lname]
} else {
lappend local_only $lname
}
} else {
lappend both [list $lname $gh_name]
}
}
set matched_remotes [dict create]
foreach pair $both {
dict set matched_remotes [lindex $pair 1] 1
}
foreach rname $remote_names {
if {![dict exists $matched_remotes $rname]} {
lappend remote_only $rname
}
}
# ── Sync check ──────────────────────────────────────────────────────
puts stderr [dim "Checking sync status (fetching from remotes)..."]
set sync_results {}
foreach pair $both {
lassign $pair local_name remote_name
set local_path [file join $LOCAL_DIR $local_name]
set linfo [dict get $local_dict $local_name]
run_quiet git -C $local_path fetch origin
set local_head [run_quiet git -C $local_path rev-parse HEAD]
# Find default branch
set default_branch [run_quiet git -C $local_path symbolic-ref refs/remotes/origin/HEAD]
regsub {^refs/remotes/origin/} $default_branch "" branch_name
if {$branch_name eq ""} {
foreach b {main master} {
set test [run_quiet git -C $local_path rev-parse origin/$b]
if {$test ne ""} { set branch_name $b; break }
}
}
if {$branch_name eq ""} { set branch_name "main" }
set remote_head [run_quiet git -C $local_path rev-parse origin/$branch_name]
set ahead 0
set behind 0
set status "synced"
if {$local_head ne "" && $remote_head ne ""} {
if {$local_head eq $remote_head} {
set status "synced"
} else {
set ahead [run_quiet git -C $local_path rev-list --count origin/$branch_name..HEAD]
set behind [run_quiet git -C $local_path rev-list --count HEAD..origin/$branch_name]
if {$ahead eq ""} { set ahead 0 }
if {$behind eq ""} { set behind 0 }
if {$ahead > 0 && $behind > 0} {
set status "diverged"
} elseif {$ahead > 0} {
set status "ahead"
} elseif {$behind > 0} {
set status "behind"
}
}
} else {
set status "unknown"
}
set rinfo [dict get $remote_dict $remote_name]
lappend sync_results [dict create \
local_name $local_name \
remote_name $remote_name \
status $status \
ahead $ahead \
behind $behind \
description [dict get $rinfo description] \
is_fork [dict get $rinfo is_fork] \
pushed [format_date [dict get $rinfo pushed]] \
local_commit [dict get $linfo last_commit] \
]
}
# ── Output Report ────────────────────────────────────────────────────
puts ""
puts [bold " REPO SYNC REPORT \u2014 [clock format [clock seconds] -format {%Y-%m-%d %H:%M}]"]
puts [dim " user: $GH_USER local: $LOCAL_DIR"]
puts ""
# ── Section 1: Local Only ────────────────────────────────────────────
set n [llength $local_only]
puts [bold [red " \u25a0 LOCAL ONLY ($n) \u2014 not published to GitHub"]]
hr
if {$n == 0} {
puts " (none)"
} else {
puts [dim [format " %-35s %-12s %s" "NAME" "LAST COMMIT" "TYPE"]]
hr
foreach lname [lsort $local_only] {
set linfo [dict get $local_dict $lname]
set is_git [dict get $linfo is_git]
set lc [dict get $linfo last_commit]
if {$is_git} {
set type "git repo"
} else {
set type [yellow "directory"]
}
puts [format " %-35s %-12s %s" $lname $lc $type]
}
}
puts ""
# ── Section 2: Remote Only ───────────────────────────────────────────
set n [llength $remote_only]
puts [bold [cyan " \u25a0 REMOTE ONLY ($n) \u2014 not cloned to ~/github"]]
hr
if {$n == 0} {
puts " (none)"
} else {
puts [dim [format " %-30s %-5s %-12s %s" "NAME" "FORK?" "PUSHED" "DESCRIPTION"]]
hr
foreach rname [lsort $remote_only] {
set rinfo [dict get $remote_dict $rname]
set desc [truncate [dict get $rinfo description] 40]
set is_fork [dict get $rinfo is_fork]
set pushed [format_date [dict get $rinfo pushed]]
puts [format " %-30s %-5s %-12s %s" $rname $is_fork $pushed $desc]
}
}
puts ""
# ── Section 3: Sync Status ──────────────────────────────────────────
set out_of_sync {}
set synced {}
foreach sr $sync_results {
if {[dict get $sr status] eq "synced"} {
lappend synced $sr
} else {
lappend out_of_sync $sr
}
}
set n [llength $out_of_sync]
puts [bold [yellow " \u25a0 OUT OF SYNC ($n)"]]
hr
if {$n == 0} {
puts " (none \u2014 all matched repos are in sync)"
} else {
puts [dim [format " %-28s %-5s %-10s %-10s %-9s %s" "NAME" "FORK?" "LOCAL" "REMOTE" "STATUS" "DESCRIPTION"]]
hr
set sorted_oos [lsort -command {apply {{a b} {
string compare [dict get $a local_name] [dict get $b local_name]
}}} $out_of_sync]
foreach sr $sorted_oos {
set name [dict get $sr local_name]
set is_fork [dict get $sr is_fork]
set lc [dict get $sr local_commit]
set rc [dict get $sr pushed]
set st [dict get $sr status]
set a [dict get $sr ahead]
set b [dict get $sr behind]
set desc [truncate [dict get $sr description] 30]
switch $st {
ahead { set stxt [green "\u2191$a ahead"] }
behind { set stxt [red "\u2193$b behind"] }
diverged { set stxt [red "\u2191$a \u2193$b"] }
default { set stxt [dim $st] }
}
puts [format " %-28s %-5s %-10s %-10s %-20s %s" \
$name $is_fork $lc $rc $stxt $desc]
}
}
puts ""
set n [llength $synced]
puts [bold [green " \u25a0 IN SYNC ($n)"]]
hr
if {$n == 0} {
puts " (none)"
} else {
puts [dim [format " %-30s %-5s %-12s %s" "NAME" "FORK?" "PUSHED" "DESCRIPTION"]]
hr
set sorted_synced [lsort -command {apply {{a b} {
string compare [dict get $a local_name] [dict get $b local_name]
}}} $synced]
foreach sr $sorted_synced {
set name [dict get $sr local_name]
set is_fork [dict get $sr is_fork]
set rc [dict get $sr pushed]
set desc [truncate [dict get $sr description] 40]
puts [format " %-30s %-5s %-12s %s" $name $is_fork $rc $desc]
}
}
puts ""
# ── Summary ──────────────────────────────────────────────────────────
hr
set total [expr {[llength $local_only] + [llength $remote_only] + [llength $sync_results]}]
puts [bold " SUMMARY"]
puts " Total unique repos: $total"
puts " Local only: [llength $local_only]"
puts " Remote only: [llength $remote_only]"
puts " In both: [llength $sync_results]"
puts " In sync: [llength $synced]"
puts " Out of sync: [llength $out_of_sync]"
hr
puts ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment