feat(dev-env): backport matured dev-env implementation from /etc/nixos
Replace the stub dev-env with the real, working implementation that grew
in the reference machine config — de-identified for the public scaffold.
Nix layer:
- options.nix: full project schema (url/upstream/fork/category/
worktreeRoot/worktrees{branch,path,remote}/isClone/deployFlakeInput),
deploy.targets, github.forkUser, writeDirenvHints. Drops the
forgejo-URL block + deploy-flake auto-derivation (incoherent in a
scaffold that uses explicit per-project urls).
- lib.nix: mkProject + worktreePath/bareRepoPath/projectRemotes,
generalized to the explicit-url model (origin falls back to upstream).
- config.nix: renders /etc/dev-env/{config.sh,projects.json,
tmux-sessions.json}, installs helpers via writeShellScriptBin, loads
shell functions into interactive shells, wires the git pre-commit hook.
Scripts (config-driven, read /etc/dev-env at runtime):
- bootstrap.sh, nav.sh, worktree.sh, pr-helpers.sh, rebase.sh,
status.sh, deploy.sh, regtest.sh, tmux-launch.sh.
- Stripped aiolabs/forgejo/bitspire/lamassu/webapp hardcoding; the
github-fork remote is renamed 'fork' to match git.remotes vocabulary.
- Removes the dev.sh stub (the matured impl uses discrete commands +
shell functions, not a unified 'dev' CLI).
presets/example.nix: a worked, generic project list replacing the
identity-specific aiolabs preset. tests/smoke.nix + flake checks
exercise the schema; 'nix flake check' is green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
773632562e
commit
e38d313db2
17 changed files with 2925 additions and 147 deletions
409
modules/dev-env/scripts/rebase.sh
Normal file
409
modules/dev-env/scripts/rebase.sh
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
#!/usr/bin/env bash
|
||||
# dev-env: safe fork-onto-upstream rebase helper
|
||||
#
|
||||
# Walks every worktree under $DEV_ROOT that has an `upstream` remote and
|
||||
# rebases your fork commits onto upstream, with backups and a
|
||||
# force-with-lease push gated on confirmation. Reads paths from
|
||||
# /etc/dev-env/config.sh; the rebase log lives under the user's XDG
|
||||
# state dir.
|
||||
#
|
||||
# Workflow for a single rebase:
|
||||
# 1. Safety checks (no uncommitted changes, upstream remote exists)
|
||||
# 2. Create backup branch `backup/pre-rebase-YYYYMMDD-HHMMSS`
|
||||
# 3. Show incoming + outgoing commits
|
||||
# 4. Rebase and force-with-lease push (with confirmation)
|
||||
# 5. On conflict, print resolution guide and preserve backup
|
||||
|
||||
set -e
|
||||
|
||||
# Load runtime config (sets DEV_ROOT, REPOS_DIR, SHARED_DIR, …)
|
||||
if [[ -r /etc/dev-env/config.sh ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/dev-env/config.sh
|
||||
else
|
||||
echo "Error: /etc/dev-env/config.sh not found (is the dev-env module enabled?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEV_ROOT="${DEV_ROOT:-$HOME/dev}"
|
||||
REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}"
|
||||
SHARED_DIR="${SHARED_DIR:-$DEV_ROOT/shared}"
|
||||
|
||||
BACKUP_PREFIX="backup/pre-rebase"
|
||||
LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/dev-env/rebase.log"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Colors
|
||||
#-------------------------------------------------------------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
header() { echo -e "\n${BOLD}${CYAN}═══ $1 ═══${NC}\n"; }
|
||||
|
||||
log_action() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="${1:-Continue?}"
|
||||
read -r -p "$prompt (y/N) " -n 1 REPLY
|
||||
echo
|
||||
[[ $REPLY =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Generic repo helpers
|
||||
#-------------------------------------------------------------------------------
|
||||
get_upstream_branch() {
|
||||
local repo_path="$1"
|
||||
(cd "$repo_path"
|
||||
for branch in main master; do
|
||||
if git rev-parse "upstream/$branch" &>/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "main")
|
||||
}
|
||||
|
||||
check_repo_clean() {
|
||||
local repo_path="$1"
|
||||
[[ -z "$(git -C "$repo_path" status --porcelain)" ]]
|
||||
}
|
||||
|
||||
has_upstream() {
|
||||
git -C "$1" remote get-url upstream &>/dev/null
|
||||
}
|
||||
|
||||
create_backup() {
|
||||
local repo_path="$1"
|
||||
local branch
|
||||
branch="$(git -C "$repo_path" branch --show-current)"
|
||||
local backup_name="${BACKUP_PREFIX}-$(date +%Y%m%d-%H%M%S)"
|
||||
git -C "$repo_path" branch -f "$backup_name" "$branch"
|
||||
echo "$backup_name"
|
||||
}
|
||||
|
||||
show_divergence() {
|
||||
local repo_path="$1" upstream_branch="$2"
|
||||
local behind ahead
|
||||
behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0)
|
||||
ahead=$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo 0)
|
||||
echo -e " ${CYAN}Behind upstream:${NC} $behind commits"
|
||||
echo -e " ${GREEN}Ahead (your work):${NC} $ahead commits"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Core rebase
|
||||
#-------------------------------------------------------------------------------
|
||||
rebase_single_repo() {
|
||||
local repo_path="$1" repo_name="$2"
|
||||
local upstream_branch="${3:-}"
|
||||
local auto_mode="${4:-false}"
|
||||
|
||||
header "Rebasing: $repo_name"
|
||||
|
||||
if [[ ! -d "$repo_path/.git" ]] && [[ ! -f "$repo_path/.git" ]]; then
|
||||
error "Not a git repository: $repo_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! has_upstream "$repo_path"; then
|
||||
warn "No upstream remote configured. Skipping."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! check_repo_clean "$repo_path"; then
|
||||
error "Uncommitted changes detected!"
|
||||
echo " Stash or commit first: git stash / git commit"
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ -z "$upstream_branch" ]] && upstream_branch="$(get_upstream_branch "$repo_path")"
|
||||
|
||||
local current_branch
|
||||
current_branch="$(git -C "$repo_path" branch --show-current)"
|
||||
info "Current branch: $current_branch"
|
||||
info "Upstream branch: upstream/$upstream_branch"
|
||||
|
||||
info "Fetching upstream..."
|
||||
git -C "$repo_path" fetch upstream
|
||||
|
||||
show_divergence "$repo_path" "$upstream_branch"
|
||||
|
||||
local behind
|
||||
behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0)
|
||||
if [[ "$behind" -eq 0 ]]; then
|
||||
success "Already up to date with upstream!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}New commits from upstream:${NC}"
|
||||
git -C "$repo_path" log --oneline --graph "HEAD..upstream/$upstream_branch" | head -15
|
||||
local total_upstream
|
||||
total_upstream="$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch")"
|
||||
(( total_upstream > 15 )) && echo " ... and $((total_upstream - 15)) more commits"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Your commits to replay:${NC}"
|
||||
git -C "$repo_path" log --oneline "upstream/$upstream_branch..HEAD" | head -15
|
||||
local total_yours
|
||||
total_yours="$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD")"
|
||||
(( total_yours > 15 )) && echo " ... and $((total_yours - 15)) more commits"
|
||||
|
||||
echo ""
|
||||
if [[ "$auto_mode" != "true" ]] && ! confirm "Proceed with rebase?"; then
|
||||
warn "Skipped."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup_branch
|
||||
backup_branch="$(create_backup "$repo_path")"
|
||||
success "Backup created: $backup_branch"
|
||||
|
||||
info "Rebasing onto upstream/$upstream_branch..."
|
||||
if git -C "$repo_path" rebase "upstream/$upstream_branch"; then
|
||||
success "Rebase completed successfully!"
|
||||
echo ""
|
||||
if confirm "Push to origin with --force-with-lease?"; then
|
||||
git -C "$repo_path" push origin "$current_branch" --force-with-lease
|
||||
success "Pushed to origin!"
|
||||
log_action "REBASE SUCCESS: $repo_name onto upstream/$upstream_branch"
|
||||
else
|
||||
warn "Not pushed. When ready: git push origin $current_branch --force-with-lease"
|
||||
fi
|
||||
echo ""
|
||||
info "Backup branch '$backup_branch' preserved."
|
||||
echo " Delete later: git branch -D $backup_branch"
|
||||
return 0
|
||||
else
|
||||
error "Rebase encountered conflicts!"
|
||||
cat <<EOF
|
||||
|
||||
${BOLD}${YELLOW}Conflict Resolution Guide:${NC}
|
||||
1. Check conflicts: ${CYAN}git status${NC}
|
||||
2. Resolve markers <<<<<<< ======= >>>>>>>
|
||||
3. Stage: ${CYAN}git add <file>${NC}
|
||||
4. Continue: ${CYAN}git rebase --continue${NC}
|
||||
5. Skip empty: ${CYAN}git rebase --skip${NC}
|
||||
6. Abort: ${CYAN}git rebase --abort${NC}
|
||||
7. After success: ${CYAN}git push origin $current_branch --force-with-lease${NC}
|
||||
|
||||
Backup: ${GREEN}$backup_branch${NC}
|
||||
EOF
|
||||
log_action "REBASE CONFLICT: $repo_name - manual resolution required"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Discovery: walk actual worktrees under $DEV_ROOT instead of the old
|
||||
# $PROJECTS_DIR layout that no longer exists.
|
||||
#-------------------------------------------------------------------------------
|
||||
find_forked_repos() {
|
||||
# Print one "<path>:<display-name>" per rebase-eligible repo (has
|
||||
# upstream remote). We look at every git dir (.git dir or .git file
|
||||
# for worktrees) under $DEV_ROOT, excluding $REPOS_DIR bare repos.
|
||||
find "$DEV_ROOT" -type d -name '.git' -prune 2>/dev/null | while read -r gitdir; do
|
||||
local repo_path
|
||||
repo_path="$(dirname "$gitdir")"
|
||||
# Skip bare repos under REPOS_DIR
|
||||
[[ "$repo_path" == "$REPOS_DIR"* ]] && continue
|
||||
# Skip node_modules
|
||||
[[ "$repo_path" == *"/node_modules/"* ]] && continue
|
||||
if has_upstream "$repo_path"; then
|
||||
local display
|
||||
display="${repo_path#$DEV_ROOT/}"
|
||||
echo "$repo_path:$display"
|
||||
fi
|
||||
done
|
||||
# Also bare repos under REPOS_DIR (the .git file case)
|
||||
find "$DEV_ROOT" -type f -name '.git' 2>/dev/null | while read -r gitfile; do
|
||||
local repo_path
|
||||
repo_path="$(dirname "$gitfile")"
|
||||
[[ "$repo_path" == *"/node_modules/"* ]] && continue
|
||||
if has_upstream "$repo_path"; then
|
||||
local display
|
||||
display="${repo_path#$DEV_ROOT/}"
|
||||
echo "$repo_path:$display"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Batch modes
|
||||
#-------------------------------------------------------------------------------
|
||||
rebase_all() {
|
||||
header "Rebasing ALL Forks with Upstreams"
|
||||
warn "This walks $DEV_ROOT for every worktree with an 'upstream' remote."
|
||||
echo ""
|
||||
if ! confirm "Continue?"; then info "Aborted."; return 0; fi
|
||||
|
||||
local failed=()
|
||||
while IFS=: read -r path name; do
|
||||
if ! rebase_single_repo "$path" "$name"; then
|
||||
failed+=("$name")
|
||||
fi
|
||||
done < <(find_forked_repos)
|
||||
|
||||
echo ""
|
||||
header "Summary"
|
||||
if (( ${#failed[@]} == 0 )); then
|
||||
success "All repositories rebased successfully!"
|
||||
else
|
||||
error "Failed (${#failed[@]}):"
|
||||
printf ' - %s\n' "${failed[@]}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_all_status() {
|
||||
header "Repository Rebase Status"
|
||||
while IFS=: read -r path name; do
|
||||
local upstream_branch behind ahead
|
||||
upstream_branch="$(get_upstream_branch "$path")"
|
||||
git -C "$path" fetch upstream --quiet 2>/dev/null || true
|
||||
behind=$(git -C "$path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo ?)
|
||||
ahead=$(git -C "$path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo ?)
|
||||
printf " %-40s ↓%-3s ↑%-3s\n" "$name" "$behind" "$ahead"
|
||||
done < <(find_forked_repos)
|
||||
echo ""
|
||||
echo -e "${CYAN}Legend:${NC} ↓=behind upstream ↑=ahead (your commits)"
|
||||
}
|
||||
|
||||
view_log() {
|
||||
header "Rebase Log"
|
||||
if [[ -f "$LOG_FILE" ]]; then tail -50 "$LOG_FILE"
|
||||
else info "No rebase log yet at $LOG_FILE"; fi
|
||||
}
|
||||
|
||||
cleanup_backups() {
|
||||
header "Cleanup Backup Branches"
|
||||
echo "Options:"
|
||||
echo " 1) Delete backups older than 7 days"
|
||||
echo " 2) Delete backups older than 30 days"
|
||||
echo " 3) Delete ALL backup branches"
|
||||
echo " 4) Cancel"
|
||||
read -r -p "Select option: " -n 1 REPLY; echo ""
|
||||
|
||||
local days=0
|
||||
case $REPLY in
|
||||
1) days=7 ;;
|
||||
2) days=30 ;;
|
||||
3) days=0 ;;
|
||||
*) info "Cancelled."; return 0 ;;
|
||||
esac
|
||||
|
||||
local cutoff=""
|
||||
(( days > 0 )) && cutoff=$(date -d "$days days ago" +%Y%m%d 2>/dev/null || date -v-"${days}d" +%Y%m%d)
|
||||
|
||||
local deleted=0
|
||||
while IFS=: read -r path _; do
|
||||
cd "$path" || continue
|
||||
git branch --list "backup/*" 2>/dev/null | while read -r branch; do
|
||||
branch="$(echo "$branch" | tr -d ' *')"
|
||||
local bdate
|
||||
bdate="$(echo "$branch" | grep -oE '[0-9]{8}' | head -1)"
|
||||
if [[ $days -eq 0 ]] \
|
||||
|| { [[ -n "$bdate" ]] && [[ "$bdate" < "$cutoff" ]]; }; then
|
||||
git branch -D "$branch" 2>/dev/null && deleted=$((deleted + 1)) || true
|
||||
fi
|
||||
done
|
||||
done < <(find_forked_repos)
|
||||
success "Deleted $deleted backup branch(es)."
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Interactive / CLI
|
||||
#-------------------------------------------------------------------------------
|
||||
interactive_menu() {
|
||||
while true; do
|
||||
header "Rebase Helper"
|
||||
echo " 1) Rebase a single repository (by path)"
|
||||
echo " 2) Rebase ALL forks with upstream"
|
||||
echo " s) Status of all repos"
|
||||
echo " l) View rebase log"
|
||||
echo " c) Clean up old backup branches"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -r -p "Select: " -n 1 REPLY; echo ""
|
||||
case $REPLY in
|
||||
1) read -r -p "Repo path: " p
|
||||
[[ -d "$p/.git" || -f "$p/.git" ]] && rebase_single_repo "$p" "$(basename "$p")" || warn "Not a repo: $p" ;;
|
||||
2) rebase_all ;;
|
||||
s) show_all_status ;;
|
||||
l) view_log ;;
|
||||
c) cleanup_backups ;;
|
||||
q) echo "Bye!"; exit 0 ;;
|
||||
*) warn "Invalid option" ;;
|
||||
esac
|
||||
echo ""
|
||||
read -r -p "Press Enter to continue..."
|
||||
done
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
${BOLD}rebase${NC} — Safely rebase forks onto upstream
|
||||
|
||||
USAGE:
|
||||
rebase Interactive mode
|
||||
rebase single <path> Rebase a single repository
|
||||
rebase all Rebase all forks with upstream remote
|
||||
rebase status Show status of all forks
|
||||
rebase log Show rebase log
|
||||
rebase cleanup Clean up old backup branches
|
||||
|
||||
OPTIONS:
|
||||
-b, --branch <name> Upstream branch to rebase onto
|
||||
-y, --yes Auto-confirm prompts
|
||||
-h, --help Show this help
|
||||
|
||||
Backups are branched automatically as backup/pre-rebase-YYYYMMDD-HHMMSS
|
||||
and never deleted without explicit cleanup.
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local command="" repo_path="" upstream_branch="" auto_yes=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-b|--branch) upstream_branch="$2"; shift 2 ;;
|
||||
-y|--yes) auto_yes=true; shift ;;
|
||||
single|all|status|log|cleanup) command="$1"; shift ;;
|
||||
*)
|
||||
if [[ -z "$command" ]] && [[ -d "$1/.git" || -f "$1/.git" ]]; then
|
||||
command="single"; repo_path="$1"
|
||||
else
|
||||
repo_path="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case $command in
|
||||
"") interactive_menu ;;
|
||||
single) [[ -z "$repo_path" ]] && { error "Need path"; exit 1; }
|
||||
rebase_single_repo "$repo_path" "$(basename "$repo_path")" "$upstream_branch" "$auto_yes" ;;
|
||||
all) rebase_all ;;
|
||||
status) show_all_status ;;
|
||||
log) view_log ;;
|
||||
cleanup) cleanup_backups ;;
|
||||
*) error "Unknown command: $command"; usage; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in a new issue