From d0858ce7c0a5b30abfa3169308daa3e9f79245bb Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:20:16 +0200 Subject: [PATCH] feat(lessons): the Git module practised as steps, checked on the repository Five Git exercises asked for one command out of context. They now follow the whole gesture, each step checked on the repository state: - git init: mkdir mon-projet, cd mon-projet, git init (a git init in ~ is flagged). - first commit: git status, git add ., git commit (.env stays out). - diff: git diff, git add, git diff --staged, git commit. - branch: create it, commit feature.txt on it, back on main the file is gone. - merge: git merge (fast-forward), then git branch -d. Equivalent commands count (switch -c, add -A, diff --cached, commit -a then git show). Out-of-order sequences never leave the learner stuck: a later command completes the steps, or the terminal says what to do. Every expected output comes from real Git 2.56. repoFile now returns the bytes git reads, so it compares with head and index. The five command-only validators and their tests are gone; their accepted forms are step tests now. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++++ STORY.md | 10 ++ docs/ROADMAP.md | 2 +- docs/plan.md | 2 +- src/app/data/curriculum.ts | 168 ++++++++++++++++++++++++++++----- src/app/data/exerciseSteps.ts | 32 +++++-- src/app/data/landingContent.ts | 2 +- src/app/data/validators.ts | 13 --- src/test/exerciseSteps.test.ts | 165 +++++++++++++++++++++++++++++++- src/test/lessonSolutions.ts | 13 ++- src/test/validators.test.ts | 41 +------- 11 files changed, 375 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a7b60..63efc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ --- +## 🌿 30 septembre 2026 — Le module Git se pratique comme au travail + +*Module Git · cinq exercices en plusieurs étapes · sorties vérifiées sur Git 2.56* + +Les exercices du module Git demandaient une seule commande, sortie de son contexte. Ils suivent maintenant le geste complet d'un développeur, et chaque étape est vérifiée sur l'état du dépôt. + +- **Démarrer un projet** : `mkdir mon-projet`, `cd mon-projet`, puis `git init`. Un `git init` lancé par erreur dans le dossier personnel est signalé tout de suite. +- **Le premier commit** : `git status` montre les fichiers que Git ne suit pas encore, `git add .` les prépare, `git commit` les enregistre. `.env` reste dehors, grâce au `.gitignore`. +- **Lire un diff** : `git diff`, puis `git add`, après quoi `git diff` n'affiche plus rien et `git diff --staged` montre ce que le commit va enregistrer. +- **Une branche qui isole vraiment** : créer `feature/ma-feature`, y committer `feature.txt`, revenir sur `main` et voir le fichier disparaître du dossier. +- **Fusionner puis ranger** : `git merge` (en « Fast-forward »), puis `git branch -d`. Supprimer la branche avant la fusion est refusé, comme dans le vrai Git. +- Les commandes équivalentes comptent aussi : `git switch -c`, `git add -A`, `git diff --cached`. +- **Jamais coincé, même dans le désordre.** Un `git add` tapé avant `git status`, un `git commit -a` qui saute la préparation, un commit parti sur `main` par erreur : soit une commande suivante termine l'exercice, soit le terminal explique comment s'en sortir. + +--- + ## 🧩 29 septembre 2026 — Des exercices en plusieurs étapes, vérifiés sur le terminal *Leçons · exercices · page de leçon* diff --git a/STORY.md b/STORY.md index 65ba173..f02bfb5 100644 --- a/STORY.md +++ b/STORY.md @@ -21,6 +21,16 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod --- +## Git, geste par geste (30 septembre 2026) + +Une fois le moteur d'étapes en place, le module Git était le premier candidat. Ses exercices demandaient `git init`, `git add .` ou `git merge`, une commande à la fois, alors que Git ne se comprend qu'en enchaînant : on prépare, on enregistre, on compare, on change de branche. + +J'ai rejoué chaque parcours dans un vrai Git avant d'écrire les étapes. Deux détails m'auraient échappé en lisant la documentation. Quand on change de branche avec un fichier préparé mais pas encore commité, Git l'emporte avec lui et affiche une ligne `A` suivie du nom du fichier. Et `git branch -d` sur une branche pas encore fusionnée ne se contente pas de refuser : il propose `git branch -D`. Le simulateur faisait déjà les deux ; les tests le vérifient maintenant, avec la sortie exacte du vrai Git. + +Cinq validateurs, des expressions régulières qui ne lisaient que la commande tapée, ont disparu. Leurs cas utiles (`git switch -c`, `git add -A`, `git diff --cached`) sont devenus des tests des étapes : ce qui compte, c'est l'état du dépôt, pas l'orthographe de la commande. + +--- + ## Un exercice qui s'arrêtait au moment où le travail commence (29 septembre 2026) La leçon sur les conflits de merge en provoquait enfin un vrai. Mais l'exercice se validait sur `git merge`, et la page partait vers la leçon suivante deux secondes et demie plus tard. L'élève voyait `CONFLICT`, puis plus rien : la résolution, qui est tout l'objet de la leçon, restait une lecture. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82f10ee..9b0cca9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap — Terminal Learning -> **Last updated:** 29 September 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry, #402). **Multi-step exercises checked on the terminal state** (the conflict lesson now resolves a real conflict end to end; no more auto-advance, a « Suivant » button instead). Next: more exercises as steps (Git module), then the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…). +> **Last updated:** 29 September 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry, #402). **Multi-step exercises checked on the terminal state** (the conflict lesson now resolves a real conflict end to end; no more auto-advance, a « Suivant » button instead). **Git module as steps** (init, first commit, diff, branch, merge). Next: GitHub module as steps (remote, push, PR), then the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…). > > **Previous update (26 September 2026 CEST)**: 🩺 **Full check-up (P1 terminal fidelity, THI-353)**: #389 a real shell layer (`;` `&&` `||`, pipelines, every redirection) — **198/198 exercises validate with no red line**; #390 output matches a real shell (`pwd`, `cd -`, `ls` in a pipe, `wc`, `apt`); #391 permanent replay of every lesson's theory (754 commands); #392 `cp`/`mv` into a directory (a `mv file .` wiped the home directory) and native Windows paths in PowerShell — theory gaps **174 → 162**, bash shown on Windows **39 → 37**; #393 audit agents refreshed (13 Opus / 8 Sonnet) + new `terminal-fidelity-auditor` (simulator vs real bash / PowerShell). Public landing roadmap corrected (LTI is a disabled spike, OpenAI BYOK blocked by CORS). Next: PowerShell error messages, `/app/reference` pedagogy, remaining theory gaps (Git history, `gh`, jobs). > diff --git a/docs/plan.md b/docs/plan.md index f3f70f3..411d52f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,6 +1,6 @@ # Terminal Learning — Plan de lancement public -> Dernière mise à jour : **29 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · #401 page d'accueil : ne promet plus que des commandes simulées · #402 module « Personnaliser son terminal » annoncé · **exercices en plusieurs étapes vérifiés sur l'état du terminal** (le conflit Git se résout de A à Z, plus de saut automatique : bouton « Suivant »). Suite : migrer d'autres exercices en étapes (module Git), puis les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…). +> Dernière mise à jour : **29 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · #401 page d'accueil : ne promet plus que des commandes simulées · #402 module « Personnaliser son terminal » annoncé · **exercices en plusieurs étapes vérifiés sur l'état du terminal** (le conflit Git se résout de A à Z, plus de saut automatique : bouton « Suivant »). · **module Git en étapes** (init, premier commit, diff, branche, fusion). Suite : module GitHub (remote, push, PR), puis les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…). > Précédent : **26 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353)** : #389 vraie couche shell (**198/198 exercices sans ligne rouge**) · #390 sorties d'un vrai shell · #391 rejeu permanent de la théorie · #392 `cp`/`mv` vers un dossier + chemins Windows natifs (écarts théorie **174 → 162**) · #393 agents d'audit remis à jour + `terminal-fidelity-auditor` · roadmap publique corrigée. Suite : messages d'erreur PowerShell, page Référence, écarts restants. > Statut global : **Phase 5 EN COURS** — Curriculum Expansion : 11 modules ✅, 66 leçons, **2500+ tests unitaires** + 176 E2E — **Vision consolidée** : LTI-first (ADR-001), BYOK OpenRouter 4-tiers (ADR-002), TTFR KPI central (ADR-003), Classroom Composer UI (ADR-004), AI Tutor V1 décisions gelées (ADR-005 — stockage, rate-limit, guardrails), Solo-sustainable practices (ADR-006), tuteur IA socratique dès A1, i18n FR/NL/EN — Architecture stratégique précédente (THI-35) : Terminal Sentinel (Phase 5.5) ✅, RBAC complet (Phase 7) ✅, Admin Panel (Phase 9), PWA avancée (Phase finale) — **Epic Web 2026 Compliance** (THI-96) : 6/8 sub-issues livrées (THI-97 → THI-102), reste Desktop a11y + CSS moderne 2026 — **Phase 7b (AI Tutor V1) ✅ COMPLETE + V1.5 SÉQUENCÉE** : THI-115 ✅, THI-109 ✅ (gate-zero guardrail), THI-110 ✅ (keyManager AES-GCM), THI-120 ✅ (Sentry scrubber), **THI-111 ✅ COEUR FONCTIONNEL** (PR #188 — sanitizer + 4 providers + panel + 287 AI tests, audits guardrail 9.4/10 + security 8.8/10 + ui A11y exemplary), **THI-147 ✅ FIX SAFE-AREA iPhone PWA** (PR #189), **panel actif en Production** (`VITE_AI_TUTOR_ENABLED=true` + `VITE_AI_TUTOR_OPENROUTER_MODEL=anthropic/claude-haiku-4-5` activés Production+Preview par @cowork) — **Sprint sécurité 1-2 mai 2026 ✅ CLOS** : audit security-auditor 8.1/10 → ~8.6/10 post-sprint, 11 PRs livrées (#168 à #178), 5 HIGH/MEDIUM Done (THI-133/134/135/137/140), 4 MEDIUM ciblés en backlog (THI-136/138/139/112), agent `route-attack-auditor` créé — **Session 4 mai 2026** : THI-111 livré + THI-147 livré + 5 tickets V1.5 backlog créés (THI-142 lessonContext renforcé HIGH, THI-143 frustration heuristic + détection sémantique user MEDIUM, THI-144 system prompt v1.1.0 + ADR-008 + eval suite MEDIUM, THI-145 chat assistant role-based Phase 9+ LOW, THI-146 modèle défaut Haiku HIGH déjà actif via env var, THI-148 extend tutor scope platform meta-questions V1.0.1 P1 1h30 estimé) — **Posture validée** : pas de rush deadline, qualité/scalabilité/perf non négociables, plan respecté en ordre, **mea culpa explicite** à chaque round trio @thierry / @cc-terminallearning / @cowork (estimation 30 min → 1h30, privacy `userProgress` retirée V1.0.1, hypothèse `transform` mobile réfutée par diagnostic Chrome DevTools MCP) — **Verdict empirique Haiku 4.5 (capturé 4 mai 21h par @cowork via Chrome MCP)** : 5 tests qualitatifs, score moyen **9.3/10** — Test 1 méta-plateforme 8/10, Test 2 fichiers cachés 9/10, Test 3 hallucination 9.5/10, Test 4 frustration 10/10 (Haiku bascule mode direct AUTONOMEMENT), Test 5 jailbreak 10/10. **Reprio backlog tranchée @cowork** : THI-146 ✅ SUCCÈS validé, **THI-142 → Low** (Haiku gère déjà bien le contexte leçon), **THI-143 → Low** (Haiku résout naturellement la frustration via compréhension contextuelle), **THI-148 P1 INCHANGÉ** (scope ≠ modèle prouvé Test 1, GO IMMÉDIAT), THI-144 P2 Medium (peut englober THI-148 + eval suite), THI-145 P3 Low (Phase 9+). **ROI méthode scientifique** : ~4-6h économisées (THI-142/143 reportés V2). **Next ordonné — décision @cowork 5 mai matin** : (1) **THI-150 EN COURS** (12ᵉ agent `mobile-responsive-auditor`, ex-brick 3a de THI-149 epic Done, 11 sections / ≥48 checkpoints + bonus Section 11 Desktop Preservation + checkpoints BUG-FAB-001 visibility/contrast/detachment), (2) THI-151 audit Playwright WebKit + matrice bugs (ex-brick 3b), (3) THI-152 mini-PRs fix séquentielles (ex-brick 3c, critère ABSOLU **ne pas casser desktop**), (4) THI-148 extend tutor scope méta-plateforme V1.0.1 (1h30 honnête, scope statique platformContext, bump v1.0.0→v1.0.1, audit guardrail Règle 10 obligatoire), (5) **THI-144 enrichi P1** system prompt v1.1.0 + ADR-008 + eval suite (intègre 5 micro-frictions identifiées par cross-validation ChatGPT sur session 8 tours @thierry — compound questions, sur-explication internal mechanics, indices répétés, platformContext absent confirme THI-148, conclusion ouverte), (6) THI-112 onboarding AiKeySetup + picker modèle curated, (7) THI-114 Web Worker isolation, (8) THI-145 chat role-based Phase 9+. **THI-142/143 reportés V2** (Haiku 9.3/10 résout 80% naturellement). diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 299a9d2..a1e916a 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -7,7 +7,7 @@ import { validateRedirectionSortie, validatePipes, validateStderr, validateTee, validateEnvVars, validatePathVariable, validateShellConfig, validateDotenv, validateScripts, validateCron, validatePing, validateCurl, validateWget, validateDns, validateSsh, validateScp, - validateGitInit, validateGitConfig, validateGitAddCommit, validateGitStatusLog, validateGitDiffGitignore, validateGitBranch, validateGitMerge, + validateGitConfig, validateGitStatusLog, validateGitRemote, validateGitPushPull, validateGitFetchClone, validatePullRequests, validateMergeStrategies, validateGithubActions, validateAiHelp, validateAiHelpCapabilities, validateAiHelpLimits, validateAiHelpPrompts, validateAiHelpContext, validateAiHelpValidate, validateAiHelpDebug, validateAiHelpSecurity, @@ -18,7 +18,9 @@ import { type LessonSetup, } from './lessonSetup'; import type { OutputLine, TerminalState } from './commands/types'; -import { hasConflictMarkers, inHomeDir, printedError, repoFile, stepAccepts } from './exerciseSteps'; +import { + hasConflictMarkers, homeDirExists, inHomeDir, printed, printedError, repoFile, repoInHomeDir, stepAccepts, +} from './exerciseSteps'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; export interface ContentBlock { @@ -2305,10 +2307,32 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Initialisez un nouveau dépôt Git avec `git init`.', - hint: 'Tapez: git init', - validate: validateGitInit, - successMessage: 'Parfait ! Votre premier dépôt Git est initialisé. Le dossier .git/ a été créé.', + instruction: 'Démarrez un projet comme un développeur : créez son dossier, entrez-y, puis initialisez-y un dépôt Git.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', + steps: [ + { + instruction: 'Créez le dossier du projet avec `mkdir mon-projet`.', + hint: 'Tapez : mkdir mon-projet', + check: ({ state }) => homeDirExists(state, 'mon-projet'), + warn: ({ state }) => repoInHomeDir(state) + ? 'git init vient de créer un dépôt dans votre dossier personnel lui-même : Git suivrait tout ce qu\'il contient. Cliquez sur « Réinitialiser », puis créez d\'abord le dossier du projet.' + : undefined, + }, + { + instruction: 'Entrez dans le dossier avec `cd mon-projet`.', + hint: 'Tapez : cd mon-projet', + check: ({ state }) => inHomeDir(state, 'mon-projet'), + warn: ({ state }) => repoInHomeDir(state) + ? 'git init vient de créer un dépôt dans votre dossier personnel lui-même : Git suivrait tout ce qu\'il contient. Cliquez sur « Réinitialiser » et entrez d\'abord dans mon-projet.' + : undefined, + }, + { + instruction: 'Initialisez le dépôt avec `git init`. Git crée le dossier caché `.git`, où il rangera tout l\'historique.', + hint: 'Tapez : git init', + check: ({ state }) => repoInHomeDir(state, 'mon-projet'), + }, + ], + successMessage: 'Votre premier dépôt Git est prêt, dans son propre dossier. Git y a créé le dossier caché `.git` : c\'est lui, le dépôt. Le supprimer effacerait tout l\'historique, jamais vos fichiers.', }, }, { @@ -2386,11 +2410,29 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Ajoutez tous les fichiers du répertoire courant à la zone de staging avec `git add .`.', - hint: 'Tapez: git add .', - validate: validateGitAddCommit, + instruction: 'Faites le premier commit du projet : regardez ce que Git voit, préparez les fichiers, puis enregistrez-les.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoEmpty, - successMessage: 'Fichiers stagés ! Maintenant vous pouvez les committer avec git commit -m "message".', + steps: [ + { + instruction: 'Regardez ce que Git voit avec `git status` : des fichiers qu\'il ne suit pas encore (« Untracked files »).', + hint: 'Tapez : git status', + // Any git status counts: typed after `git add .`, it shows « Changes to be committed » instead. + check: ({ lines }) => /^On branch /m.test(printed(lines)), + }, + { + instruction: 'Ajoutez tous les fichiers à la zone de staging avec `git add .`.', + hint: 'Tapez : git add .', + check: ({ state }) => ['README.md', 'index.html', 'script.sh', '.gitignore'] + .every((f) => state.git?.stagedFiles.includes(f) || state.git?.head?.[f] !== undefined), + }, + { + instruction: 'Enregistrez-les dans un commit avec `git commit -m "feat: premier commit"`.', + hint: 'Tapez : git commit -m "feat: premier commit"', + check: ({ state }) => (state.git?.commits.length ?? 0) > 0 && state.git?.stagedFiles.length === 0, + }, + ], + successMessage: 'Premier commit enregistré ! Remarquez que `.env` n\'en fait pas partie : le `.gitignore` l\'a écarté dès le `git add .`. `git log --oneline` montre maintenant votre commit.', }, }, { @@ -2466,11 +2508,39 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Visualisez les différences actuelles dans votre dépôt avec `git diff`.', - hint: 'Tapez: git diff', - validate: validateGitDiffGitignore, + instruction: 'Suivez une modification de `README.md` jusqu\'au commit, en regardant le diff à chaque étape.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoWithChange, - successMessage: 'Vous savez lire un diff Git. Les lignes en vert (+) sont les ajouts, en rouge (-) les suppressions.', + steps: [ + { + instruction: 'Voyez ce qui a changé avec `git diff` : en rouge (-) la ligne retirée, en vert (+) la ligne ajoutée.', + hint: 'Tapez : git diff', + check: ({ lines }) => printed(lines).includes('diff --git a/README.md b/README.md'), + warn: ({ state }) => { + if ((state.git?.commits.length ?? 0) >= 2) return 'La modification est déjà commitée : git diff n\'a plus rien à montrer. git show affiche le diff du dernier commit.'; + if (state.git?.stagedFiles.includes('README.md')) return 'README.md est déjà préparé : git diff n\'affiche plus rien. git diff --staged montre le changement.'; + if (repoFile(state, 'README.md') === state.git?.head?.['README.md']) return 'La modification de README.md a été annulée : il n\'y a plus rien à comparer. Cliquez sur « Réinitialiser » pour la retrouver.'; + return undefined; + }, + }, + { + instruction: 'Préparez la modification avec `git add README.md`. Tapez `git diff` ensuite si vous voulez : il n\'affiche plus rien.', + hint: 'Tapez : git add README.md', + check: ({ state }) => Boolean(state.git?.stagedFiles.includes('README.md')) || (state.git?.commits.length ?? 0) >= 2, + }, + { + instruction: 'Voyez ce que le prochain commit enregistrera avec `git diff --staged`.', + hint: 'Tapez : git diff --staged', + // Already committed (git commit -a): git show printed the same diff. + check: ({ lines, state }) => printed(lines).includes('diff --git a/README.md b/README.md') || (state.git?.commits.length ?? 0) >= 2, + }, + { + instruction: 'Enregistrez la modification avec `git commit -m "docs: précise le README"`.', + hint: 'Tapez : git commit -m "docs: précise le README"', + check: ({ state }) => (state.git?.commits.length ?? 0) >= 2 && state.git?.stagedFiles.length === 0, + }, + ], + successMessage: 'Vous savez lire un diff, et où il regarde : `git diff` compare vos fichiers à la zone de staging, `git diff --staged` compare la zone de staging au dernier commit. Après le commit, les deux sont vides.', }, }, { @@ -2507,11 +2577,51 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Créez une nouvelle branche `feature/ma-feature` et basculez dessus avec `git checkout -b feature/ma-feature`.', - hint: 'Tapez: git checkout -b feature/ma-feature', - validate: validateGitBranch, + instruction: 'Voyez une branche isoler votre travail : créez-la, commitez-y un fichier, puis revenez sur `main`.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoWithCommit, - successMessage: 'Branche créée et activée ! Vous développez maintenant en isolation totale de main.', + steps: [ + { + instruction: 'Créez la branche `feature/ma-feature` et basculez dessus avec `git checkout -b feature/ma-feature`.', + hint: 'Tapez : git checkout -b feature/ma-feature', + check: ({ state }) => state.git?.branch === 'feature/ma-feature', + warn: ({ state }) => state.git?.branch === 'main' && state.git.head?.['feature.txt'] !== undefined + ? 'feature.txt vient d\'être commité sur main, avant la création de la branche. Cliquez sur « Réinitialiser » pour recommencer.' + : undefined, + }, + { + instruction: 'Créez un fichier sur cette branche avec `echo "Nouvelle fonctionnalité" > feature.txt`.', + instructionByEnv: { + windows: 'Créez un fichier sur cette branche avec `Set-Content feature.txt "Nouvelle fonctionnalité"`.', + }, + hint: 'Tapez : echo "Nouvelle fonctionnalité" > feature.txt', + hintByEnv: { windows: 'Tapez : Set-Content feature.txt "Nouvelle fonctionnalité"' }, + check: ({ state }) => repoFile(state, 'feature.txt') !== null, + }, + { + instruction: 'Préparez-le avec `git add feature.txt`.', + hint: 'Tapez : git add feature.txt', + check: ({ state }) => Boolean(state.git?.stagedFiles.includes('feature.txt')), + }, + { + instruction: 'Enregistrez-le sur la branche avec `git commit -m "feat: ajoute feature.txt"`.', + hint: 'Tapez : git commit -m "feat: ajoute feature.txt"', + check: ({ state }) => state.git?.branch === 'feature/ma-feature' && state.git.head?.['feature.txt'] !== undefined, + warn: ({ state }) => { + const git = state.git; + if (git?.branch !== 'main') return undefined; + if (git.head?.['feature.txt'] !== undefined) return 'Ce commit est parti sur main, pas sur la branche : main contient maintenant feature.txt. Cliquez sur « Réinitialiser » pour recommencer.'; + if (git.stagedFiles.includes('feature.txt')) return 'feature.txt est préparé mais pas encore commité, et vous êtes sur main. Revenez avec git switch feature/ma-feature (Git emporte le fichier préparé avec vous), puis commitez-le.'; + return undefined; + }, + }, + { + instruction: 'Revenez sur la branche principale avec `git switch main`, puis regardez les fichiers : `feature.txt` n\'y est pas.', + hint: 'Tapez : git switch main', + check: ({ state }) => state.git?.branch === 'main' && repoFile(state, 'feature.txt') === null && Boolean(state.git.refs?.['feature/ma-feature']), + }, + ], + successMessage: 'feature.txt a disparu de votre dossier : il n\'existe que sur la branche `feature/ma-feature`. `git switch feature/ma-feature` le fait revenir. C\'est tout l\'intérêt d\'une branche : travailler sans toucher à `main`.', }, }, { @@ -2545,11 +2655,25 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Fusionnez la branche `feature/ma-feature` dans la branche courante avec `git merge feature/ma-feature`.', - hint: 'Tapez: git merge feature/ma-feature', - validate: validateGitMerge, + instruction: 'Intégrez le travail de la branche `feature/ma-feature` dans `main`, puis rangez la branche devenue inutile.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoWithBranch('feature/ma-feature'), - successMessage: 'Fusion réussie ! Le travail de la branche est maintenant intégré. C\'est le coeur du workflow Git en entreprise.', + steps: [ + { + instruction: 'Fusionnez la branche dans `main` avec `git merge feature/ma-feature`. Comme `main` n\'a pas bougé entre-temps, Git avance simplement (« Fast-forward »).', + hint: 'Tapez : git merge feature/ma-feature', + check: ({ state }) => state.git?.branch === 'main' && state.git.head?.['ma-feature.html'] !== undefined, + warn: ({ state }) => !state.git?.refs?.['feature/ma-feature'] && state.git?.head?.['ma-feature.html'] === undefined + ? 'La branche feature/ma-feature n\'existe plus (supprimée avec -D, ou renommée) et son travail n\'a pas été fusionné. C\'est pour éviter cela que git branch -d refuse. Cliquez sur « Réinitialiser » pour recommencer.' + : undefined, + }, + { + instruction: 'Supprimez la branche fusionnée avec `git branch -d feature/ma-feature`. Son travail est déjà dans `main`, rien n\'est perdu.', + hint: 'Tapez : git branch -d feature/ma-feature', + check: ({ state }) => !state.git?.refs?.['feature/ma-feature'] && state.git?.head?.['ma-feature.html'] !== undefined, + }, + ], + successMessage: 'Fusion réussie et branche rangée ! `git branch -d` refuse de supprimer une branche dont le travail n\'est pas encore fusionné : c\'est un garde-fou. C\'est le coeur du workflow Git en entreprise.', }, }, ], diff --git a/src/app/data/exerciseSteps.ts b/src/app/data/exerciseSteps.ts index 9589ac5..095eb6b 100644 --- a/src/app/data/exerciseSteps.ts +++ b/src/app/data/exerciseSteps.ts @@ -19,16 +19,36 @@ export const printedError = (lines: OutputLine[]): boolean => lines.some((l) => export const stepAccepts = (validate: ValidateFn, { command, env, lines }: ExerciseCheckContext): boolean => exerciseAccepts(validate, command, env) && !printedError(lines); -/** The shell stands in `~/` (the same path on every environment). */ -export const inHomeDir = (state: TerminalState, ...segments: string[]): boolean => - state.cwd.join('/') === ['home', state.user, ...segments].join('/') || - state.cwd.join('/') === ['home', 'user', ...segments].join('/'); +/** `path` is `~/` (the same path on every environment). */ +const isHomePath = (state: TerminalState, path: string[] | undefined, segments: string[]): boolean => + path !== undefined && + (path.join('/') === ['home', state.user, ...segments].join('/') || path.join('/') === ['home', 'user', ...segments].join('/')); -/** A file of the working tree, by its path in the repository (or from the current directory without one). */ +/** The shell stands in `~/`. */ +export const inHomeDir = (state: TerminalState, ...segments: string[]): boolean => isHomePath(state, state.cwd, segments); + +/** `~/` is a directory. */ +export const homeDirExists = (state: TerminalState, ...segments: string[]): boolean => + nodeAt(state.root, ['home', 'user', ...segments])?.type === 'directory'; + +/** A Git repository was initialised in `~/` (no segment: in `~` itself). */ +export const repoInHomeDir = (state: TerminalState, ...segments: string[]): boolean => + Boolean(state.git?.initialized) && isHomePath(state, state.git?.repoPath, segments); + +/** What a command printed, as one text. */ +export const printed = (lines: OutputLine[]): string => lines.map((l) => l.text).join('\n'); + +/** + * A file of the working tree, by its path in the repository (or from the + * current directory without one), as git reads it: the simulator stores text + * without its final newline, git's `head` and `index` keep it. So the result + * compares directly with them. + */ export function repoFile(state: TerminalState, path: string): string | null { const base = state.git?.repoPath ?? state.cwd; const node = nodeAt(state.root, [...base, ...path.split('/')]); - return node?.type === 'file' ? node.content : null; + if (node?.type !== 'file') return null; + return node.content ? `${node.content}\n` : ''; } /** Git's conflict markers, each at the start of a line. */ diff --git a/src/app/data/landingContent.ts b/src/app/data/landingContent.ts index a6642cf..5f97eb8 100644 --- a/src/app/data/landingContent.ts +++ b/src/app/data/landingContent.ts @@ -136,7 +136,7 @@ export const ROADMAP_IN_PROGRESS: readonly RoadmapGroup[] = [ { group: 'Curriculum', items: [ - 'Exercices en plusieurs étapes, vérifiés sur l\'état du terminal (le premier : résoudre un vrai conflit Git)', + 'Exercices en plusieurs étapes, vérifiés sur l\'état du terminal (déjà : les gestes de base de Git et la résolution d\'un conflit)', 'Une variante PowerShell pour chaque leçon', ], }, diff --git a/src/app/data/validators.ts b/src/app/data/validators.ts index b805996..da99889 100644 --- a/src/app/data/validators.ts +++ b/src/app/data/validators.ts @@ -280,23 +280,10 @@ export const validateSsh: ValidateFn = (cmd) => { export const validateScp: ValidateFn = (cmd) => /^scp\s+.+\s+.+@.+:.+/.test(cmd.trim().toLowerCase()); -export const validateGitInit: ValidateFn = (cmd) => /^git\s+init(\s+.*)?$/.test(cmd.trim().toLowerCase()); - export const validateGitConfig: ValidateFn = (cmd) => /^git\s+config\s+(--list|--global\s+user\.)/.test(cmd.trim().toLowerCase()); -export const validateGitAddCommit: ValidateFn = (cmd) => /^git\s+add\s+(\.|--all|-a|-p)/.test(cmd.trim().toLowerCase()); - export const validateGitStatusLog: ValidateFn = (cmd) => /^git\s+status(\s+(-\w+|--\w[\w-]*))*$/.test(cmd.trim().toLowerCase()); -export const validateGitDiffGitignore: ValidateFn = (cmd) => /^git\s+diff(\s+.*)?$/.test(cmd.trim().toLowerCase()); - -export const validateGitBranch: ValidateFn = (cmd) => { - const c = cmd.trim().toLowerCase(); - return /^git\s+checkout\s+-b\s+\S+/.test(c) || /^git\s+switch\s+-c\s+\S+/.test(c); - }; - -export const validateGitMerge: ValidateFn = (cmd) => /^git\s+merge\s+\S+/.test(cmd.trim().toLowerCase()); - export const validateGitRemote: ValidateFn = (cmd) => /^git\s+remote\s+add\s+\S+\s+https?:\/\/\S+/.test(cmd.trim().toLowerCase()); export const validateGitPushPull: ValidateFn = (cmd) => /^git\s+push(\s+-u\s+\S+\s+\S+|\s+\S+\s+\S+|\s*)$/.test(cmd.trim().toLowerCase()); diff --git a/src/test/exerciseSteps.test.ts b/src/test/exerciseSteps.test.ts index 65ab431..92598c1 100644 --- a/src/test/exerciseSteps.test.ts +++ b/src/test/exerciseSteps.test.ts @@ -4,7 +4,10 @@ * is the reference case: every command and output below was checked against * real Git 2.56 in a throwaway repository (merge, checkout --theirs, add, * commit --no-edit, and the two ways learners go wrong: `git add` with the - * markers still in, `git merge --abort`). + * markers still in, `git merge --abort`). The Git module's expected outputs + * (`branch -d` refused before the merge, a staged file carried along by + * `git switch`, an empty `git diff` after `git add`) come from the same kind of + * sandbox, run on 30 September 2026. */ import { describe, it, expect } from 'vitest'; import { curriculum, type EnvId, type Exercise } from '../app/data/curriculum'; @@ -112,6 +115,166 @@ describe('the conflict lesson, step by step', () => { }); }); +describe('the Git module, step by step', () => { + it('git init: the repository lands in the project folder, not in ~', () => { + const t = session(find('git', 'git-init')); + const wrong = t.type('git init'); + expect(wrong.index).toBe(0); + expect(wrong.messages[0].text).toMatch(/^⚠ git init vient de créer un dépôt dans votre dossier personnel/); + const right = session(find('git', 'git-init')); + right.type('mkdir mon-projet'); + right.type('cd mon-projet'); + const init = right.type('git init'); + expect(init.output).toEqual(['Initialized empty Git repository in /home/user/mon-projet/.git/']); + expect(init.completed).toBe(true); + }); + + it('git add & commit: any way of staging everything counts (git add -A)', () => { + const t = session(find('git', 'git-add-commit')); + t.type('git status'); + expect(t.type('git add -A').index).toBe(2); + expect(t.type('git commit -m "feat: premier commit"').completed).toBe(true); + }); + + it('git add & commit: committing nothing staged does not count', () => { + const t = session(find('git', 'git-add-commit')); + t.type('git status'); + const commit = t.type('git commit -m "feat: premier commit"'); + expect(commit.index).toBe(1); + }); + + it('git diff: after git add, only the staged diff shows the change (--cached is the same)', () => { + const t = session(find('git', 'git-diff-gitignore')); + t.type('git diff'); + t.type('git add README.md'); + // Real git: nothing left between the files and the staging area. + const plain = t.type('git diff'); + expect(plain.output).toEqual([]); + expect(plain.index).toBe(2); + expect(t.type('git diff --cached').index).toBe(3); + expect(t.type('git commit -m "docs: précise le README"').completed).toBe(true); + }); + + it('git branch: switch -c works too, and back on main the committed file is gone', () => { + const t = session(find('git', 'git-branch')); + expect(t.type('git switch -c feature/ma-feature').index).toBe(1); + t.type('echo "Nouvelle fonctionnalité" > feature.txt'); + t.type('git add feature.txt'); + t.type('git commit -m "feat: ajoute feature.txt"'); + const back = t.type('git switch main'); + expect(back.output).toEqual(["Switched to branch 'main'"]); + expect(back.completed).toBe(true); + expect(t.type('ls').output).toEqual(['README.md index.html script.sh']); + }); + + it('git branch: a file left uncommitted follows the learner back to main, so it does not count', () => { + const t = session(find('git', 'git-branch')); + t.type('git checkout -b feature/ma-feature'); + t.type('echo "Nouvelle fonctionnalité" > feature.txt'); + t.type('git add feature.txt'); + const back = t.type('git switch main'); + // Real git names the staged file it carries along. + expect(back.output).toEqual(['A\tfeature.txt', "Switched to branch 'main'"]); + expect(back.index).toBe(3); + }); + + it('git merge: deleting the branch before merging is refused by Git and does not count', () => { + const t = session(find('git', 'git-merge')); + const early = t.type('git branch -d feature/ma-feature'); + expect(early.output).toEqual([ + "error: the branch 'feature/ma-feature' is not fully merged", + "hint: If you are sure you want to delete it, run 'git branch -D feature/ma-feature'", + 'hint: Disable this message with "git config set advice.forceDeleteBranch false"', + ]); + expect(early.index).toBe(0); + t.type('git merge feature/ma-feature'); + expect(t.type('git branch -d feature/ma-feature').completed).toBe(true); + }); +}); + +// A learner who types the commands in another order must never be stuck: +// either a later command still completes the steps, or the terminal says what to do. +describe('the Git module, out of order', () => { + it('git init in ~ after mkdir is flagged at the cd step too', () => { + const t = session(find('git', 'git-init')); + t.type('mkdir mon-projet'); + const wrong = t.type('git init'); + expect(wrong.index).toBe(1); + expect(wrong.messages[0].text).toMatch(/^⚠ git init vient de créer un dépôt dans votre dossier personnel/); + }); + + it('first commit: add and commit before looking, then git status still completes it', () => { + const t = session(find('git', 'git-add-commit')); + expect(t.type('git add .').index).toBe(0); + expect(t.type('git commit -m "feat: premier commit"').index).toBe(0); + expect(t.type('git status').completed).toBe(true); + }); + + it('diff: after git add, git diff shows nothing and the terminal points to --staged', () => { + const t = session(find('git', 'git-diff-gitignore')); + t.type('git add README.md'); + const empty = t.type('git diff'); + expect(empty.output).toEqual([]); + expect(empty.messages[0].text).toMatch(/^⚠ README\.md est déjà préparé .* git diff --staged montre le changement\.$/); + expect(t.type('git diff --staged').index).toBe(3); + expect(t.type('git commit -m "docs: précise le README"').completed).toBe(true); + }); + + it('diff: git commit -a first, then git show completes it', () => { + const t = session(find('git', 'git-diff-gitignore')); + const commit = t.type('git commit -am "docs: précise le README"'); + expect(commit.index).toBe(0); + expect(commit.messages[0].text).toMatch(/^⚠ La modification est déjà commitée .* git show affiche le diff du dernier commit\.$/); + expect(t.type('git show').completed).toBe(true); + }); + + it('branch: the staged file carried to main gets advice, and following it completes the exercise', () => { + const t = session(find('git', 'git-branch')); + t.type('git checkout -b feature/ma-feature'); + t.type('echo "Nouvelle fonctionnalité" > feature.txt'); + t.type('git add feature.txt'); + const carried = t.type('git switch main'); + expect(carried.messages[0].text).toMatch(/^⚠ feature\.txt est préparé mais pas encore commité, et vous êtes sur main\. Revenez avec git switch feature\/ma-feature/); + t.type('git switch feature/ma-feature'); + expect(t.type('git commit -m "feat: ajoute feature.txt"').index).toBe(4); + expect(t.type('git switch main').completed).toBe(true); + }); + + it('branch: a commit made on main by mistake is flagged', () => { + const t = session(find('git', 'git-branch')); + t.type('git checkout -b feature/ma-feature'); + t.type('git switch main'); + t.type('echo "Nouvelle fonctionnalité" > feature.txt'); + t.type('git add feature.txt'); + const onMain = t.type('git commit -m "feat: ajoute feature.txt"'); + expect(onMain.index).toBe(3); + expect(onMain.messages[0].text).toMatch(/^⚠ Ce commit est parti sur main/); + }); + + it('merge: git branch -D before the merge is flagged', () => { + const t = session(find('git', 'git-merge')); + const gone = t.type('git branch -D feature/ma-feature'); + expect(gone.output[0]).toMatch(/^Deleted branch feature\/ma-feature \(was [0-9a-f]{7}\)\.$/); + expect(gone.messages[0].text).toMatch(/^⚠ La branche feature\/ma-feature n'existe plus \(supprimée avec -D, ou renommée\)/); + }); + + it('branch: feature.txt committed on main before the branch exists is flagged', () => { + const t = session(find('git', 'git-branch')); + t.type('echo "Nouvelle fonctionnalité" > feature.txt'); + t.type('git add feature.txt'); + const early = t.type('git commit -m "feat: ajoute feature.txt"'); + expect(early.index).toBe(0); + expect(early.messages[0].text).toMatch(/^⚠ feature\.txt vient d'être commité sur main, avant la création de la branche/); + }); + + it('diff: a change discarded with git restore is flagged', () => { + const t = session(find('git', 'git-diff-gitignore')); + const restored = t.type('git restore README.md'); + expect(restored.index).toBe(0); + expect(restored.messages[0].text).toMatch(/^⚠ La modification de README\.md a été annulée/); + }); +}); + describe('one-command exercises', () => { it('are a single step, done by the command the lesson asks for', () => { const pwd = find('navigation', 'pwd'); diff --git a/src/test/lessonSolutions.ts b/src/test/lessonSolutions.ts index fb25db4..b89e350 100644 --- a/src/test/lessonSolutions.ts +++ b/src/test/lessonSolutions.ts @@ -94,13 +94,16 @@ export const LESSON_SOLUTIONS: Record = { 'reseau/ssh': { all: ['ssh-keygen -t ed25519'] }, 'reseau/scp': { all: ['scp fichier.txt user@serveur.example.com:/home/user/'] }, // ── git ── - 'git/git-init': { all: ['git init'] }, + 'git/git-init': { all: ['mkdir mon-projet', 'cd mon-projet', 'git init'] }, 'git/git-config': { all: ['git config --list'] }, - 'git/git-add-commit': { all: ['git add .'] }, + 'git/git-add-commit': { all: ['git status', 'git add .', 'git commit -m "feat: premier commit"'] }, 'git/git-status-log': { all: ['git status'] }, - 'git/git-diff-gitignore': { all: ['git diff'] }, - 'git/git-branch': { all: ['git checkout -b feature/ma-feature'] }, - 'git/git-merge': { all: ['git merge feature/ma-feature'] }, + 'git/git-diff-gitignore': { all: ['git diff', 'git add README.md', 'git diff --staged', 'git commit -m "docs: précise le README"'] }, + 'git/git-branch': { + all: ['git checkout -b feature/ma-feature', 'echo "Nouvelle fonctionnalité" > feature.txt', 'git add feature.txt', 'git commit -m "feat: ajoute feature.txt"', 'git switch main'], + windows: ['git checkout -b feature/ma-feature', 'Set-Content feature.txt "Nouvelle fonctionnalité"', 'git add feature.txt', 'git commit -m "feat: ajoute feature.txt"', 'git switch main'], + }, + 'git/git-merge': { all: ['git merge feature/ma-feature', 'git branch -d feature/ma-feature'] }, // ── github-collaboration ── 'github-collaboration/git-remote': { all: ['git remote add origin https://github.com/user/mon-projet.git'] }, 'github-collaboration/git-push-pull': { all: ['git push -u origin main'] }, diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index 4760dae..cc88f28 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -40,13 +40,8 @@ import { validateDns, validateSsh, validateScp, - validateGitInit, validateGitConfig, - validateGitAddCommit, validateGitStatusLog, - validateGitDiffGitignore, - validateGitBranch, - validateGitMerge, validateGitRemote, validateGitPushPull, validateGitFetchClone, @@ -403,25 +398,12 @@ describe('validateScp', () => { }); // ── Git ─────────────────────────────────────────────────────────────────────── -describe('validateGitInit', () => { - it('accepts "git init"', () => expect(validateGitInit('git init')).toBe(true)); - it('accepts "git init my-project"', () => expect(validateGitInit('git init my-project')).toBe(true)); - it('rejects "git status"', () => expect(validateGitInit('git status')).toBe(false)); -}); - describe('validateGitConfig', () => { it('accepts "git config --list"', () => expect(validateGitConfig('git config --list')).toBe(true)); it('accepts "git config --global user.name"', () => expect(validateGitConfig('git config --global user.name "Test"')).toBe(true)); it('rejects "git config color.ui"', () => expect(validateGitConfig('git config color.ui')).toBe(false)); }); -describe('validateGitAddCommit', () => { - it('accepts "git add ."', () => expect(validateGitAddCommit('git add .')).toBe(true)); - it('accepts "git add --all"', () => expect(validateGitAddCommit('git add --all')).toBe(true)); - it('accepts "git add -A"', () => expect(validateGitAddCommit('git add -A')).toBe(true)); - it('rejects "git add file.txt" (specific file)', () => expect(validateGitAddCommit('git add file.txt')).toBe(false)); -}); - describe('validateGitStatusLog', () => { it('accepts "git status"', () => expect(validateGitStatusLog('git status')).toBe(true)); it('accepts "git status --short"', () => expect(validateGitStatusLog('git status --short')).toBe(true)); @@ -431,25 +413,6 @@ describe('validateGitStatusLog', () => { it('rejects arbitrary args (git status foo bar)', () => expect(validateGitStatusLog('git status foo bar')).toBe(false)); }); -describe('validateGitDiffGitignore', () => { - it('accepts "git diff"', () => expect(validateGitDiffGitignore('git diff')).toBe(true)); - it('accepts "git diff HEAD"', () => expect(validateGitDiffGitignore('git diff HEAD')).toBe(true)); - it('accepts "git diff --staged"', () => expect(validateGitDiffGitignore('git diff --staged')).toBe(true)); - it('rejects "git status"', () => expect(validateGitDiffGitignore('git status')).toBe(false)); -}); - -describe('validateGitBranch', () => { - it('accepts "git checkout -b feature/test"', () => expect(validateGitBranch('git checkout -b feature/test')).toBe(true)); - it('accepts "git switch -c feature/test"', () => expect(validateGitBranch('git switch -c feature/test')).toBe(true)); - it('rejects "git branch"', () => expect(validateGitBranch('git branch')).toBe(false)); - it('rejects "git checkout main"', () => expect(validateGitBranch('git checkout main')).toBe(false)); -}); - -describe('validateGitMerge', () => { - it('accepts "git merge feature/test"', () => expect(validateGitMerge('git merge feature/test')).toBe(true)); - it('rejects bare "git merge"', () => expect(validateGitMerge('git merge')).toBe(false)); -}); - describe('validateGitRemote', () => { it('accepts "git remote add origin https://github.com/user/repo"', () => expect(validateGitRemote('git remote add origin https://github.com/user/repo')).toBe(true)); it('accepts "git remote add upstream https://github.com/org/repo"', () => expect(validateGitRemote('git remote add upstream https://github.com/org/repo')).toBe(true)); @@ -577,11 +540,11 @@ describe('security — injection attempts on validators', () => { it('rejects SQL injection in ls', () => expect(validateLs("'; DROP TABLE users; --", linux)).toBe(false)); it('rejects path traversal in cat', () => expect(validateCat('cat ../../etc/passwd', linux)).toBe(false)); it('rejects command chaining in mkdir', () => expect(validateMkdir('mkdir test && rm -rf /', linux)).toBe(false)); - it('rejects null byte in git init', () => expect(validateGitInit('git init\x00evil', linux)).toBe(false)); + it('rejects null byte in git status', () => expect(validateGitStatusLog('git status\x00evil', linux)).toBe(false)); it('rejects overly long input (DoS)', () => expect(validatePwd('p'.repeat(10000), linux)).toBe(false)); it('rejects empty string everywhere', () => { expect(validatePwd('', linux)).toBe(false); - expect(validateGitInit('')).toBe(false); + expect(validateGitStatusLog('')).toBe(false); expect(validatePing('')).toBe(false); }); });