Un fichier de configuration local (config/runtime.local) a été ajouté à .gitignore, mais Git continue de le considérer comme modifié dans le staging. Ce symptôme précis indique que ce fichier était déjà suivi par Git avant l’ajout de la règle d’ignorance. L’objectif final est donc d’accepter le nouvel état de l’arbre de travail (sans inclure runtime.local dans le commit) tout en préservant ses données locales, puis de vérifier ce résultat par un contrôle reproductible. Nous appliquons la politique suivante : config/runtime.local et config/preview.local doivent être absents de l’index et du futur commit, tandis que le modèle partagé config/runtime.example reste versionné. Concrètement, on doit ôter runtime.local de l’index sans toucher au fichier sur le disque, puis committer la suppression. L’ensemble se déroule en cinq cas de test dans un dépôt jetable :
1. Règle .gitignore seule : on constate que config/runtime.local apparaît toujours comme modifié (état ignore only) et doit être enlevé de l’index (décision REPAIR INDEX).
2. Index réparé avant commit : on retire runtime.local de l’index (git rm --cached), les données locales sont intactes. HEAD contient encore l’ancienne version, on doit donc vérifier et committer la suppression (décision REVIEW AND COMMIT).
3. Commit et clone : après avoir committé la suppression, un clone frais ne doit plus contenir config/runtime.local, alors que config/runtime.example reste présent (décision ACCEPT CURRENT TREE).
4. Réintroduction forcée : si on force l’ajout (git add -f) de runtime.local malgré .gitignore, la politique doit bloquer ce cas (décision HOLD).
5. Divergence index/travail : si l’index et le fichier de travail ont divergé (staged ≠ HEAD ≠ disk), la suppression sans forçage avec git rm --cached échoue et on garde les deux versions pour qu’un humain concilie (décision HOLD AND RECONCILE).
Chacun de ces cas décrit précisément la situation de config/runtime.local par rapport à l’index Git, au dernier commit HEAD et au système de fichiers. Ce workflow d’acceptation et de réparation fournit un guide opérationnel rigoureux, au-delà d’une simple check-list Git ou d’un tutoriel basique. Les exemples qui suivent sont exécutés dans un dépôt isolé, conformément aux bonnes pratiques de contrôle de version pour des pipelines reproductibles, pour aboutir à une décision fondée sur des preuves techniques.
1. Define which configuration belongs outside the repository
Nous définissons clairement la politique : les fichiers config/runtime.local et config/preview.local ne doivent pas être versionnés. Le premier est la configuration réelle, spécifique à la machine, à conserver uniquement localement. Le second (preview.local) est un cas témoin (jamais committé) qui sert à valider le diagnostic. En revanche, config/runtime.example est un modèle générique qui doit rester dans le dépôt. En pratique, un fichier peut être utile localement mais considéré comme une erreur dans le commit final. Par exemple, on peut corriger localement runtime.local sans que cela doive apparaître dans la branche partagée. Comme le rappelle le guide du workflow Git de base, Git suit le processus add/commit standard, et .gitignore n’intervient que pour les fichiers non suivis. Un fichier déjà versionné ne disparaît pas du prochain commit juste parce qu’on l’ajoute à .gitignore. Pour s’assurer que runtime.local soit retiré de l’index, il faut une action explicite sur l’index (pas seulement sur la règle). Autrement dit, le contenu runtime.local peut être parfaitement correct en local, mais il est qualifié d’erreur pour les commits. C’est cette distinction entre état local (fichier sur le disque) et état versionné (index et arbre de commit) que nous allons exploiter. La documentation Git recommande d’utiliser git rm --cached pour arrêter le suivi d’un fichier déjà committé. Nous appliquons donc une démarche en deux étapes : enlever runtime.local de l’index puis vérifier que le commit résultant respecte la politique.
2. Separate ignore rules, the index and committed trees
Pour diagnostiquer correctement, il faut bien distinguer trois « points de vue » de Git :
Working tree : ce qui existe sur le disque (les fichiers de l’arborescence de travail).
Index (staging area) : la liste des fichiers préparés pour le prochain commit (la candidate tree).
Tree du commit sélectionné (par exemple HEAD) : le contenu du commit (historique actuel).
Considérons les trois chemins de test :
config/runtime.local (devrait être ignoré)
config/preview.local (jamais committé, devrait aussi être ignoré)
config/runtime.example (exemple à conserver)
La règle .gitignore est /config/*.local, qui correspond à la fois à runtime.local et preview.local. Nous résumons leur statut dans le tableau suivant :
Chemin | Correspond à la règle .gitignore ? | Existe sur disque ? | Dans l’index ? | Dans HEAD (commit) ? |
config/runtime.local | Oui | Oui (mode-b) | Oui (initialement) | Oui (mode-a, base du commit) |
config/preview.local | Oui | Oui (mode-preview) | Non | Non |
config/runtime.example | Non | Oui (mode-example) | Oui | Oui |
Pour clarifier la politique, on peut interroger Git à chaque niveau. Par exemple, git ls-files --cached liste tout ce qui est dans l’index (les fichiers suivis). En ajoutant --ignored --exclude-standard, on ne renvoie que les chemins déjà suivis qui correspondent à la politique d’exclusion active. Dans notre cas, cette commande renvoie config/runtime.local, ce qui prouve que ce fichier suit une règle d’ignore tout en étant dans l’index. Inversement, preview.local n’apparaît pas, car il n’a jamais été indexé. Pour vérifier le contenu committé (HEAD), on peut utiliser git ls-tree -r HEAD, qui liste les fichiers de l’arbre du commit. On s’attend à y trouver runtime.local pour le commit de référence initial (baseline), mais plus après la réparation. Il ne faut pas se contenter de lister le disque pour audit : seuls l’index et l’arbre Git renseignent exactement ce qui sera dans le commit.
A matching pattern does not remove an existing index entry
Les deux fichiers .local correspondent à la règle /config/*.local, mais l’effet diffère selon qu’ils étaient suivis ou pas. Gitignore n’affecte pas un fichier déjà suivi. Concrètement, config/runtime.local était déjà dans l’index et restera inscrit malgré la règle. config/preview.local, n’ayant jamais été commis, restera ignoré. Cela signifie qu’un simple changement du .gitignore (même répétitif) ne peut pas retirer runtime.local de l’index : cette règle ne s’applique qu’aux chemins non suivis. Pour résoudre la situation, il faut passer par l’étape git rm --cached, qui enlèvera explicitement runtime.local de l’index tout en conservant son contenu sur le disque. En résumé, la présence de runtime.local dans l’index ne sera pas corrigée par la règle .gitignore elle-même : il faut le retirer manuellement.
3. Build a disposable repository with independent controls
Nous créons un dépôt Git isolé pour simuler ce scénario, en accord avec les recommandations sur le contrôle de version pour des pipelines reproductibles. Le script Python ci-dessous (fichier git_ignore_lab.py) initialise un répertoire temporaire, désactive toute configuration globale (GIT_CONFIG_NOSYSTEM=1, core.excludesFile=/dev/null) et crée deux fichiers de test :
config/runtime.local avec le contenu mode=local-a\n.
config/runtime.example avec le contenu mode=example\n.
Puis on fait un commit « fixture baseline ». À partir de cette base, le script ajoute config/*.local à .gitignore, modifie runtime.local (passant à mode=local-b\n) et crée config/preview.local (mode=preview\n). Il exécute ensuite une série de commandes Git pour simuler les cas d’usage. Le script génère un rapport JSON détaillant les sorties (codes retour, chemins trouvés, diffs, etc.) des contrôles principaux, et décide de l’action appropriée. Voici le pilote complet (à exécuter avec python3 git_ignore_lab.py) :
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tempfile
import traceback
ROOT = Path(tempfile.mkdtemp(prefix="git-ignore-lab-", dir=Path(__file__).parent))
ENV = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
GIT_TERMINAL_PROMPT="0")
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
ENV.pop(key, None)
ledger = []
def git(repo, args, ok=(0,)):
p = subprocess.run(["git", "-C", str(repo), args], env=ENV,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if p.returncode not in ok:
raise RuntimeError((args, p.returncode, p.stderr.decode()))
return p
def init(name):
repo = ROOT / name
repo.mkdir()
git(repo, "init", "-q")
for key, value in [("user.name", "Fixture Author"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.excludesFile", os.devnull),
("core.hooksPath", str(ROOT / "empty-hooks"))]:
git(repo, "config", key, value)
(repo / "config").mkdir()
(repo / "config/runtime.local").write_bytes(b"mode=local-a\n")
(repo / "config/runtime.example").write_bytes(b"mode=example\n")
git(repo, "add", "--", "config/runtime.local", "config/runtime.example")
git(repo, "commit", "-qm", "fixture baseline")
return repo
report_path = ROOT / "git-preflight-results.json"
result = {"root": str(ROOT), "report_path": str(report_path),
"cases": ledger, "status": "FAIL", "all_assertions_passed": False}
try:
if not debug:
raise RuntimeError("Assertions are required: disable -O, -OO and PYTHONOPTIMIZE.")
result["git"] = git(ROOT, "--version").stdout.decode().strip()
repo = init("main")
baseline = git(repo, "rev-parse", "HEAD").stdout.decode().strip()
(repo / ".gitignore").write_bytes(b"/config/*.local\n")
(repo / "config/runtime.local").write_bytes(b"mode=local-b\n")
(repo / "config/preview.local").write_bytes(b"mode=preview\n")
tracked_default = git(repo, "check-ignore", "-v", "--", "config/runtime.local", ok=(1,))
tracked_pattern = git(repo, "check-ignore", "-v", "--no-index", "--", "config/runtime.local")
untracked_default = git(repo, "check-ignore", "-v", "--", "config/preview.local")
git(repo, "add", "-A")
staged = git(repo, "diff", "--cached", "--name-status").stdout.decode().splitlines()
policy_paths = git(repo, "ls-files", "--cached", "--ignored", "--exclude-standard", "-z").stdout
assert policy_paths == b"config/runtime.local\0"
assert "M\tconfig/runtime.local" in staged
assert all("preview.local" not in s for s in staged)
ledger.append({"case": "ignore_rule_only", "tracked_default_rc": tracked_default.returncode,
"tracked_no_index_rc": tracked_pattern.returncode,
"untracked_default_rc": untracked_default.returncode,
"staged": staged, "policy_paths": policy_paths.decode().split("\0")[:-1],
"decision": "REPAIR INDEX"})
before = hashlib.sha256((repo / "config/runtime.local").read_bytes()).hexdigest()
git(repo, "rm", "--dry-run", "--cached", "--", "config/runtime.local")
git(repo, "rm", "--cached", "--", "config/runtime.local")
assert hashlib.sha256((repo / "config/runtime.local").read_bytes()).hexdigest() == before
assert git(repo, "ls-files", "--cached", "--", "config/runtime.local").stdout == b""
assert git(repo, "ls-tree", "-r", "--name-only", "HEAD", "--", "config/runtime.local").stdout
ledger.append({"case": "index_repaired_before_commit", "local_bytes_preserved": True,
"index_contains_target": False, "head_still_contains_target": True,
"decision": "REVIEW AND COMMIT"})
git(repo, "commit", "-qm", "keep runtime config local")
assert git(repo, "ls-tree", "-r", "--name-only", "HEAD", "--", "config/runtime.local").stdout == b""
assert git(repo, "show", baseline + ":config/runtime.local").stdout == b"mode=local-a\n"
clone = ROOT / "fresh"
git(repo, "clone", "-q", "--no-hardlinks", "--", str(repo), str(clone))
assert not (clone / "config/runtime.local").exists()
assert (clone / "config/runtime.example").read_bytes() == b"mode=example\n"
ledger.append({"case": "committed_repair", "head_contains_target": False,
"local_bytes_preserved": True, "fresh_clone_has_local_file": False,
"historical_blob_readable": True, "decision": "ACCEPT CURRENT TREE"})
git(repo, "add", "-f", "--", "config/runtime.local")
assert git(repo, "ls-files", "-ci", "--exclude-standard", "-z").stdout == b"config/runtime.local\0"
ledger.append({"case": "forced_reintroduction", "policy_detected": True, "decision": "HOLD"})
git(repo, "rm", "--cached", "--", "config/runtime.local")
dirty = init("staged-divergence")
(dirty / "config/runtime.local").write_bytes(b"mode=local-b\n")
git(dirty, "add", "--", "config/runtime.local")
(dirty / "config/runtime.local").write_bytes(b"mode=local-c\n")
refusal = git(dirty, "rm", "--cached", "--", "config/runtime.local", ok=(1,))
assert git(dirty, "show", ":config/runtime.local").stdout == b"mode=local-b\n"
assert (dirty / "config/runtime.local").read_bytes() == b"mode=local-c\n"
ledger.append({"case": "staged_worktree_divergence", "rc": refusal.returncode,
"index_b_preserved": True, "worktree_c_preserved": True, "decision": "HOLD AND RECONCILE"})
result.update(status="PASS", all_assertions_passed=True)
except Exception as exc:
result["failure"] = {"type": type(exc).__name__, "message": str(exc),
"traceback": traceback.format_exc()}
raise
finally:
report_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, indent=2))Ce script produit un rapport JSON (dans git-preflight-results.json) listant chaque cas de test avec ses résultats (codes retour, sorties, diffs, etc.). Ce rapport sert de preuve détaillée pour qu’un relecteur tiers puisse reproduire toutes les étapes. Par exemple, le cas ignore_rule_only contient tracked_default_rc:1, tracked_no_index_rc:0, la liste policy_paths: ["config/runtime.local"], et la décision "REPAIR INDEX". Les lignes du diff de staging et les chemins issus de la sortie NUL-séparée sont conservés dans ce fichier JSON. Ainsi, tout autre développeur peut exécuter ce même driver, fournir le rapport produit, et vérifier que les mêmes décisions sont prises.
4. Reproduce the tracked-file failure after adding .gitignore
À partir du commit initial (baseline), le script applique la règle /config/*.local et fait git add -A. On s’attend à voir en staged : la nouvelle .gitignore marquée Ajoutée et config/runtime.local marquée Modifiée, tandis que config/preview.local (non suivi) n’est pas listé. Par exemple, on obtient :
$ git add -A
$ git diff --cached --name-status
A .gitignore
M config/runtime.local
Aucune ligne pour preview.local, car ce fichier, bien que créé, est ignoré et n’a donc pas été ajouté. Le code du driver capture aussi le code de sortie et les sorties détaillées :
git check-ignore -v config/runtime.local retourne 1 et n’affiche rien (par défaut, les fichiers suivis sont ignorés par check-ignore).
git check-ignore -v --no-index config/runtime.local retourne 0 et affiche le motif correspondant (on passe outre l’index).
git check-ignore -v config/preview.local retourne 0 et affiche le même motif /config/*.local (ce fichier non suivi est ignoré).
Ces codes de retour proviennent de la documentation : 0 signifie qu’au moins un chemin est ignoré, 1 qu’aucun ne l’est. L’absence de sortie pour runtime.local en mode normal n’est pas une erreur : c’est attendu quand le fichier est suivi et donc hors du champ des règles d’ignorance. Cela confirme que le motif est bien reconnu, mais qu’il s’applique seulement aux fichiers non suivis ou avec --no-index.
Compare the already tracked file with the never-tracked control
Les deux fichiers (runtime.local et preview.local) correspondent au motif /config/*.local, mais seulement runtime.local figurait déjà dans l’index. Dans le staging, seul runtime.local persiste en tant que modifié, tandis que preview.local est ignoré. Cela montre que le statut d’index détermine le résultat du git add, pas seulement le match de motif. Changer .gitignore après coup (par ex. modifier ou dupliquer la règle) ne supprime pas le suivi de runtime.local. En pratique, on ne peut réparer cette situation qu’en supprimant explicitement runtime.local de l’index ; la règle d’ignore seule ne suffit pas, comme déjà noté.
5. Diagnose the rule without confusing it with repository contents
Nous traitons d’abord le diagnostic de la règle d’ignore indépendamment du contenu du dépôt. Les résultats de git check-ignore montrent clairement la différence :
Fichier suivi (runtime.local) : git check-ignore -v config/runtime.local retourne 1 (aucune sortie) car les fichiers suivis ne sont pas pris en compte.
Même fichier, mode --no-index : git check-ignore -v --no-index config/runtime.local retourne 0 et affiche la règle /config/*.local.
Fichier non suivi (preview.local) : git check-ignore -v config/preview.local retourne 0 et affiche également /config/*.local.
On souligne que le statut 1 n’est pas une erreur du pattern : c’est simplement la convention de Git pour dire "aucun fichier n’est ignoré", ici parce que runtime.local était déjà suivi. De même, le code 0 pour preview.local confirme que le motif l’ignore. L’utilisateur ne doit pas confondre une sortie vide avec un motif invalide : le motif est bien reconnu ici.
Use an index query as the independent acceptance gate
Pour valider la conformité à la politique au niveau de l’index, nous utilisons git ls-files --cached --ignored --exclude-standard -z. Cette commande ne liste que les fichiers dans l’index qui correspondent à un motif d’ignorance actif. Dans notre cas, elle retourne le chemin config/runtime.local\0 (NUL-séparateur), confirmant qu’on a un fichier suivi qui viole la règle. Si cette commande renvoie un chemin, on décide qu’il faut réparer l’index (tout fichier listé est en anomalie). Une sortie vide signifie qu’aucun fichier suivi ne viole la politique ; c’est acceptable ici. En revanche, si ls-files échoue (code d’erreur) ou tombe sur une autre anomalie, c’est un cas de HOLD. Pour chaque projet, la politique d’exceptions doit être explicite : si un fichier ignoré doit être autorisé, cela devrait être documenté séparément (par exemple dans un manifeste d’artefacts approuvés). L’important est que le gate traitant ls-files se contente de contrôler l’index ; il ne redéfinit pas la politique à la volée.
6. Inspect staged content before choosing the repair
Avant de retirer quoi que ce soit, examinons l’état dans l’index et sur le disque. À ce stade, notre arbre de travail contient runtime.local modifié (mode=local-b), l’index a exactement cette version (staged), et le HEAD pointe encore vers mode=local-a (anciennes données). Par exemple, git diff --cached montrerait M config/runtime.local. Nous nous retrouvons donc avec trois versions du fichier : local-a dans le commit HEAD, local-b dans l’index, et aussi local-b sur le disque (pas encore committé). Il n’y a pas de divergence index/travail dans ce cas. Dans l’autre scénario test (dépôt staged-divergence), on crée une divergence : l’index contient mode=local-b alors que le fichier sur disque a été changé à mode=local-c sans être re-stagé. Dans ce cas, git rm --cached refuse l’opération : il exige que le contenu de l’index corresponde soit à HEAD soit au disque. Concrètement, ici l’index (local-b) ne correspond ni au HEAD (local-a) ni au disque (local-c), donc git rm --cached config/runtime.local termine avec code retour 1 et ne modifie ni l’index ni le fichier (index reste local-b, fichier reste local-c). Ce refus définit un cas HOLD AND RECONCILE : on ne fait rien d’automatique, on demande à un humain de décider comment concilier (local-b vs local-c).
Dans le premier cas (sans divergence), l’index correspond au fichier sur le disque (local-b dans les deux), on peut donc continuer la suppression sans forcer. Le fait de repérer la divergence par un code retour non nul nous oblige à stopper, afin de ne pas écraser des données potentielles. On ne corrige pas ce refus par un -f automatique : cela reviendrait à perdre le contrôle de la situation. Nous conservons donc l’état non résolu (index=local-b, disk=local-c) et passons à la décision de HOLD pour qu’un développeur intervienne.
7. Remove only the approved index entry and retain local bytes
Maintenant que l’index est en bon état (sans divergence par rapport au fichier), nous retirons précisément runtime.local de l’index, tout en laissant intact le fichier local. On fait d’abord un dry-run :
$ git rm -n --cached -- config/runtime.local
Git répond que runtime.local serait supprimé de l’index (code retour 0), ce qui confirme que nous avons ciblé la bonne entrée. On exécute ensuite :
$ git rm --cached -- config/runtime.local
Grâce aux conditions préalables (l’index contenait la même version que sur le disque), cette commande réussit sans forcer. Les effets sont les suivants :
Le fichier reste sur le disque (le SHA-256 avant/après est identique).
git ls-files --cached config/runtime.local ne renvoie plus rien (il a été retiré de l’index).
config/runtime.example est toujours présent dans l’index et sur le disque.
Nous n’avons pas besoin de --ignore-unmatch ici, car nous nous assurons que le fichier existe bien. L’index est maintenant conforme à la politique (absence de runtime.local). À ce stade, HEAD contient toujours runtime.local, donc le commit n’est pas encore réparé. La décision est donc REVIEW AND COMMIT : on doit committer cette suppression pour mettre à jour l’historique.
Verify the index before claiming the commit is repaired
Il est important de vérifier l’index avant de confirmer la réparation. Après git rm --cached, on constate que HEAD (avant commit) contient encore runtime.local. On n’a fait que nettoyer l’index. En l’état, git status affiche la suppression préparée de runtime.local ainsi que l’ajout de .gitignore ; le commit n’est pas encore finalisé. Il faut effectuer le commit pour que le HEAD reflète la suppression. Tant que ce commit n’est pas fait, on reste en REVIEW AND COMMIT. Ce n’est qu’après le commit que le nouvel arbre Git sera examiné.
8. Commit the policy change and inspect the selected tree
Nous réalisons maintenant le commit qui enregistre la suppression de runtime.local. Par exemple :
$ git commit -qm "keep runtime config local"
Ce commit inclut la nouvelle .gitignore et retire config/runtime.local de l’arbre. On récupère alors l’ID complet du commit (SHA-1 ou autre) qui représente l’arbre accepté. Pour vérifier le contenu du commit, on exécute :
$ git ls-tree -r <commit> --name-only
Cette commande liste les fichiers contenus dans l’arbre du commit. Nous confirmons qu’elle n’inclut pas config/runtime.local, et qu’elle contient toujours config/runtime.example. En comparant avec l’ancien commit (baseline), on peut aussi vérifier que le blob runtime.local original (mode=local-a) est encore accessible dans l’historique avec git show <baseline>:config/runtime.local. Cela illustre la distinction source/artefact : l’arbre du nouveau commit (l’artefact approuvé) ne contient plus runtime.local, alors que l’ancienne version reste stockée dans Git. Ce processus est analogue à la validation d’artefacts, à la manière de l’article sur la vérification de l’identité d’un artefact approuvé avec Terraform. On s’assure que seul le changement autorisé (suppression de runtime.local) est appliqué, et aucun autre contenu non désiré n’apparaît dans le nouveau commit. Grâce à git ls-tree -r HEAD, on confirme formellement l’état final de l’arbre sans le fichier local indésirable.
9. Verify a fresh checkout and explain the history boundary
Pour clore la vérification, on effectue un clone frais du dépôt au commit réparé :
$ git clone <dépôt> fresh
Dans cette copie neuve, le fichier config/runtime.local n’existe pas, conformément à notre politique. Le fichier config/runtime.example y est présent et contient bien mode=example. Cela prouve que le commit réparé est appliqué pour un nouvel utilisateur du dépôt. Pendant ce temps, dans le dépôt original, le fichier runtime.local reste sur le disque (mode=local-b), car nous ne l’avons jamais supprimé du système de fichiers. Enfin, on peut vérifier que l’ancien commit (baseline) contient toujours le blob mode=local-a, par exemple avec :
$ git show <baseline-commit-ID>:config/runtime.local
Cette ligne afficherait mode=local-a, démontrant que les anciens objets Git ne sont pas effacés par ce processus. En résumé, le clone montre l’état autorisé sans runtime.local, l’original conserve son fichier local, et l’historique garde la trace de runtime.local dans le passé.
A current-tree repair leaves earlier commits intact
La suppression de runtime.local dans l’arbre actuel ne fait pas disparaître les versions précédentes. Les blobs correspondants aux anciens commits restent stockés. La documentation Git explique comment arrêter le suivi d’un fichier avec git rm --cached. Le commit qui suit n’efface pas les données des anciens commits. Si ce fichier contenait un secret, cette opération ne suffirait pas à le rendre introuvable dans les versions passées. Tout incident réel d’exposition exigerait un processus de purge de l’historique (par exemple, réécrire les commits). Ce volet extrêmement sensible relève d’une décision séparée, au-delà de l’outil de contrôle standard. Dans ce workflow, nous nous limitons à dire au propriétaire du dépôt : « Le fichier n’apparaît plus dans l’arbre actuel. Les utilisateurs à venir ne le verront pas, mais les anciens commits le contiennent toujours. » La décision finale « ACCEPT CURRENT TREE » se base uniquement sur l’arbre du commit, en supposant que la politique de sécurité sur l’historique a été traitée par ailleurs.
10. Challenge the repair with forced reintroduction
Pour tester la robustesse de la politique, on simule une réintégration forcée. Dans le même dépôt réparé, nous exécutons :
$ git add -f config/runtime.local
Git ajoute alors runtime.local dans l’index malgré son entrée dans .gitignore. La commande suivante montre l’effet :
$ git ls-files -ci --exclude-standard -z
Le préfixe ci signifie cached + ignored. Cette commande retourne config/runtime.local\0, ce qui indique que runtime.local est à nouveau listé dans l’index comme fichier ignoré. Autrement dit, le simple .gitignore ne peut empêcher quelqu’un d’ajouter le fichier par force. Selon notre politique, cela déclenche la décision HOLD : ce cas doit être examiné manuellement. Nous avons donc supprimé ce chemin forcé pour remettre l’index dans l’état précédant le test.
Ce test rappelle un principe fondamental des bonnes pratiques de gouvernance en DevSecOps : une configuration de politique ne suffit pas sans contrôle humain. Git a bien signalé le fichier ignoré (config/runtime.local), mais a tout de même exécuté l’ajout forcé. Une équipe responsable doit définir si cela est autorisé (par ex. via une approbation explicite ou un manifeste des exceptions) ou si c’est une violation de la politique. Dans tous les cas, notre gate détecte cette situation et empêche le commit automatique.
11. Package evidence that another reviewer can reproduce
Pour rendre cette procédure auditable, chaque résultat clé est consigné. Le rapport JSON fourni inclut le répertoire racine de l’exécution, la version de Git et les résultats des cinq cas. Pour constituer un dossier de preuve complet, il faut aussi consigner les versions de Python et de l’OS, le hash SHA-256 du driver et les deux identifiants de commit (initial et réparé). Les résultats enregistrés comprennent certains codes retour, les chemins détectés, les lignes du diff de staging (git diff --cached) et la décision prise. Le script compare les hashs avant/après ; leurs valeurs et les sorties complètes de chaque commande peuvent compléter ce dossier. Par exemple, après la réparation committée, le rapport enregistre head_contains_target: False, fresh_clone_has_local_file: False, etc. Ainsi, un relecteur peut lancer le même script dans les mêmes conditions et obtenir un résultat similaire.
Statuts et sorties : Consigner les codes retour des contrôles : git check-ignore distingue 0, 1 et 128 ; pour git ls-files, vérifier explicitement si la commande réussit ou échoue. Une sortie vide (pas de fichier) n’est pas confondue avec une erreur.
Séparation d’erreur et d’approbation : Ajouter un contrôle négatif pour vérifier qu’un code d’erreur (par ex. 128) entraîne une décision HOLD. Par exemple, exécuter git ls-files dans un dépôt inexistant renvoie 128 ; ce cas doit être traité comme échec de contrôle, pas comme succès silencieux. Cela garantit qu’une commande cassée ne passe pas à travers le gate.
Rapport : Un identifiant d’exécution (par exemple un UUID), les chemins, les hashs SHA et les informations d’environnement complètent le dossier de preuve nécessaire à la reproduction.
Les sorties détaillées ne contiennent aucun secret réel ; elles se limitent à des chemins synthétiques et des contenus d’exemple. Tout développeur peut ainsi exécuter ce script dans un environnement propre, reproduire les commandes du script et vérifier que la décision finale est bien Accept, Repair, Review ou Hold, selon les cas. Cette documentation systématique facilite la revue par un second ingénieur ou un processus CI/CD automatisé.
Distinguish a successful empty query from a broken check
Une subtilité importante : le script distingue un résultat vide valide d’une erreur de commande. Par exemple, si git ls-files est lancé sur un dépôt factice inexistant, il retournera un code d’erreur (≠0). La fonction git lève alors une exception et le rapport conserve le statut FAIL. Pour l’acceptation opérationnelle, cela impose une décision HOLD, pas une réussite silencieuse. Autrement dit, un ls-files -z qui retourne simplement une liste vide sans erreur est traité comme succès (aucun fichier hors politique), tandis qu’un échec de la commande force l’arrêt. L’ajout explicite de ce cas limite permet de s’assurer que notre gate réagit bien à un problème système ou à un usage incorrect, plutôt que d’ignorer silencieusement un état de défaillance.
12. Recover from an incorrect local action without widening the change
Si une étape de réparation est mal ciblée, nous interrompons le processus avant le commit. Par exemple, si on avait accidentellement retiré un fichier autre que runtime.local avec git rm --cached, il faudrait simplement git add ce fichier pour le restaurer dans l’index, puis relancer le diagnostic pour runtime.local. De même, si un fichier local nécessaire avait été supprimé par erreur, on le rétablit à partir d’un backup approuvé avant de continuer. L’idée est de corriger précisément l’erreur locale sans modifier le reste de l’index.
Dans tous les cas, on évite de tout réinitialiser. On ne fait pas de git reset --hard, ni de purge globale de l’index, ni de revert généralisé (qui annulerait la suppression). Par exemple, revenir en arrière (revert) réinsérerait runtime.local dans l’index, ce qui va à l’encontre de la politique. On continue simplement à cibler le même fichier. Le reste du travail en staging (éventuelles modifications d’autres fichiers) est préservé. Ce confinement du correctif local garantit qu’on ne s’écarte pas de l’objectif initial.
13. Assign ownership and make the acceptance decision
Au terme de ce processus, on forme le verdict avec les preuves recueillies. Voici une matrice simplifiée des conditions clés et des décisions correspondantes :
Situation observée | Décision |
Chemin correspondant à la règle .gitignore toujours dans l’index | REPAIR INDEX (supprimer du staging) |
Suppression d’index échoue (divergence ou erreur) ou git add -f détecté | HOLD AND RECONCILE (intervention humaine) |
Index corrigé (sans le fichier) mais ancien HEAD contenait encore le fichier | REVIEW AND COMMIT (vérifier puis commiter) |
Fichier absent du nouvel arbre HEAD et fichier local préservé + modèle intact | ACCEPT CURRENT TREE (tout est conforme) |
Ajout forcé du fichier ignoré détecté | HOLD (revoir la politique) |
Ces décisions indiquent à qui revient la responsabilité : le propriétaire du dépôt définit la politique .gitignore et confirme l’état final, le contributeur (auteur du changement) réalise la manipulation Git (rm, commit, etc.), et un relecteur indépendant (ou un système CI) valide que la politique est bien respectée avant fusion. Ce workflow d’acceptation s’intègre dans une démarche DevOps plus large où l’infrastructure et la configuration sont versionnées comme du code. Par exemple, le guide de l’infrastructure as code dans un workflow DevOps souligne l’importance de l’automatisation des pipelines CI/CD et de la revue systématique des artefacts. Ici, le pipeline pourrait inclure un hook ou une étape CI inspirée des contrôles ci-dessus et adaptée au dépôt à vérifier. Le script fourni teste ses propres dépôts jetables ; le contrôle appliqué au projet doit examiner l’index ou le commit réellement proposé. Seul un changement vérifié (par exemple, une merge request approuvée) pourra alors franchir l’étape d’acceptation. Ce contrôle automatique garantit que l’organisation maintient des fondations solides de Git, GitHub et CI/CD, comme enseigné dans la formation DevOps de Refonte Learning.
14. Develop Git and delivery foundations with Refonte Learning
Le processus décrit ici complète la formation Git par un contrôle rigoureux de contenu. Pour approfondir ces principes dans un cursus formel, le programme Ingénierie DevOps de Refonte Learning (3 mois, 12–14 h/semaine) inclut des modules sur Git et GitHub, l’intégration continue (CI/CD), Docker/Kubernetes, et l’infrastructure as code (Terraform). Les étudiants apprennent à concevoir des workflows sécurisés (comme la revue des changements et la validation d’artefacts), dans un environnement encadré par des mentors. Ce programme n’est pas une promesse de certification professionnelle, mais il offre un cadre structuré pour maîtriser les bonnes pratiques DevOps. Les lecteurs soucieux de solides fondations Git et CI/CD peuvent consulter le programme officiel pour en savoir plus.
