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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user