Created
April 15, 2026 18:19
-
-
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.
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 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