1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
|
#!/usr/bin/env bash
# repos: one line per local repo under ~/code/<bucket>/<repo>, showing where
# origin lives, the last commit, and anything that needs attention.
#
# repos every repo
# repos <name> only repos whose folder contains <name> (repos tui, repos alethi/)
# repos -p only repos with a problem (flags starting with !)
# repos -f fetch every repo's origin first (network)
# repos -g include <repo>-github fallback clones (hidden by default,
# except when flagged with a ! problem or named in <name>)
# repos -h this help, plus what every flag means
#
# In a terminal the table is colored and followed by a short legend for the
# flags it shows; piped output is the plain table. NO_COLOR turns color off.
# REPOS_ROOT overrides ~/code.
set -uo pipefail
# flag_help: one plain-English line per flag, used by the legend and -h.
# Flags starting with ! are problems; the rest are just information.
flag_help() {
cat <<'EOF'
dirty:N|N files changed but not committed
unpushed:N|N commits saved only on this machine, not on the server yet
wt:N|N extra worktrees (side-by-side checkouts) inside this repo
behind:N|this GitHub backup copy is N commits old; git pull before using it
personal-ns|company repo stored under your personal account, not alethi-dev
!unsynced|this GitHub backup has commits GitStack lacks; run gs repo pull-github in the main copy
!orphan|GitHub backup copy with no main copy next to it
!company-code|company repo sitting in personal/; move it to alethi/ or mynqs/
!not-oss|your own or company repo sitting in oss/
!no-remote|personal/ repo with no server copy; push it somewhere or move it to scratch/
!has-remote|scratch/ repo that has a server copy; move it to personal/
!personal-email|company repo would commit with your personal email
!company-email|personal repo would commit with a non-personal email
!wt-outside|a worktree of this repo lives outside its folder
EOF
}
# print_flags prints flag_help lines as an aligned list; with names given,
# only those flags (matched on the part before ":").
print_flags() {
flag_help | awk -F '|' -v want=" $* " -v color="$color" '
{ name = $1; base = name; sub(/:.*/, "", base) }
want != " " && index(want, " " base " ") == 0 { next }
{
label = color ? (base ~ /^!/ ? "\033[1;31m" : "\033[33m") name "\033[0m" : name
pad = ""; for (i = length(name); i < 16; i++) pad = pad " "
print " " label pad $2
}'
}
ROOT="${REPOS_ROOT:-$HOME/code}"
COMPANY_OWNERS=" alethi-dev alethi-co mynqs "
PERSONAL_OWNER="grindlemire"
GLOBAL_EMAIL="$(git config --global user.email 2>/dev/null || true)"
color=0
[ -t 1 ] && [ -z "${NO_COLOR:-}" ] && color=1
# parse_remote sets host and slug (owner/repo) from a remote URL.
parse_remote() {
local u="${1%.git}" rest
case "$u" in
"") host=""; slug="" ;;
git@*) rest="${u#git@}"; host="${rest%%:*}"; slug="${rest#*:}" ;;
*://*) rest="${u#*://}"; rest="${rest#*@}"; host="${rest%%/*}"; slug="${rest#*/}" ;;
*) host="path"; slug="$u" ;;
esac
}
where_of() {
case "$1" in
"") echo "none" ;;
github.com) echo "github" ;;
gitstack.sh) echo "gitstack" ;;
localhost*) echo "sandbox" ;;
*) echo "$1" ;;
esac
}
count() { wc -l | tr -d ' '; }
default_branch() {
local b
b="$(git -C "$1" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)" && { echo "${b#origin/}"; return; }
for b in main master develop; do
git -C "$1" show-ref --verify --quiet "refs/remotes/origin/$b" && { echo "$b"; return; }
done
}
# row prints one tab-separated table row for the repo at $1. The main run
# calls this script back as `repos --row <path>`, many at once, because each
# repo costs a dozen small git processes and running them in turn is slow.
row() {
local repo="$1" rel bucket name host slug where owner last flags unpushed
local dirty wts email primary tip branch behind
rel="${repo#"$ROOT"/}"
bucket="${rel%%/*}"
name="${rel##*/}"
[ "${REPOS_FETCH:-0}" = 1 ] && git -C "$repo" fetch --quiet origin 2>/dev/null
parse_remote "$(git -C "$repo" remote get-url origin 2>/dev/null || true)"
where="$(where_of "$host")"
owner="${slug%%/*}"
last="$(git -C "$repo" log -1 --format=%cs 2>/dev/null || true)"
flags=""
add() { flags="$flags $1"; }
unpushed="$(git -C "$repo" log --oneline --branches --not --remotes 2>/dev/null | count)"
[ "$unpushed" != 0 ] && add "unpushed:$unpushed"
if [ "$bucket" != archive ]; then
dirty="$(git -C "$repo" status --porcelain 2>/dev/null | count)"
[ "$dirty" != 0 ] && add "dirty:$dirty"
wts="$(git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | tail -n +2)"
if [ -n "$wts" ]; then
add "wt:$(printf '%s\n' "$wts" | count)"
printf '%s\n' "$wts" | grep -qv "^$repo/" && add "!wt-outside"
fi
email="$(git -C "$repo" config user.email 2>/dev/null || true)"
case "$bucket" in
alethi|mynqs)
[ "$owner" = "$PERSONAL_OWNER" ] && add "personal-ns"
[ -n "$GLOBAL_EMAIL" ] && [ "$email" = "$GLOBAL_EMAIL" ] && add "!personal-email"
;;
personal)
case "$COMPANY_OWNERS" in *" $owner "*) [ -n "$owner" ] && add "!company-code" ;; esac
[ "$where" = none ] && add "!no-remote"
[ -n "$GLOBAL_EMAIL" ] && [ "$email" != "$GLOBAL_EMAIL" ] && add "!company-email"
;;
oss)
case "$COMPANY_OWNERS $PERSONAL_OWNER " in *" $owner "*) [ -n "$owner" ] && add "!not-oss" ;; esac
;;
scratch)
[ "$where" != none ] && add "!has-remote"
;;
esac
# A <repo>-github folder is the GitHub fallback clone of <repo> next to it.
case "$name" in
*-github)
primary="${repo%-github}"
if [ ! -d "$primary/.git" ]; then
add "!orphan"
else
tip="$(git -C "$repo" rev-parse HEAD 2>/dev/null)"
branch="$(git -C "$repo" branch --show-current 2>/dev/null)"
[ -z "$branch" ] && branch="$(default_branch "$primary")"
if ! git -C "$primary" cat-file -e "$tip^{commit}" 2>/dev/null; then
add "!unsynced"
elif git -C "$primary" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
behind="$(git -C "$primary" rev-list --count "$tip..origin/$branch" 2>/dev/null || echo 0)"
[ "$behind" != 0 ] && add "behind:$behind"
fi
fi
;;
esac
fi
flags="${flags# }"
printf '%s\t%s\t%s\t%s\t%s\n' "$rel" "$where" "${slug:--}" "${last:--}" "${flags:--}"
}
if [ "${1:-}" = --row ]; then
row "$2"
exit 0
fi
problems_only=0
fetch=0
fallbacks=0
while getopts "pfgh" opt; do
case $opt in
p) problems_only=1 ;;
f) fetch=1 ;;
g) fallbacks=1 ;;
*)
sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
echo
echo "Flags (! means a problem, the rest are information):"
print_flags
exit 0
;;
esac
done
shift $((OPTIND - 1))
filter="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
repos_list="$(find "$ROOT" -mindepth 2 -maxdepth 4 \
\( -name .worktrees -o -name node_modules -o -name .claude \) -prune -o \
-name .git -type d -print -prune 2>/dev/null | sed 's|/\.git$||')"
if [ -n "$filter" ]; then
repos_list="$(printf '%s\n' "$repos_list" | awk -v root="$ROOT/" -v f="$filter" \
'{ rel = substr($0, length(root) + 1) } index(tolower(rel), f)')"
fi
matched=0
[ -n "$repos_list" ] && matched=1
rows="$(printf '%s\n' "$repos_list" | grep . | REPOS_FETCH="$fetch" xargs -P 8 -I{} "$0" --row {} | sort)"
# Fallback clones are noise day to day. They still show when asked for (-g, or
# a filter naming -github) or when flagged with a problem such as !unsynced,
# which means a fix exists only on GitHub.
case "$filter" in *-github*) fallbacks=1 ;; esac
[ "$fallbacks" = 0 ] && rows="$(printf '%s\n' "$rows" | awk -F '\t' 'NF && ($1 !~ /-github$/ || $5 ~ /!/)')"
[ "$problems_only" = 1 ] && rows="$(printf '%s\n' "$rows" | awk -F '\t' '$5 ~ /!/')"
[ -n "$rows" ] && rows="$rows
"
if [ "$matched" = 0 ] && [ -n "$filter" ]; then
echo "no repo under $ROOT matches '$1'" >&2
exit 1
fi
# Align columns on the plain text, then color each cell, so escape codes never
# throw off the widths.
{
printf 'REPO\tWHERE\tREMOTE\tLAST\tFLAGS\n'
printf '%s' "$rows"
} | awk -F '\t' -v color="$color" '
function c(code, s) { return color ? "\033[" code "m" s "\033[0m" : s }
function pad(n, s) { s = ""; while (n-- > 0) s = s " "; return s }
function paint(col, s, row, i, n, t, out, parts) {
if (row == 1) return c("1", s)
if (col == 1) { i = index(s, "/"); return i ? c("2", substr(s, 1, i)) substr(s, i + 1) : s }
if (col == 2) return s == "gitstack" ? c("32", s) : s == "github" ? c("34", s) : c("2", s)
if (col == 4) return c("2", s)
if (col == 5) {
if (s == "-") return c("2", s)
n = split(s, parts, " ")
for (i = 1; i <= n; i++) {
t = parts[i]
if (t ~ /^!/) t = c("1;31", t)
else if (t ~ /^(dirty|unpushed|behind):/) t = c("33", t)
else t = c("2", t)
out = out (i > 1 ? " " : "") t
}
return out
}
return s
}
{
for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) }
if (NF > nf) nf = NF
}
END {
for (r = 1; r <= NR; r++) {
line = ""
for (i = 1; i <= nf; i++) {
line = line paint(i, cell[r, i], r)
if (i < nf) line = line pad(w[i] - length(cell[r, i]) + 2)
}
print line
}
}'
# Legend for the flags this table shows, only when a person is reading.
if [ -t 1 ]; then
shown="$(printf '%s' "$rows" | cut -f5 | tr ' ' '\n' | sed 's/:.*//' | grep -v '^-*$' | sort -u | tr '\n' ' ')"
if [ -n "$shown" ]; then
echo
print_flags $shown
fi
fi
|