feat(repo-sync): one local tree for every repo reachable on your forges

bin/repo-sync ports the Alphalink dotfiles repo/repo-reset zsh functions to
bash + zsh on Linux and macOS, for several forges at once: GitLab, GitHub
(+GHES), Gitea/Forgejo, Bitbucket Cloud, via curl + jq (no per-forge CLI).
Tokens live in ~/.config/repos/forges.conf (0600, parsed line by line, never
tracked), written by `repo-sync add`. Cache ~/.cache/repos/list refreshed
at most once a day (mkdir lock: flock is not on macOS), same namespace/project
on two forges kept once, archived and mirrors skipped. Layout
~/repos/<namespace with / as @>/<project>.

rc files: `repo <project> [namespace]` clones on demand and cd's, with
completion (bash compgen, zsh _describe), background refresh at shell start.
install.sh: jq in the apt and brew lists, chmod repo-sync.

Verified: shellcheck + bash -n, zsh -n; stub-curl harness over the four forges
(pagination, dedup, auth headers, lock, stale cache, path/clone); live GitLab
refresh (139 projects, 3s).
This commit is contained in:
bchanot
2026-10-07 15:31:03 +02:00
parent 2573756e73
commit f42e28807b
9 changed files with 451 additions and 4 deletions
+3
View File
@@ -114,3 +114,6 @@ Done: MouseDrag1Pane binding had `\"` inside '...' (literal since tmux 3.0); bra
## 2026-10-07 — tmux dim inactive panes ## 2026-10-07 — tmux dim inactive panes
Done: window-style / window-active-style added. Two traps: colour234 ≈ iTerm2 dark bg (#15191f) → invisible; `window-active-style bg=default` inherits window-style since tmux 3 → active pane dimmed too, terminal colours hardcoded instead (fg #dcdcdc, bg #15191f). Inactive settled at #353d48 / #a6acb6 after 3 live rounds. Light macOS theme would need the inverse. Merged feature/tmux-dim-inactive-panes → develop. Done: window-style / window-active-style added. Two traps: colour234 ≈ iTerm2 dark bg (#15191f) → invisible; `window-active-style bg=default` inherits window-style since tmux 3 → active pane dimmed too, terminal colours hardcoded instead (fg #dcdcdc, bg #15191f). Inactive settled at #353d48 / #a6acb6 after 3 live rounds. Light macOS theme would need the inverse. Merged feature/tmux-dim-inactive-panes → develop.
## 2026-10-07 (pm) — repo-sync: multi-forge repo tree
Done: `bin/repo-sync` (gitlab/github/gitea/bitbucket cloud via curl+jq, daily cache, mkdir lock, dedup ns/project) + `repo` fn/completion in 3 rc files + install.sh jq + docs. Port of Alphalink dotfiles `repo`/`repo-reset`. Stub-curl harness caught token shift (IFS tab merges empty field); live GitLab 139 projects in 3s. Decided (chat): name `repo-sync`, tokens in `~/.config/repos/forges.conf` 0600, root `~/repos`, Bitbucket Cloud only. On feature/repo-sync, not finished. BDR/LRN offered, not written.
+12
View File
@@ -123,3 +123,15 @@ Branch: feature/identity-prompt (off develop). Values were visible to anyone rea
- [x] vim/vimrc: g:_author / g:_email from $USER / $EMAIL - [x] vim/vimrc: g:_author / g:_email from $USER / $EMAIL
- [x] README, CLAUDE.md (CHANGELOG left to the release step, like the tmux work) - [x] README, CLAUDE.md (CHANGELOG left to the release step, like the tmux work)
- [x] Verify: shellcheck, bash -n, zsh -n; temp-HOME render (env preset, existing rc reuse); vim reads the env - [x] Verify: shellcheck, bash -n, zsh -n; temp-HOME render (env preset, existing rc reuse); vim reads the env
## Feature — repo-sync: one local tree for every reachable git repo, multi-forge (2026-10-07)
Branch: feature/repo-sync (off develop). Design approved in chat: name `repo-sync`, tokens in
`~/.config/repos/forges.conf` 0600 (never tracked), root `~/repos`, Bitbucket Cloud only.
Port of the Alphalink dotfiles `repo` / `repo-reset` zsh functions, bash + zsh, Linux + macOS.
- [x] bin/repo-sync — add / refresh (daily, mkdir lock) / list / tree / path / clone; curl + jq
fetchers for gitlab, github (+GHES), gitea/forgejo, bitbucket cloud; dedup namespace/project
- [x] bash/bashrc-linux, bash/bashrc-osx, zsh/zshrc-osx — `repo` function (cd), completion, background refresh
- [x] install.sh — jq in apt + brew lists, chmod repo-sync
- [x] README.md (table + CLI section) + CLAUDE.md layout
- [x] shellcheck + bash -n; stub-curl harness per forge (fixtures), live GitLab run
- [ ] commit on feature branch; registries. No finish without explicit signal.
+2
View File
@@ -26,6 +26,8 @@ gitconfig user-scope ~/.gitconfig template, @USER@/@EMAIL
tmux.conf tmux config (vi keys, tpm plugins) → ~/.config/tmux/tmux.conf, both OSes (tmux ≥ 3.1) tmux.conf tmux config (vi keys, tpm plugins) → ~/.config/tmux/tmux.conf, both OSes (tmux ≥ 3.1)
zsh/{zshrc-osx,bchanot.zsh-theme} macOS zsh option: oh-my-zsh zshrc + theme porting the bash prompt zsh/{zshrc-osx,bchanot.zsh-theme} macOS zsh option: oh-my-zsh zshrc + theme porting the bash prompt
bin/{dt,dtach-router,claude-provider} CLI scripts deployed to ~/.local/bin bin/{dt,dtach-router,claude-provider} CLI scripts deployed to ~/.local/bin
bin/repo-sync multi-forge repo tree (gitlab/github/gitea/bitbucket cloud) → ~/repos, daily cache;
tokens in ~/.config/repos/forges.conf (0600, never tracked); `repo` fn in the rc files
etc/profile.d/disk-usage-warning.sh login-time low-disk warning → /etc/profile.d (Linux only) etc/profile.d/disk-usage-warning.sh login-time low-disk warning → /etc/profile.d (Linux only)
etc/tmpfiles.d/tmp.conf disk-backed /tmp cleanup rules (offer: /tmp on disk) etc/tmpfiles.d/tmp.conf disk-backed /tmp cleanup rules (offer: /tmp on disk)
etc/systemd/ssh.service.d/override.conf sshd OOM-exempt drop-in (offer: SSH memory guard) etc/systemd/ssh.service.d/override.conf sshd OOM-exempt drop-in (offer: SSH memory guard)
+7
View File
@@ -36,6 +36,7 @@ curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-instal
| `bin/dt` | dtach session manager for claude-in-dtach sessions. | | `bin/dt` | dtach session manager for claude-in-dtach sessions. |
| `bin/dtach-router` | Dashboard to resume dtach sessions, shown at the start of every interactive shell (wired into `~/.bashrc` by the installer). | | `bin/dtach-router` | Dashboard to resume dtach sessions, shown at the start of every interactive shell (wired into `~/.bashrc` by the installer). |
| `bin/claude-provider`| Switch Claude Code between Anthropic and OpenRouter. | | `bin/claude-provider`| Switch Claude Code between Anthropic and OpenRouter. |
| `bin/repo-sync` | One local tree (`~/repos`) for every repository reachable on your forges (GitLab, GitHub, Gitea/Forgejo, Bitbucket Cloud), daily cache, `repo <project>` shell function to jump into one. |
| `etc/profile.d/disk-usage-warning.sh` | Login-time warning (bold red) when `/` or `/home` cross 85% usage. Deployed to `/etc/profile.d/` on Linux. | | `etc/profile.d/disk-usage-warning.sh` | Login-time warning (bold red) when `/` or `/home` cross 85% usage. Deployed to `/etc/profile.d/` on Linux. |
## Install ## Install
@@ -121,6 +122,12 @@ Deployed to `~/.local/bin` (the deployed bashrc adds this dir to `PATH`):
```sh ```sh
export OPENROUTER_API_KEY="<your-openrouter-key>" export OPENROUTER_API_KEY="<your-openrouter-key>"
``` ```
- **`repo-sync`** — one local tree for every git repository you can reach on your forges, same layout as the Alphalink `repo` zsh function: `~/repos/<namespace with / as @>/<project>` (`REPOS_DIR` overrides). Needs `jq`, `curl`, `git`.
- `repo-sync add` registers a forge (type `gitlab|github|gitea|bitbucket`, host, token asked on the terminal) in **`~/.config/repos/forges.conf`** (mode 0600, parsed line by line, never tracked). Run it once per access you have. GitHub Enterprise and self-hosted GitLab/Gitea work by host; Bitbucket is Cloud only (Atlassian email + API token).
- `repo-sync refresh` rebuilds the project list in `~/.cache/repos/list` when it is older than a day (`--force` to redo it now). The deployed rc files run it in the background at shell start, so the list is fresh at most once per day without a cron.
- `repo-sync list|tree` shows the projects; `repo-sync clone [--filter REGEX] [--pull] [--dry-run] [--https]` clones everything missing.
- `repo <project> [namespace]` (shell function, bash and zsh, with completion) clones on demand and `cd`s into it. The token only lists projects; clones use ssh unless `--https`.
- The same `namespace/project` on two forges is kept once, from the first section of `forges.conf`. Archived projects and mirrors are skipped.
## Requirements ## Requirements
+21
View File
@@ -130,6 +130,27 @@ alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise. # Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router' alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo_complete() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}" words
[ -f "$cache" ] || return 0
if [ "$COMP_CWORD" -eq 1 ]; then
words="$(cut -f1 "$cache" | sort -u)"
else
words="$(awk -F'\t' -v p="${COMP_WORDS[1]}" '$1 == p { print $2 }' "$cache")"
fi
# shellcheck disable=SC2207
COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _repo_complete repo
case $- in *i*) command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &) ;; esac
# Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante # Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante
# (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code # (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code
# Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu. # Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu.
+21
View File
@@ -146,6 +146,27 @@ alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise. # Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router' alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo_complete() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}" words
[ -f "$cache" ] || return 0
if [ "$COMP_CWORD" -eq 1 ]; then
words="$(cut -f1 "$cache" | sort -u)"
else
words="$(awk -F'\t' -v p="${COMP_WORDS[1]}" '$1 == p { print $2 }' "$cache")"
fi
# shellcheck disable=SC2207
COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _repo_complete repo
case $- in *i*) command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &) ;; esac
# Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante # Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante
# (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code # (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code
# Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu. # Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu.
Executable
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env bash
# repo-sync — one local tree for every git repository you can reach on your
# forges (GitLab, GitHub, Gitea/Forgejo, Bitbucket Cloud), with a daily cache.
#
# repo-sync add register a forge (type, host, token)
# repo-sync refresh [--force] [--quiet] rebuild the cache if older than a day
# repo-sync list the cache: project, namespace, host
# repo-sync tree namespaces as a tree, project counts
# repo-sync path <project> [namespace] clone if missing, print the local path
# repo-sync clone [--filter RE] [--pull] [--dry-run] [--https] clone all
#
# Layout: $REPOS_DIR/<namespace with / as @>/<project> (default ~/repos)
# Forges: $REPOS_CONF, 0600, never tracked (default ~/.config/repos/forges.conf)
# [work]
# type = gitlab gitlab | github | gitea | bitbucket
# host = gitlab.example.com
# token = glpat-... bitbucket: user = <atlassian email>, token = api token
# The token only lists projects; clones use ssh unless --https.
# Same namespace/project on two forges: the first section wins. Archived
# projects and mirrors are skipped.
set -euo pipefail
REPOS_DIR="${REPOS_DIR:-$HOME/repos}"
REPOS_CONF="${REPOS_CONF:-$HOME/.config/repos/forges.conf}"
REPOS_CACHE="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}"
REPOS_CACHE_MINUTES="${REPOS_CACHE_MINUTES:-1440}"
REPOS_CURL="${REPOS_CURL:-curl}"
die() { echo "repo-sync: $*" >&2; exit 1; }
usage() { sed -n '2,20p' "$0"; exit "${1:-0}"; }
# ── forges.conf ──────────────────────────────────────────────────────────────
# Parse the INI line by line (never sourced). One TSV line per section:
# name type host user token (an empty user is "-": tab-separated read
# would otherwise merge two consecutive tabs and shift the token)
forges() {
[ -f "$REPOS_CONF" ] || return 0
awk '
function flush() {
if (!name) return
if (user == "") user = "-"
printf "%s\t%s\t%s\t%s\t%s\n", name, type, host, user, token
}
/^[ \t]*\[/ {
flush()
name = $0; gsub(/^[ \t]*\[|\][ \t]*$/, "", name)
type = host = user = token = ""
next
}
/^[ \t]*(#|$)/ { next }
{
key = $0; sub(/[ \t]*=.*/, "", key); gsub(/[ \t]/, "", key)
val = $0; sub(/^[^=]*=[ \t]*/, "", val); sub(/[ \t]+$/, "", val)
if (key == "type") type = val
else if (key == "host") host = val
else if (key == "user") user = val
else if (key == "token") token = val
}
END { flush() }
' "$REPOS_CONF"
}
ask() {
local label="$1" default="${2:-}" value
if [ -n "$default" ]; then
read -r -p "$label [$default]: " value
else
read -r -p "$label: " value
fi
echo "${value:-$default}"
}
ask_secret() {
local value
read -r -s -p "$1: " value
echo >&2
[ -n "$value" ] || die "a token is required"
echo "$value"
}
cmd_add() {
[ -t 0 ] || die "add needs a terminal"
local name type host user="" token
name="$(ask "Name for this forge (e.g. work, home)")"
[[ "$name" =~ ^[A-Za-z0-9_-]+$ ]] || die "name: letters, digits, - and _ only"
type="$(ask "Type (gitlab|github|gitea|bitbucket)" gitlab)"
case "$type" in
gitlab|github|gitea|bitbucket) ;;
*) die "unknown type: $type" ;;
esac
case "$type" in
github) host="$(ask "Host" github.com)" ;;
bitbucket) host="bitbucket.org"; user="$(ask "Atlassian account email")" ;;
*) host="$(ask "Host (e.g. git.example.com)")" ;;
esac
[[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || die "host: hostname only, no scheme"
token="$(ask_secret "Token (read scope on repositories)")"
mkdir -p "$(dirname "$REPOS_CONF")"
( umask 077; touch "$REPOS_CONF" )
chmod 600 "$REPOS_CONF"
{
printf '\n[%s]\ntype = %s\nhost = %s\n' "$name" "$type" "$host"
[ -n "$user" ] && printf 'user = %s\n' "$user"
printf 'token = %s\n' "$token"
} >> "$REPOS_CONF"
echo "Saved to $REPOS_CONF"
cmd_refresh --force
}
# ── forge fetchers: each prints TSV "project namespace ssh_url https_url" ─
api() {
"$REPOS_CURL" -fsS --max-time 60 "$@"
}
# Page through a JSON-array endpoint: $1 = url without page, $2.. = curl auth.
paged_array() {
local url="$1" page=1 body
shift
while :; do
body="$(api "$@" "$url&page=$page")" || return 1
[ "$(printf '%s' "$body" | jq 'length')" -gt 0 ] || break
printf '%s\n' "$body"
page=$((page + 1))
done
}
fetch_gitlab() {
local host="$1" token="$2"
local url="https://$host/api/v4/projects"
url+="?membership=true&archived=false&per_page=100"
paged_array "$url" -H "PRIVATE-TOKEN: $token" |
jq -r '.[] | select(.mirror != true) |
[.path, .namespace.full_path, .ssh_url_to_repo, .http_url_to_repo] | @tsv'
}
fetch_github() {
local host="$1" token="$2" base="https://api.github.com"
[ "$host" = "github.com" ] || base="https://$host/api/v3"
local url="$base/user/repos?per_page=100"
url+="&affiliation=owner,collaborator,organization_member"
paged_array "$url" -H "Authorization: Bearer $token" \
-H "Accept: application/vnd.github+json" |
jq -r '.[] | select(.archived != true) |
[.name, .owner.login, .ssh_url, .clone_url] | @tsv'
}
fetch_gitea() {
local host="$1" token="$2"
paged_array "https://$host/api/v1/user/repos?limit=50" \
-H "Authorization: token $token" |
jq -r '.[] | select(.archived != true and .mirror != true) |
[.name, .owner.username, .ssh_url, .clone_url] | @tsv'
}
fetch_bitbucket() {
local user="$1" token="$2" body
local url="https://api.bitbucket.org/2.0/repositories?role=member&pagelen=100"
while [ -n "$url" ] && [ "$url" != "null" ]; do
body="$(api -u "$user:$token" "$url")" || return 1
printf '%s' "$body" | jq -r '.values[] |
[.slug, .workspace.slug,
(.links.clone[] | select(.name == "ssh") | .href),
(.links.clone[] | select(.name == "https") | .href)] | @tsv'
url="$(printf '%s' "$body" | jq -r '.next')"
done
}
fetch_forge() {
local name="$1" type="$2" host="$3" user="$4" token="$5"
case "$type" in
gitlab) fetch_gitlab "$host" "$token" ;;
github) fetch_github "$host" "$token" ;;
gitea) fetch_gitea "$host" "$token" ;;
bitbucket) fetch_bitbucket "$user" "$token" ;;
*) echo "repo-sync: [$name] unknown type '$type', skipped" >&2; return 0 ;;
esac | awk -F'\t' -v host="$host" -v OFS='\t' '{ print $1, $2, host, $3, $4 }'
}
# ── cache ────────────────────────────────────────────────────────────────────
# Cache line: project namespace host ssh_url https_url
# Dedup key = namespace/project, case-insensitive; first forge in the conf wins.
fetch_all() {
local line name type host user token
while IFS=$'\t' read -r name type host user token; do
[ -n "$name" ] || continue
[ "$user" = "-" ] && user=""
fetch_forge "$name" "$type" "$host" "$user" "$token" ||
echo "repo-sync: [$name] $type on $host failed, skipped" >&2
done < <(forges) |
awk -F'\t' '{ key = tolower($2 "/" $1) } !seen[key]++' |
sort -t $'\t' -f -k2,2 -k1,1
}
cache_is_fresh() {
[ -f "$REPOS_CACHE" ] &&
[ -z "$(find "$REPOS_CACHE" -mmin "+$REPOS_CACHE_MINUTES" 2>/dev/null)" ]
}
# mkdir is atomic on every platform (flock is not on macOS); a lock older than
# ten minutes is a crashed run.
lock_cache() {
local lock="$REPOS_CACHE.lock"
[ -d "$lock" ] &&
find "$lock" -maxdepth 0 -mmin +10 -exec rmdir {} \; 2>/dev/null
mkdir "$lock" 2>/dev/null || return 1
# shellcheck disable=SC2064
trap "rmdir '$lock' 2>/dev/null" EXIT
}
cmd_refresh() {
local force=0 quiet=0 count
for arg in "$@"; do
case "$arg" in
--force) force=1 ;;
--quiet) quiet=1 ;;
*) die "refresh: unknown option $arg" ;;
esac
done
if [ ! -f "$REPOS_CONF" ]; then
[ "$quiet" = 1 ] && exit 0
die "no forge yet: run repo-sync add"
fi
[ "$force" = 1 ] || ! cache_is_fresh || exit 0
mkdir -p "$(dirname "$REPOS_CACHE")"
lock_cache || exit 0
fetch_all > "$REPOS_CACHE.tmp"
mv "$REPOS_CACHE.tmp" "$REPOS_CACHE"
count="$(wc -l < "$REPOS_CACHE" | tr -d ' ')"
[ "$quiet" = 1 ] || echo "$count projects in $REPOS_CACHE"
}
need_cache() {
[ -f "$REPOS_CACHE" ] || cmd_refresh --force >&2
[ -s "$REPOS_CACHE" ] || die "empty cache: check $REPOS_CONF"
}
cmd_list() {
need_cache
awk -F'\t' -v OFS='\t' '{ print $1, $2, $3 }' "$REPOS_CACHE" |
column -t -s $'\t'
}
cmd_tree() {
need_cache
awk -F'\t' '
{
n = split($2, parts, "/"); cur = ""
for (i = 1; i <= n; i++) {
parent = cur; cur = (cur ? cur "/" parts[i] : parts[i])
if (cur in seen) continue
seen[cur] = 1; kids[parent] = kids[parent] SUBSEP cur; name[cur] = parts[i]
}
leaf = cur "/" $1
kids[cur] = kids[cur] SUBSEP leaf; name[leaf] = $1; count[cur]++
}
function walk(node, prefix, last, items, n, i, k, label) {
if (node != "") {
label = name[node]; if (count[node]) label = label " (" count[node] ")"
printf "%s%s%s\n", prefix, (last ? "└── " : "├── "), label
prefix = prefix (last ? " " : "│ ")
}
n = split(kids[node], items, SUBSEP); k = 0
for (i = 1; i <= n; i++) if (items[i] != "") k++
for (i = 1; i <= n; i++) if (items[i] != "") walk(items[i], prefix, --k == 0)
}
END { walk("", "", 1) }
' "$REPOS_CACHE"
}
# ── clone ────────────────────────────────────────────────────────────────────
local_path() { echo "$REPOS_DIR/${2//\//@}/$1"; }
# Clone one cache line into its local path; pull it instead when --pull is set.
sync_one() {
local project="$1" namespace="$2" url="$3" pull="$4" dry="$5" dest
dest="$(local_path "$project" "$namespace")"
if [ -d "$dest/.git" ]; then
[ "$pull" = 1 ] || return 0
echo "pull $dest"
[ "$dry" = 1 ] || git -C "$dest" pull --ff-only --quiet ||
echo " pull failed: $dest" >&2
return 0
fi
echo "clone $url -> $dest"
[ "$dry" = 1 ] && return 0
mkdir -p "$(dirname "$dest")"
git clone --quiet "$url" "$dest" || echo " clone failed: $url" >&2
}
cmd_clone() {
local filter=. pull=0 dry=0 col=4 lines
while [ $# -gt 0 ]; do
case "$1" in
--filter) filter="$2"; shift ;;
--pull) pull=1 ;;
--dry-run) dry=1 ;;
--https) col=5 ;;
*) die "clone: unknown option $1" ;;
esac
shift
done
need_cache
lines="$(awk -F'\t' -v re="$filter" -v c="$col" -v OFS='\t' \
'$2 ~ re { print $1, $2, $c }' "$REPOS_CACHE")"
[ -n "$lines" ] || die "no project matches '$filter'"
echo "$(printf '%s\n' "$lines" | wc -l | tr -d ' ') projects, into $REPOS_DIR"
while IFS=$'\t' read -r project namespace url; do
sync_one "$project" "$namespace" "$url" "$pull" "$dry"
done <<< "$lines"
}
# Pick the cache line for a project: the given namespace, else the first one
# without a dot (groups before personal namespaces like j.doe).
resolve() {
local project="$1" namespace="${2:-}"
awk -F'\t' -v p="$project" -v ns="$namespace" '
$1 == p && (ns == "" || $2 == ns) {
if (!best || ($2 !~ /\./ && best ~ /\./)) { best = $2; line = $0 }
}
END { print line }
' "$REPOS_CACHE"
}
cmd_path() {
[ $# -ge 1 ] || usage 2
need_cache
local line project namespace ssh dest
line="$(resolve "$1" "${2:-}")"
[ -n "$line" ] || die "unknown project: $1${2:+ in $2}"
IFS=$'\t' read -r project namespace _ ssh _ <<< "$line"
dest="$(local_path "$project" "$namespace")"
if [ ! -d "$dest/.git" ]; then
echo "clone $ssh -> $dest" >&2
mkdir -p "$(dirname "$dest")"
git clone --quiet "$ssh" "$dest" >&2 ||
{ rmdir "$dest" 2>/dev/null; die "clone failed: $ssh"; }
fi
echo "$dest"
}
# ── dispatch ─────────────────────────────────────────────────────────────────
command -v jq >/dev/null || die "jq is required"
case "${1:-}" in
add) shift; cmd_add "$@" ;;
refresh) shift; cmd_refresh "$@" ;;
list) cmd_list ;;
tree) cmd_tree ;;
path) shift; cmd_path "$@" ;;
clone) shift; cmd_clone "$@" ;;
-h|--help|help) usage ;;
*) usage 2 ;;
esac
+5 -4
View File
@@ -307,7 +307,7 @@ install_brew_packages() {
brew upgrade brew upgrade
brew install \ brew install \
vim git git-lfs git-filter-repo gitleaks pkgconf shellcheck gh git-delta \ vim git git-lfs git-filter-repo gitleaks pkgconf shellcheck gh git-delta \
curl gnupg lftp inetutils \ curl gnupg lftp inetutils jq \
unzip tree tmux fzf dtach \ unzip tree tmux fzf dtach \
node python pipx php \ node python pipx php \
mariadb imagemagick \ mariadb imagemagick \
@@ -595,7 +595,7 @@ if command -v apt-get >/dev/null 2>&1; then
# follow the distro's PHP version instead of pinning php8.x-*. # follow the distro's PHP version instead of pinning php8.x-*.
sudo apt-get install -y \ sudo apt-get install -y \
vim git git-lfs git-filter-repo gitleaks gcc make pkg-config dkms valgrind shellcheck git-delta \ vim git git-lfs git-filter-repo gitleaks gcc make pkg-config dkms valgrind shellcheck git-delta \
curl gnupg ca-certificates apt-transport-https \ curl gnupg ca-certificates apt-transport-https jq \
unzip tree tmux fzf dtach net-tools \ unzip tree tmux fzf dtach net-tools \
openssh-server cifs-utils lftp ftp \ openssh-server cifs-utils lftp ftp \
nodejs python3-pip pipx php-cli \ nodejs python3-pip pipx php-cli \
@@ -699,11 +699,12 @@ if command -v pipx >/dev/null 2>&1; then
pipx ensurepath >/dev/null pipx ensurepath >/dev/null
fi fi
# Deploy personal CLI scripts to ~/.local/bin (dt, dtach-router, claude-provider). # Deploy personal CLI scripts to ~/.local/bin (dt, dtach-router, claude-provider, repo-sync).
echo "Deploying CLI scripts to ~/.local/bin" echo "Deploying CLI scripts to ~/.local/bin"
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
cp "$SCRIPT_DIR"/bin/* "$HOME/.local/bin/" cp "$SCRIPT_DIR"/bin/* "$HOME/.local/bin/"
chmod +x "$HOME"/.local/bin/dt "$HOME"/.local/bin/dtach-router "$HOME"/.local/bin/claude-provider chmod +x "$HOME"/.local/bin/dt "$HOME"/.local/bin/dtach-router \
"$HOME"/.local/bin/claude-provider "$HOME"/.local/bin/repo-sync
# Remove any stale dtach wiring from ~/.profile (the menu now ships in ~/.bashrc; see above). # Remove any stale dtach wiring from ~/.profile (the menu now ships in ~/.bashrc; see above).
+22
View File
@@ -63,6 +63,28 @@ alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise. # Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router' alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}"
local -a items
[[ -f "$cache" ]] || return 0
if (( CURRENT == 2 )); then
items=(${(f)"$(cut -f1 "$cache" | sort -u)"})
_describe 'project' items
elif (( CURRENT == 3 )); then
items=(${(f)"$(awk -F'\t' -v p="$words[2]" '$1 == p { print $2 }' "$cache")"})
_describe 'namespace' items
fi
}
compdef _repo repo
[[ -o interactive ]] && command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &)
# Reglages propres a cette machine (non versionnes). # Reglages propres a cette machine (non versionnes).
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local" [ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"