commit 73b8910c73f4203090e8678b8b2ca3b6cf6ab057 Author: bachir Date: Mon Sep 7 11:27:03 2026 +0200 gitploy: outillage de déploiement git (deploy + hotfix transactionnels) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8fb9e3 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# gitploy + +Outillage git minimal pour fluidifier un déploiement *rolling release* : +une branche de développement, des branches d'environnement, et deux +sous-commandes git **transactionnelles** et **sûres**. + +```bash +git deploy -m "message" # livrer le dev vers un environnement +git hotfix -m "message" # corriger un environnement puis rapatrier dans le dev +``` + +## Modèle de branches + +- Une **branche de dev** (par défaut `master`) : là où tu travailles et intègres. +- Des **branches de déploiement** (`prod`, `stage`, …) : une par environnement. +- Tout passe par un remote (ex. `gitea`) qui déclenche le déploiement serveur. + +`git deploy` fait avancer une branche d'environnement au niveau du dev (merge). +`git hotfix` permet de corriger un environnement **sans changer de branche à la +main**, puis rapatrie automatiquement le correctif dans le dev. + +## Installation + +### En submodule (recommandé) + +```bash +# dans le dépôt qui doit déployer +git submodule add https://figureslibres.io/gitea/bachir/gitploy.git gitploy +git submodule update --init +gitploy/setup +``` + +### En dépôt autonome + +```bash +git clone gitea-figureslibres.io:bachir/gitploy.git +cd gitploy && ./setup # configure le dépôt parent courant +``` + +`setup` installe des **alias git locaux** (`git deploy`, `git hotfix`) pointant +vers les scripts — **aucune modification du `PATH`**. git n'exécutant jamais une +config versionnée tout seul (par sécurité), cette étape unique est nécessaire. + +## Configuration + +Stockée dans `git config` (aucun fichier `.env`) : + +| Clé | Défaut | Rôle | +|-----|--------|------| +| `deploy.source` | `master` | Branche de développement | +| `deploy.push` | `false` | Autorise le push réel (sinon simulation locale) | + +```bash +git config deploy.source master +git config deploy.push true # activer le push réel quand tu es prêt +``` + +Tant que `deploy.push` est `false`, **rien n'est poussé** : tu peux tout tester +en local, les scripts affichent le push qu'ils auraient fait. + +## Utilisation + +### Déployer le dev vers un environnement + +```bash +# tu es sur master +git deploy gitea prod -m "ajout du bloc contact" +``` + +1. commit sur `master` (message obligatoire s'il y a des modifs) ; +2. merge `master` → `prod` ; +3. push atomique (dernière étape) ; +4. retour sur `master`. + +### Corriger un environnement (hotfix) + +```bash +# tu es sur master, tu écris le correctif (sans committer) +git hotfix gitea prod -m "fix: lien du menu" +``` + +1. met tes modifs de côté (stash) ; +2. bascule sur `prod`, applique et committe le correctif ; +3. rapatrie le correctif dans `master` (merge) ; +4. push atomique puis retour sur `master`. + +Résultat : le correctif est sur `prod` **et** sur `master`, sans embarquer les +features non livrées de `master`. + +## Sûreté + +- **Confirmation** : un `git status` + le plan d'action, avec validation `[O/n]`. +- **Transactionnel** : toutes les mutations locales d'abord ; en cas d'échec + (conflit, etc.), l'arbre est **restauré à l'identique** (refs, branche + courante, modifs en cours) et **rien n'est poussé**. +- **Push en dernier**, **atomique** (`--atomic`, tout ou rien), **jamais forcé**, + précédé d'un `--dry-run`. +- **Garde-fous** : remote et branche existants, cible ≠ branche de dev, bonne + branche de départ, aucun merge/rebase en cours. + +## Complétion (optionnelle) + +Complète le remote puis la branche, lus en direct depuis le dépôt : + +```bash +# dans ~/.bashrc +source /chemin/vers/gitploy/deploy-completion.bash +``` + +## Fichiers + +| Fichier | Rôle | +|---------|------| +| `git-deploy` | Sous-commande `git deploy` | +| `git-hotfix` | Sous-commande `git hotfix` | +| `_deploy_lib.sh` | Helpers partagés (config, garde-fous, confirmation) | +| `deploy-completion.bash` | Complétion bash | +| `setup` | Installe les alias git (sans PATH) | diff --git a/_deploy_lib.sh b/_deploy_lib.sh new file mode 100644 index 0000000..31829cd --- /dev/null +++ b/_deploy_lib.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Helpers partagés par git-deploy et git-hotfix. À sourcer, pas à exécuter. + +# Config git-native (aucun .env) +deploy_source() { git config deploy.source 2>/dev/null || echo master; } +deploy_push() { git config --bool deploy.push 2>/dev/null || echo false; } + +# Erreur AVANT toute mutation : on sort sans rien avoir touché. +die() { echo "✗ $*" >&2; exit 1; } + +# Refuse de continuer si une opération git est déjà en cours (merge/rebase/…). +assert_clean_git_state() { + local gd; gd=$(git rev-parse --git-dir) + [ ! -e "$gd/MERGE_HEAD" ] || die "Un merge est déjà en cours. Termine-le d'abord." + [ ! -e "$gd/CHERRY_PICK_HEAD" ] || die "Un cherry-pick est en cours. Termine-le d'abord." + [ ! -e "$gd/REVERT_HEAD" ] || die "Un revert est en cours. Termine-le d'abord." + [ -z "$(ls -d "$gd"/rebase-* 2>/dev/null)" ] || die "Un rebase est en cours. Termine-le d'abord." +} + +# Affiche l'état + le plan, puis demande confirmation. Non → sortie sans mutation. +confirm() { + echo + echo "── État actuel ─────────────────────────────" + git status --short --branch + echo "── Opérations prévues ──────────────────────" + printf '%s\n' "$1" + echo "────────────────────────────────────────────" + local ans="" + printf "Confirmer ? [O/n] " + read -r ans || ans="" + case "$ans" in + n|N|non|Non|NON|no) echo "✗ Annulé — aucun changement effectué."; exit 0 ;; + *) return 0 ;; + esac +} diff --git a/deploy-completion.bash b/deploy-completion.bash new file mode 100644 index 0000000..a04eb03 --- /dev/null +++ b/deploy-completion.bash @@ -0,0 +1,26 @@ +# Complétion bash pour « git deploy » et « git hotfix ». +# Complète le 1er argument avec les remotes, le 2e avec les branches locales, +# le tout lu en direct depuis le dépôt courant. +# +# À sourcer dans ~/.bashrc APRÈS la complétion git, par ex. : +# source /chemin/vers/src/bin/deploy-completion.bash + +_deploy_complete_positional() { + local i args=() cur="${COMP_WORDS[COMP_CWORD]}" + # Recense les arguments positionnels déjà saisis (hors options et valeur de -m) + for ((i = 2; i < COMP_CWORD; i++)); do + case "${COMP_WORDS[i-1]}" in -m|--message) continue ;; esac + case "${COMP_WORDS[i]}" in -*) continue ;; esac + args+=("${COMP_WORDS[i]}") + done + + case "${#args[@]}" in + 0) COMPREPLY=($(compgen -W "$(git remote 2>/dev/null)" -- "$cur")) ;; + 1) COMPREPLY=($(compgen -W "$(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)" -- "$cur")) ;; + *) COMPREPLY=() ;; + esac +} + +# Hooks reconnus par la complétion git pour les sous-commandes personnalisées. +_git_deploy() { _deploy_complete_positional; } +_git_hotfix() { _deploy_complete_positional; } diff --git a/git-deploy b/git-deploy new file mode 100755 index 0000000..13bf4a5 --- /dev/null +++ b/git-deploy @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# git-deploy -m "message" +# +# Depuis la branche de dev : commit (message obligatoire s'il y a des modifs) +# → merge dans la branche d'env → push (dernière étape, atomique) → retour dev. +# +# Sécurité : toutes les mutations locales sont faites AVANT tout push. +# En cas d'échec local, l'arbre est restauré à l'identique et rien n'est poussé. +# +# Config git-native : +# git config deploy.source (défaut : master) +# git config deploy.push true|false (défaut : false — push désactivé) +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_deploy_lib.sh +. "$DIR/_deploy_lib.sh" + +usage() { echo 'Usage : git deploy -m "message"'; } + +SOURCE=$(deploy_source) +PUSH=$(deploy_push) + +MSG=""; MSG_SET=0; POS=() +while [ $# -gt 0 ]; do + case "$1" in + -m|--message) MSG="${2:-}"; MSG_SET=1; shift 2 ;; + -h|--help) usage; exit 0 ;; + -*) usage; die "Option inconnue : $1" ;; + *) POS+=("$1"); shift ;; + esac +done +REMOTE="${POS[0]:-}"; BRANCH="${POS[1]:-}" +{ [ -n "$REMOTE" ] && [ -n "$BRANCH" ]; } || { usage; exit 1; } + +# ---- Validations pré-vol (aucune mutation) --------------------------------- +git remote get-url "$REMOTE" >/dev/null 2>&1 || die "Remote inconnu : $REMOTE" +git show-ref --verify --quiet "refs/heads/$BRANCH" || die "Branche locale inconnue : $BRANCH" +[ "$BRANCH" != "$SOURCE" ] || die "'$SOURCE' est la branche de dev, pas une branche de déploiement." +current=$(git branch --show-current) +[ "$current" = "$SOURCE" ] || die "Place-toi sur '$SOURCE' pour déployer (git switch $SOURCE)." +assert_clean_git_state + +dirty=0; [ -n "$(git status --porcelain)" ] && dirty=1 +if [ "$dirty" = 1 ]; then + { [ "$MSG_SET" = 1 ] && [ -n "$MSG" ]; } || die 'Des modifs sont présentes : message requis (-m "…").' +fi + +# ---- Confirmation ---------------------------------------------------------- +if [ "$PUSH" = true ]; then push_line="OUI vers '$REMOTE' ('$BRANCH' + '$SOURCE', atomique)"; else push_line="NON (désactivé)"; fi +[ "$dirty" = 1 ] && commit_line="commit sur '$SOURCE' : \"$MSG\"" || commit_line="aucun commit (arbre propre)" +confirm " • $commit_line + • merge '$SOURCE' → '$BRANCH' + • push : $push_line" + +# ---- Sauvegarde de l'état pour rollback ------------------------------------ +SAVED_SOURCE=$(git rev-parse --verify "refs/heads/$SOURCE") +SAVED_BRANCH=$(git rev-parse --verify "refs/heads/$BRANCH") +COMMITTED=0 + +rollback() { + set +e + echo "↩ Restauration de l'état initial…" >&2 + local gd; gd=$(git rev-parse --git-dir) + [ -e "$gd/MERGE_HEAD" ] && git merge --abort + git reset --hard -q + git update-ref "refs/heads/$BRANCH" "$SAVED_BRANCH" + git switch -q "$SOURCE" 2>/dev/null || git switch -q --force "$SOURCE" + if [ "$COMMITTED" = 1 ]; then + # défait le commit mais garde les fichiers modifiés (état d'origine) + git reset --mixed -q "$SAVED_SOURCE" + else + git reset --hard -q "$SAVED_SOURCE" + fi + echo "✓ État initial restauré. Aucun push effectué." >&2 +} +fail() { echo "✗ $1" >&2; rollback; exit 1; } + +# ---- Mutations locales (aucun réseau) -------------------------------------- +if [ "$dirty" = 1 ]; then + git add -A + git commit -q -m "$MSG" || fail "Échec du commit sur '$SOURCE'." + COMMITTED=1 + echo "✓ Commit sur '$SOURCE' : $MSG" +fi + +git switch -q "$BRANCH" || fail "Impossible de basculer sur '$BRANCH'." +git merge --no-edit -q "$SOURCE" || fail "Conflit de merge sur '$BRANCH'." +git switch -q "$SOURCE" || fail "Impossible de revenir sur '$SOURCE'." +echo "✓ Local OK : '$BRANCH' à jour depuis '$SOURCE'." + +# ---- Push : dernière étape, atomique, jamais forcé ------------------------- +if [ "$PUSH" = true ]; then + echo "→ Vérification du push (dry-run)…" + git push --atomic --dry-run "$REMOTE" "$BRANCH" "$SOURCE" \ + || { echo "✗ Push rejeté au dry-run (non fast-forward ?). RIEN poussé. Local conservé." >&2; exit 1; } + echo "→ Push atomique '$BRANCH' + '$SOURCE' → '$REMOTE'…" + git push --atomic "$REMOTE" "$BRANCH" "$SOURCE" \ + || { echo "✗ Échec du push. RIEN poussé (atomique). Local conservé." >&2; \ + echo " Relance : git push --atomic $REMOTE $BRANCH $SOURCE" >&2; exit 1; } + echo "✓ Poussé sur '$REMOTE'." +else + echo "→ (push désactivé) aurait fait : git push --atomic $REMOTE $BRANCH $SOURCE" +fi +echo "✓ Terminé. Tu es sur '$SOURCE'." diff --git a/git-hotfix b/git-hotfix new file mode 100755 index 0000000..8f6ab89 --- /dev/null +++ b/git-hotfix @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# git-hotfix -m "message" +# +# Sans changer de branche à la main. Tu es sur la branche de dev avec un +# correctif en cours (non commité). Le script : +# 1. met tes modifs de côté (stash), +# 2. bascule sur la branche d'env et y applique le correctif, +# 3. le committe (message obligatoire), +# 4. le rapatrie (merge) dans la branche de dev, +# 5. push en dernière étape (atomique) puis te ramène sur la branche de dev. +# +# Sécurité : toutes les mutations locales sont faites AVANT tout push. +# En cas d'échec local, l'arbre est restauré à l'identique (tes modifs +# reviennent en l'état sur la branche de dev) et rien n'est poussé. +# +# Config git-native : +# git config deploy.source (défaut : master) +# git config deploy.push true|false (défaut : false — push désactivé) +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_deploy_lib.sh +. "$DIR/_deploy_lib.sh" + +usage() { echo 'Usage : git hotfix -m "message" (depuis la branche de dev)'; } + +SOURCE=$(deploy_source) +PUSH=$(deploy_push) + +MSG=""; MSG_SET=0; POS=() +while [ $# -gt 0 ]; do + case "$1" in + -m|--message) MSG="${2:-}"; MSG_SET=1; shift 2 ;; + -h|--help) usage; exit 0 ;; + -*) usage; die "Option inconnue : $1" ;; + *) POS+=("$1"); shift ;; + esac +done +REMOTE="${POS[0]:-}"; BRANCH="${POS[1]:-}" +{ [ -n "$REMOTE" ] && [ -n "$BRANCH" ]; } || { usage; exit 1; } + +# ---- Validations pré-vol (aucune mutation) --------------------------------- +git remote get-url "$REMOTE" >/dev/null 2>&1 || die "Remote inconnu : $REMOTE" +git show-ref --verify --quiet "refs/heads/$BRANCH" || die "Branche locale inconnue : $BRANCH" +[ "$BRANCH" != "$SOURCE" ] || die "'$SOURCE' est la branche de dev, pas une cible de hotfix." +current=$(git branch --show-current) +[ "$current" = "$SOURCE" ] || die "Lance 'git hotfix' depuis la branche de dev '$SOURCE' (git switch $SOURCE)." +assert_clean_git_state +[ -n "$(git status --porcelain)" ] || die "Aucune modification à hotfixer sur '$SOURCE'." +{ [ "$MSG_SET" = 1 ] && [ -n "$MSG" ]; } || die 'Message requis : -m "…".' + +# ---- Confirmation ---------------------------------------------------------- +if [ "$PUSH" = true ]; then push_line="OUI vers '$REMOTE' ('$BRANCH' + '$SOURCE', atomique)"; else push_line="NON (désactivé)"; fi +confirm " • stash des modifs de '$SOURCE' + • application + commit sur '$BRANCH' : \"$MSG\" + • rapatriement (merge) dans '$SOURCE' + • push : $push_line" + +# ---- Sauvegarde de l'état pour rollback ------------------------------------ +SAVED_SOURCE=$(git rev-parse --verify "refs/heads/$SOURCE") +SAVED_BRANCH=$(git rev-parse --verify "refs/heads/$BRANCH") +STASH_MADE=0 + +rollback() { + set +e + echo "↩ Restauration de l'état initial…" >&2 + local gd; gd=$(git rev-parse --git-dir) + [ -e "$gd/MERGE_HEAD" ] && git merge --abort + git reset --hard -q + git update-ref "refs/heads/$SOURCE" "$SAVED_SOURCE" + git update-ref "refs/heads/$BRANCH" "$SAVED_BRANCH" + git switch -q "$SOURCE" 2>/dev/null || git switch -q --force "$SOURCE" + git reset --hard -q "$SAVED_SOURCE" + if [ "$STASH_MADE" = 1 ]; then + # le stash est conservé tant qu'on ne l'a pas 'drop' : on le remet en place + git stash pop -q 2>/dev/null \ + || echo "⚠ Tes modifs sont sauvegardées dans le stash (git stash list)." >&2 + fi + echo "✓ État initial restauré. Aucun push effectué." >&2 +} +fail() { echo "✗ $1" >&2; rollback; exit 1; } + +# ---- Mutations locales (aucun réseau) -------------------------------------- +git stash push --include-untracked -q -m "git-hotfix: $MSG" || fail "Échec du stash." +STASH_MADE=1 +git switch -q "$BRANCH" || fail "Impossible de basculer sur '$BRANCH'." +# 'apply' (et non 'pop') : le stash reste dispo pour un rollback propre en cas de conflit. +git stash apply -q || fail "Conflit en appliquant le correctif sur '$BRANCH'." +git add -A +git commit -q -m "$MSG" || fail "Échec du commit sur '$BRANCH'." +git switch -q "$SOURCE" || fail "Impossible de revenir sur '$SOURCE'." +git merge --no-edit -q "$BRANCH" || fail "Conflit en rapatriant dans '$SOURCE'." + +# Local OK : le correctif est commité sur les deux branches → on lâche le stash. +git stash drop -q 2>/dev/null || true +STASH_MADE=0 +echo "✓ Local OK : hotfix sur '$BRANCH' + rapatrié dans '$SOURCE'." + +# ---- Push : dernière étape, atomique, jamais forcé ------------------------- +if [ "$PUSH" = true ]; then + echo "→ Vérification du push (dry-run)…" + git push --atomic --dry-run "$REMOTE" "$BRANCH" "$SOURCE" \ + || { echo "✗ Push rejeté au dry-run (non fast-forward ?). RIEN poussé. Local conservé." >&2; exit 1; } + echo "→ Push atomique '$BRANCH' + '$SOURCE' → '$REMOTE'…" + git push --atomic "$REMOTE" "$BRANCH" "$SOURCE" \ + || { echo "✗ Échec du push. RIEN poussé (atomique). Local conservé." >&2; \ + echo " Relance : git push --atomic $REMOTE $BRANCH $SOURCE" >&2; exit 1; } + echo "✓ Poussé sur '$REMOTE'." +else + echo "→ (push désactivé) aurait fait : git push --atomic $REMOTE $BRANCH $SOURCE" +fi +echo "✓ Terminé. Tu es sur '$SOURCE'." diff --git a/setup b/setup new file mode 100755 index 0000000..7a45e59 --- /dev/null +++ b/setup @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# gitploy — installe les sous-commandes « git deploy » / « git hotfix » +# pour le dépôt qui utilise cet outil, sans toucher au PATH. +# +# À lancer une fois (après un clone / init de submodule) : +# gitploy/setup (si monté en submodule sous gitploy/) +# ./setup (si utilisé en dépôt autonome) +# +# git n'exécute jamais une config versionnée tout seul (sécurité) : cette +# étape unique est donc nécessaire. +set -euo pipefail + +# Dossier réel de cet outil (résout aussi les symlinks éventuels). +SCRIPTS_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +# Dépôt à configurer : le superprojet si on est un submodule, sinon le dépôt courant. +SUPER=$(git -C "$SCRIPTS_DIR" rev-parse --show-superproject-working-tree 2>/dev/null || true) +if [ -n "$SUPER" ]; then + TARGET="$SUPER" +else + TARGET=$(git -C "$SCRIPTS_DIR" rev-parse --show-toplevel) +fi + +# Chemin de l'outil relatif au dépôt cible (pour un alias portable). +REL="${SCRIPTS_DIR#"$TARGET"/}" + +git -C "$TARGET" config alias.deploy "!\"\$(git rev-parse --show-toplevel)/$REL/git-deploy\"" +git -C "$TARGET" config alias.hotfix "!\"\$(git rev-parse --show-toplevel)/$REL/git-hotfix\"" + +# Branche de dev par défaut si non définie. +git -C "$TARGET" config deploy.source >/dev/null 2>&1 || git -C "$TARGET" config deploy.source master + +echo "✓ Alias installés sur : $TARGET" +echo " git deploy / git hotfix → $REL/" +echo " deploy.source = $(git -C "$TARGET" config deploy.source)" +echo " deploy.push = $(git -C "$TARGET" config --bool deploy.push 2>/dev/null || echo 'false (désactivé)')" +echo +echo "Complétion (optionnelle) — à ajouter à ~/.bashrc :" +echo " source \"$SCRIPTS_DIR/deploy-completion.bash\"" +echo +echo "Activer le push réel quand tu seras prêt : git -C \"$TARGET\" config deploy.push true"