From dffdb61c22f0af883ecf4361933c579eacdf641d Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:02:09 +0200 Subject: [PATCH 1/3] feat(git): git pull --no-rebase merges diverged branches, --ff-only refuses them Needed by the GitHub module as steps: a learner who commits before pulling a colleague's work ends up with diverged branches, and real git's way out is `git pull --no-rebase`. Outputs captured in git 2.56: the merge commit is "Merge branch 'main' of ", and --ff-only prints its hints then "fatal: Not possible to fast-forward, aborting." (exit 128). New lesson setup gitRepoPushed (main pushed and tracked, remote in sync); gitRepoBehindRemote builds on it. Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/git.ts | 25 ++++++++++++++++++++++++ src/app/data/lessonSetup.ts | 16 +++++++++++----- src/test/gitRemotes.test.ts | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 7debd10..5fca261 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -886,6 +886,20 @@ const PUSH_NAME_MISMATCH = (remote: string, upstreamBranch: string) => [ '', ]; +/** `git pull --ff-only` when both sides have commits (git 2.56, 1 October 2026). */ +const PULL_FF_ONLY_DIVERGENT = [ + "hint: Diverging branches can't be fast-forwarded, you need to either:", + 'hint:', + 'hint: \tgit merge --no-ff', + 'hint:', + 'hint: or:', + 'hint:', + 'hint: \tgit rebase', + 'hint:', + 'hint: Disable this message with "git config set advice.diverging false"', + 'fatal: Not possible to fast-forward, aborting.', +]; + const PULL_NOT_DEFAULT_REMOTE = (remote: string) => [ `You asked to pull from the remote '${remote}', but did not specify`, 'a branch. Because this is not the default configured remote', @@ -2383,6 +2397,9 @@ export function handleGit( // --rebase prints what a merge prints when the branch is up to date or can be // fast-forwarded; only replaying diverged commits differs (git 2.56, 1 October 2026). const rebase = [o.short.delete('r'), o.long.delete('rebase')].some(Boolean); + // --no-rebase merges diverged branches; --ff-only refuses them. + const merge = o.long.delete('no-rebase'); + const ffOnly = o.long.delete('ff-only'); if (o.short.size || o.long.size) return notSimulated(`L'option ${[...o.short].map((c) => `-${c}`).concat([...o.long.keys()].map((k) => `--${k}`))[0]} de git pull`, newState); const [remote, branch] = o.positional; let ref: string; @@ -2418,6 +2435,14 @@ export function handleGit( if (!theirs) return fail(1, `fatal: couldn't find remote ref ${ref.slice(ref.indexOf('/') + 1)}`); if (tip && isAncestor(now.objects ?? {}, theirs, tip)) return { lines: [...fromLines, { text: 'Already up to date.', type: 'output' }], newState }; if (tip && !isAncestor(now.objects ?? {}, tip, theirs)) { + if (ffOnly) return { lines: [...fromLines, ...PULL_FF_ONLY_DIVERGENT.map(err)], newState, status: 128 }; + if (merge) { + // The merge commit names the branch and where it came from: "Merge branch 'main' of ". + const from = ref.slice(0, ref.indexOf('/')); + const message = `Merge branch '${ref.slice(from.length + 1)}' of ${now.remotes[from].replace(/\.git$/, '')}`; + const merged = handleGit(newState, ['merge', '-m', message, ref], env, resolve); + return { ...merged, lines: [...fromLines, ...merged.lines] }; + } if (rebase) return notSimulated('git pull --rebase sur des branches divergentes', newState); return { lines: [...fromLines, ...PULL_DIVERGENT.map(err)], newState, status: 128 }; } diff --git a/src/app/data/lessonSetup.ts b/src/app/data/lessonSetup.ts index 5954106..35e8325 100644 --- a/src/app/data/lessonSetup.ts +++ b/src/app/data/lessonSetup.ts @@ -213,6 +213,16 @@ export const gitRepoWithRemote: LessonSetup = { const REMOTE_URL = 'https://github.com/user/mon-projet.git'; const COLLEAGUE_HASH = 'e5d1a8c3f7b29e04d6a3c81f5b7e2d90c4a6f13b'; +/** `main` pushed with `git push -u origin main`: on GitHub, tracked, nothing new on either side. */ +export const gitRepoPushed: LessonSetup = { + apply: (s, env = 'linux') => withGit(s, env, { + remotes: { origin: REMOTE_URL }, + remoteRefs: { 'origin/main': INITIAL_HASH }, + upstream: { main: 'origin/main' }, + }, true), + note: 'Dépôt Git prêt dans ~/projets (main envoyée sur GitHub et suivie).', +}; + /** * `main` pushed with `git push -u origin main`; since then a colleague pushed * a commit to GitHub (an Installation section in README.md) that this @@ -222,11 +232,7 @@ const COLLEAGUE_HASH = 'e5d1a8c3f7b29e04d6a3c81f5b7e2d90c4a6f13b'; */ export const gitRepoBehindRemote: LessonSetup = { apply: (s, env = 'linux') => { - const base = withGit(s, env, { - remotes: { origin: REMOTE_URL }, - remoteRefs: { 'origin/main': INITIAL_HASH }, - upstream: { main: 'origin/main' }, - }, true); + const base = gitRepoPushed.apply(s, env); const g = base.git!; const first = g.objects![INITIAL_HASH]; const readme = first.tree?.['README.md'] ?? ''; diff --git a/src/test/gitRemotes.test.ts b/src/test/gitRemotes.test.ts index 465781d..cf5a41c 100644 --- a/src/test/gitRemotes.test.ts +++ b/src/test/gitRemotes.test.ts @@ -125,6 +125,43 @@ describe('a remote that moved on: a colleague pushed', () => { expect(texts(t.run('git push'))[1]).toBe(' ! [rejected] main -> main (non-fast-forward)'); }); + it('diverged: git pull --ff-only refuses, git pull --no-rebase merges, then the push goes through', () => { + const t = behind(); + t.run('echo "

Contact

" > contact.html'); + t.run('git add contact.html'); + t.run('git commit -q -m "feat: page contact"'); + const ffOnly = t.run('git pull --ff-only'); + expect(texts(ffOnly)).toEqual([ + FROM, + ' a3f8c12..e5d1a8c main -> origin/main', + "hint: Diverging branches can't be fast-forwarded, you need to either:", + 'hint:', + 'hint: \tgit merge --no-ff', + 'hint:', + 'hint: or:', + 'hint:', + 'hint: \tgit rebase', + 'hint:', + 'hint: Disable this message with "git config set advice.diverging false"', + 'fatal: Not possible to fast-forward, aborting.', + ]); + expect(ffOnly.status).toBe(128); + expect(texts(t.run('git pull --no-rebase'))).toEqual([ + "Merge made by the 'ort' strategy.", + ' README.md | 4 ++++', + ' 1 file changed, 4 insertions(+)', + ]); + expect(texts(t.run('git log --oneline -1'))[0]).toMatch(/^[0-9a-f]{7} \(HEAD -> main\) Merge branch 'main' of https:\/\/github\.com\/user\/mon-projet$/); + expect(texts(t.run('git status')).slice(1, 3)).toEqual([ + "Your branch is ahead of 'origin/main' by 2 commits.", + ' (use "git push" to publish your local commits)', + ]); + const push = t.run('git push'); + expect(texts(push)[1]).toMatch(/^ {3}e5d1a8c\.\.[0-9a-f]{7} {2}main -> main$/); + expect(t.state.git!.remoteServer!['origin/main']).toBe(t.state.git!.refs!.main); + expect(texts(t.run('git pull --ff-only'))).toEqual(['Already up to date.']); + }); + it('new, forced and deleted branches in a fetch report', () => { const t = behind(); t.run('git fetch -q'); From c1ab8421a17a248d7df6357ed406c4e3b83be419 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:34:30 +0200 Subject: [PATCH 2/3] fix(engine): New-Item takes the path after -ItemType, git pull accepts --no-edit, mv/cp say why a trailing slash fails - New-Item -ItemType Directory .github/workflows took "Directory" (the value of -ItemType) for the path; it now skips named parameter values and creates the parents, as PowerShell 7 does without -Force. - git pull --no-edit keeps git's merge message (no editor in this terminal); output checked against git 2.56 on diverged branches. - A destination ending in "/": GNU mv says "No such file or directory" when it is missing, cp says "Not a directory", and both say "failed to access '': Not a directory" when it is a file (coreutils 8.32). Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/git.ts | 2 ++ src/app/data/commands/windows.ts | 19 ++++++++++++------- src/app/data/terminalEngine.ts | 8 +++++++- src/test/gitRemotes.test.ts | 14 ++++++++++++++ src/test/terminalEngine.test.ts | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 5fca261..77ac071 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -2400,6 +2400,8 @@ export function handleGit( // --no-rebase merges diverged branches; --ff-only refuses them. const merge = o.long.delete('no-rebase'); const ffOnly = o.long.delete('ff-only'); + // --no-edit keeps the merge message git proposes: this terminal never opens an editor anyway. + o.long.delete('no-edit'); if (o.short.size || o.long.size) return notSimulated(`L'option ${[...o.short].map((c) => `-${c}`).concat([...o.long.keys()].map((k) => `--${k}`))[0]} de git pull`, newState); const [remote, branch] = o.positional; let ref: string; diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 93f4d27..8d62a0c 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -262,13 +262,18 @@ export function handleWindows( // ── New-Item: file or directory ─────────────────────────────────────────── case 'new-item': case 'ni': { - const isDir = args.some((a) => a.toLowerCase() === 'directory'); - const nameIdx = args.findIndex((a) => a.toLowerCase() === '-name'); - const pathIdx = args.findIndex((a) => a.toLowerCase() === '-path'); - const name = - nameIdx >= 0 ? args[nameIdx + 1] : - pathIdx >= 0 ? args[pathIdx + 1] : - args.find((a) => !a.startsWith('-')); + // The value of a named parameter is not the path: in + // `New-Item -ItemType Directory .github/workflows` the path is the last word. + const NAMED = ['-name', '-path', '-itemtype', '-type', '-value']; + const valueOf = (names: string[]) => { + const i = args.findIndex((a) => names.includes(a.toLowerCase())); + return i >= 0 ? args[i + 1] : undefined; + }; + const isDir = valueOf(['-itemtype', '-type'])?.toLowerCase() === 'directory'; + const positional = args.find((a, i) => !a.startsWith('-') && !(i > 0 && NAMED.includes(args[i - 1].toLowerCase()))); + const path = valueOf(['-path']) ?? positional; + const leaf = valueOf(['-name']); + const name = path && leaf ? `${path}/${leaf}` : leaf ?? path; if (!name) return { lines: [{ text: 'New-Item: -Name ou chemin requis', type: 'error' }], newState }; if (isDir) { const { lines, newRoot } = deps.cmdMkdir(newState, [name]); diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index fc8b91c..0024576 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -717,8 +717,14 @@ function transfer(cmd: 'cp' | 'mv', state: TerminalState, args: string[]): Trans } const parent = getNode(newRoot, targetPath.slice(0, -1)); const trailingSlashOnFile = !intoDir && dst.endsWith('/'); + // `dir/` names a directory: an existing file there cannot be one; a missing + // one is not created by mv, and cp says the file it would create is no folder. + if (trailingSlashOnFile && parent?.type === 'directory' && parent.children[targetPath[targetPath.length - 1]]) { + lines.push(err(`failed to access '${dst}': Not a directory`)); + continue; + } if (!parent || parent.type !== 'directory' || trailingSlashOnFile) { - const reason = trailingSlashOnFile && parent ? 'Not a directory' : 'No such file or directory'; + const reason = trailingSlashOnFile && parent && cmd === 'cp' ? 'Not a directory' : 'No such file or directory'; lines.push(err(cmd === 'mv' ? `cannot move '${src}' to '${shown}': ${reason}` : `cannot create ${srcNode.type === 'directory' ? 'directory' : 'regular file'} '${shown}': ${reason}`)); diff --git a/src/test/gitRemotes.test.ts b/src/test/gitRemotes.test.ts index cf5a41c..e72846a 100644 --- a/src/test/gitRemotes.test.ts +++ b/src/test/gitRemotes.test.ts @@ -162,6 +162,20 @@ describe('a remote that moved on: a colleague pushed', () => { expect(texts(t.run('git pull --ff-only'))).toEqual(['Already up to date.']); }); + it('git pull --no-rebase --no-edit fetches and merges, as without --no-edit (git 2.56, 1 October 2026)', () => { + const t = behind(); + t.run('echo "

Contact

" > contact.html'); + t.run('git add contact.html'); + t.run('git commit -q -m "feat: page contact"'); + expect(texts(t.run('git pull --no-rebase --no-edit'))).toEqual([ + FROM, + ' a3f8c12..e5d1a8c main -> origin/main', + "Merge made by the 'ort' strategy.", + ' README.md | 4 ++++', + ' 1 file changed, 4 insertions(+)', + ]); + }); + it('new, forced and deleted branches in a fetch report', () => { const t = behind(); t.run('git fetch -q'); diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index af8a042..2af1cd4 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -1636,6 +1636,13 @@ describe('Windows paths in PowerShell', () => { expect(run('linux', 'mkdir archives/2025')[0]).toContain('No such file or directory'); }); + it('New-Item takes the path after -ItemType Directory, and creates its parents (PowerShell 7, 1 October 2026)', () => { + expect(run('windows', 'New-Item -ItemType Directory .github/workflows', 'ls .github')).toEqual(['workflows']); + expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls .github')).toEqual(['workflows']); + expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls').join(' ')).not.toMatch(/\bDirectory\b/); + expect(run('windows', 'New-Item -Path archives -Name 2025 -ItemType Directory', 'ls archives')).toEqual(['2025']); + }); + it('Tab completes after a backslash on Windows, keeping the backslash', () => { const state = createInitialState(); expect(getTabCompletions('cat documents\\n', state, 'windows')).toEqual(['cat documents\\notes.txt']); @@ -1683,6 +1690,14 @@ describe('cp / mv — destination directory (GNU semantics)', () => { expect(out('ls -F')).toEqual(['documents/ downloads/ projets/']); }); + it('a trailing slash names a directory: missing or a file, GNU says why (coreutils 8.32, 1 October 2026)', () => { + const { out } = session('linux'); + expect(out('mv documents/notes.txt manquant/')).toEqual(["mv: cannot move 'documents/notes.txt' to 'manquant/': No such file or directory"]); + expect(out('cp documents/notes.txt manquant/')).toEqual(["cp: cannot create regular file 'manquant/': Not a directory"]); + expect(out('mv documents/notes.txt documents/rapport.md/')).toEqual(["mv: failed to access 'documents/rapport.md/': Not a directory"]); + expect(out('cp documents/notes.txt documents/rapport.md/')).toEqual(["cp: failed to access 'documents/rapport.md/': Not a directory"]); + }); + it('mv several files needs a directory as last argument', () => { const { out } = session('linux', 'mv documents/notes.txt documents/rapport.md downloads'); expect(out('ls downloads')).toEqual(['notes.txt rapport.md']); From 6abf2934e68620027e50bd3d8cd5c83f6443f9e6 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:34:40 +0200 Subject: [PATCH 3/3] feat(curriculum): the GitHub module becomes step-by-step exercises The six exercises of github-collaboration asked for one command each. Each is now a path checked on the repository state, with no dead end: - git-remote: add origin, look with git remote -v, switch to SSH. - git-push-pull (colleague pushed first): pull, add a page, commit, push; pushing first ("fetch first") and committing before pulling (diverged) each get advice, ending with git pull --no-rebase --no-edit. - git-fetch-clone: fetch, git log HEAD..origin/main, merge origin/main. - pull-requests: switch -c, commit, git push -u origin feature/contact. - merge-strategies: merge --no-ff --no-edit, then branch -d; fast-forward, --squash (staged or committed) and a reset --hard are handled. - github-actions: move a real workflow into .github/workflows/, commit, push (actions/checkout@v7, setup-node@v7, Node 24, checked 1 Oct 2026). Theory replays from each lesson's setup: outputs checked against git 2.56 and PowerShell 7; the branched log --graph is shown as an illustration, not replayed. New helpers onServer/branchContains/pushed, setup gitRepoWithWorkflow; gitRepoWithRemote and six validators removed. Theory gaps 120 -> 90. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 ++ STORY.md | 12 ++ src/app/data/curriculum.ts | 308 ++++++++++++++++++++++++++++----- src/app/data/exerciseSteps.ts | 19 ++ src/app/data/lessonSetup.ts | 31 ++-- src/app/data/validators.ts | 22 --- src/test/exerciseSteps.test.ts | 167 ++++++++++++++++++ src/test/lessonSetup.test.ts | 27 ++- src/test/lessonSolutions.ts | 23 ++- src/test/lessonTheoryGaps.ts | 34 ---- src/test/validators.test.ts | 61 ------- 11 files changed, 534 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71abff4..bb62341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ --- +## 🤝 1er octobre 2026 — Le module GitHub devient une suite de gestes + +*6 leçons · exercices en étapes · sorties vérifiées sur Git 2.56 et PowerShell 7* + +Les six exercices du module GitHub demandaient une seule commande. Chacun est maintenant un vrai parcours, vérifié sur l'état du dépôt et pas sur la commande tapée. + +- **git remote** : ajouter `origin`, vérifier le lien avec `git remote -v`, le passer en SSH. +- **git push & pull** : une collègue a poussé ; récupérer son commit, ajouter une page, la pousser. Pousser trop tôt est refusé (« fetch first »), et si l'élève a committé avant de récupérer, le terminal lui donne la commande qui fusionne les deux historiques. +- **git fetch & clone** : télécharger, regarder ce qui arrive (`git log HEAD..origin/main`), puis intégrer. +- **Pull Requests** : une branche, un commit, `git push -u origin feature/contact`, et le lien que GitHub renvoie pour ouvrir la PR. +- **Stratégies de merge** : un commit de fusion avec `--no-ff`, puis la branche rangée. La théorie montre aussi `--squash` (et pourquoi `git branch -d` refuse ensuite) et le rebase, sur la branche de l'exercice. +- **GitHub Actions** : ranger un vrai workflow dans `.github/workflows/`, le committer, le pousser. Versions à jour : `actions/checkout@v7`, `actions/setup-node@v7`, Node 24. +- Sous Windows, `New-Item -ItemType Directory .github/workflows` crée aussi `.github`, comme dans PowerShell 7. `git pull --no-edit` est accepté. +- Écarts entre la théorie des leçons et le terminal : **120 → 90**. + +--- + ## 📖 1er octobre 2026 — « Notre histoire » se lit dans l'ordre *Page /story · sommaire · aucun chapitre réécrit* diff --git a/STORY.md b/STORY.md index 95bbdf0..c64b1db 100644 --- a/STORY.md +++ b/STORY.md @@ -1233,6 +1233,18 @@ La même conversation a tranché une autre question. La page d'accueil promet un --- +### GitHub, geste par geste (1er octobre 2026) + +Avec un dépôt distant qui bouge, les six leçons du module GitHub pouvaient enfin devenir des exercices complets. L'une d'elles se terminait sur `git status` : vérifier son dépôt, puis « GitHub Actions fera le reste ». L'élève n'avait rien appris d'Actions. Elle lui fait maintenant ranger un vrai workflow dans `.github/workflows/`, le committer et le pousser. + +Le plus utile, dans ce chantier, a été de jouer l'élève qui se trompe d'ordre. S'il committe avant de récupérer le travail de sa collègue, `git pull` refuse : les historiques ont divergé. Le vrai Git propose trois options de configuration, ce qui n'aide pas un débutant. L'exercice lui donne la commande qui le sort de là, `git pull --no-rebase --no-edit`, et le `--no-edit` n'est pas un détail : sans lui, un vrai terminal ouvre un éditeur pour le message de fusion, souvent vim, dont beaucoup de débutants ne savent pas sortir. Même chose pour `git merge --no-ff`. + +Deux vérifications ont changé ce que j'allais écrire. Sous Windows, `New-Item -ItemType Directory .github/workflows` crée bien `.github` au passage dans un vrai PowerShell 7, mais le simulateur prenait le mot `Directory` pour le nom du dossier : il le corrige maintenant. Et les versions des actions GitHub de la théorie dataient : `actions/checkout` et `actions/setup-node` en sont à la v7, Node 24 est la version maintenue. Je les ai lues sur les pages de publication de GitHub, pas dans un résultat de recherche : deux sources s'y contredisaient. + +Six validateurs de plus ont disparu. Les écarts entre la théorie et le terminal passent de 120 à 90. + +--- + ## Épilogue ouvert Il y a des questions auxquelles on n'a pas encore de réponse. diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 324b64f..44844f6 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -8,18 +8,17 @@ import { validateEnvVars, validatePathVariable, validateShellConfig, validateDotenv, validateScripts, validateCron, validatePing, validateCurl, validateWget, validateDns, validateSsh, validateScp, validateGitConfig, validateGitStatusLog, - validateGitRemote, validateGitPushPull, validateGitFetchClone, validatePullRequests, validateMergeStrategies, validateGithubActions, validateAiHelp, validateAiHelpCapabilities, validateAiHelpLimits, validateAiHelpPrompts, validateAiHelpContext, validateAiHelpValidate, validateAiHelpDebug, validateAiHelpSecurity, validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow, } from './validators'; import { - gitRepoEmpty, gitRepoWithChange, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithConflict, gitRepoWithRemote, powershellProfile, sshDirectory, + gitRepoEmpty, gitRepoWithChange, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithConflict, gitRepoBehindRemote, gitRepoPushed, gitRepoWithWorkflow, powershellProfile, sshDirectory, type LessonSetup, } from './lessonSetup'; import type { OutputLine, TerminalState } from './commands/types'; import { - hasConflictMarkers, homeDirExists, inHomeDir, printed, printedError, repoFile, repoInHomeDir, stepAccepts, + branchContains, hasConflictMarkers, homeDirExists, inHomeDir, onServer, printed, printedError, pushed, repoFile, repoInHomeDir, stepAccepts, } from './exerciseSteps'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; @@ -113,6 +112,18 @@ export interface Module { unlocks?: string[]; } +/** + * The GitHub Actions lesson's workflow is neither at the root nor in + * .github/workflows/ (renamed by `mv ci.yml .github/workflows` before the + * folder existed, or deleted): no command brings it back. + */ +function workflowLost(state: TerminalState): string | undefined { + const somewhere = ['ci.yml', '.github/workflows/ci.yml'].some((p) => repoFile(state, p) !== null || state.git?.head?.[p] !== undefined); + return somewhere + ? undefined + : 'ci.yml n\'est plus à la racine ni dans .github/workflows/ (renommé ou supprimé). Cliquez sur « Réinitialiser » pour recommencer.'; +} + export const curriculum: Module[] = [ { id: 'navigation', @@ -2702,12 +2713,12 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Ajouter un remote (étape après git init)\n$ git remote add origin https://github.com/user/mon-projet.git\n\n# Lister les remotes configurés\n$ git remote -v\norigin\thttps://github.com/user/mon-projet.git (fetch)\norigin\thttps://github.com/user/mon-projet.git (push)\n\n# Supprimer un remote\n$ git remote remove origin\n\n# Renommer un remote\n$ git remote rename origin upstream', + content: '# Ajouter un remote (étape après git init)\n$ git remote add origin https://github.com/user/mon-projet.git\n\n# Lister les remotes configurés\n$ git remote -v\norigin\thttps://github.com/user/mon-projet.git (fetch)\norigin\thttps://github.com/user/mon-projet.git (push)\n\n# Renommer un remote (origin devient upstream)\n$ git remote rename origin upstream\n$ git remote -v\nupstream\thttps://github.com/user/mon-projet.git (fetch)\nupstream\thttps://github.com/user/mon-projet.git (push)\n\n# Supprimer un remote : git remote -v n\'affiche plus rien\n$ git remote remove upstream\n$ git remote -v', label: 'Gérer les remotes (Linux/macOS/Windows)', }, { type: 'code', - content: '# HTTPS vs SSH — deux méthodes d\'authentification\n\n# HTTPS (simple, authentification par token)\nhttps://github.com/user/repo.git\n\n# SSH (recommandé, clé cryptographique)\ngit@github.com:user/repo.git\n\n# Changer l\'URL d\'un remote existant\n$ git remote set-url origin git@github.com:user/repo.git', + content: '# HTTPS vs SSH — deux méthodes d\'authentification\n\n# HTTPS (simple, authentification par token)\nhttps://github.com/user/repo.git\n\n# SSH (recommandé, clé cryptographique)\ngit@github.com:user/repo.git\n\n# Changer l\'URL d\'un remote existant (ici, passer de HTTPS à SSH)\n$ git remote add origin https://github.com/user/repo.git\n$ git remote set-url origin git@github.com:user/repo.git\n$ git remote -v\norigin\tgit@github.com:user/repo.git (fetch)\norigin\tgit@github.com:user/repo.git (push)', label: 'HTTPS vs SSH', }, { @@ -2724,11 +2735,36 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Ajoutez un remote `origin` pointant vers `https://github.com/user/mon-projet.git` avec `git remote add origin https://github.com/user/mon-projet.git`.', - hint: 'Tapez: git remote add origin https://github.com/user/mon-projet.git', - validate: validateGitRemote, + instruction: 'Reliez votre dépôt à GitHub, vérifiez le lien, puis passez-le en SSH.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoWithCommit, - successMessage: 'Remote ajouté ! Votre dépôt local est maintenant connecté à GitHub.', + steps: [ + { + instruction: 'Ajoutez le remote `origin` avec `git remote add origin https://github.com/user/mon-projet.git`.', + hint: 'Tapez : git remote add origin https://github.com/user/mon-projet.git', + check: ({ state }) => Boolean(state.git?.remotes.origin), + warn: ({ state }) => { + const [other] = Object.keys(state.git?.remotes ?? {}); + return other && !state.git?.remotes.origin + ? `Ce remote s'appelle ${other}, pas origin. Renommez-le avec git remote rename ${other} origin` + : undefined; + }, + }, + { + instruction: 'Vérifiez le lien avec `git remote -v` : une ligne pour récupérer (fetch), une pour envoyer (push).', + hint: 'Tapez : git remote -v', + check: ({ lines }) => !printedError(lines) && /^origin\t\S+ \(fetch\)$/m.test(printed(lines)), + }, + { + instruction: 'Passez le remote en SSH avec `git remote set-url origin git@github.com:user/mon-projet.git`. `git remote -v` montre ensuite la nouvelle adresse.', + hint: 'Tapez : git remote set-url origin git@github.com:user/mon-projet.git', + check: ({ state }) => state.git?.remotes.origin === 'git@github.com:user/mon-projet.git', + }, + ], + restart: ({ state }) => (state.git?.remotes.origin + ? undefined + : 'Le remote origin n\'existe plus (supprimé ou renommé). On reprend à l\'étape 1.'), + successMessage: 'Votre dépôt sait où envoyer son travail : `origin` pointe vers GitHub, en SSH. `git push` et `git pull` passeront par ce lien.', }, }, { @@ -2743,14 +2779,19 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Premier push : envoie main et la relie à origin/main (le suivi)\n$ git push -u origin main\nTo https://github.com/user/mon-projet.git\n * [new branch] main -> main\nbranch \'main\' set up to track \'origin/main\'.\n# (Dans un vrai terminal, Git affiche aussi sa progression : Enumerating objects…, Writing objects…)\n\n# Push suivants : le suivi est configuré, git push suffit\n$ git push\nEverything up-to-date\n\n# Une nouvelle branche : GitHub répond avec le lien pour ouvrir une pull request\n$ git switch -c feature/panier\nSwitched to a new branch \'feature/panier\'\n$ git push -u origin feature/panier\nremote: \nremote: Create a pull request for \'feature/panier\' on GitHub by visiting:\nremote: https://github.com/user/mon-projet/pull/new/feature/panier\nremote: \nTo https://github.com/user/mon-projet.git\n * [new branch] feature/panier -> feature/panier\nbranch \'feature/panier\' set up to track \'origin/feature/panier\'.', + content: '# Le premier envoi d\'une branche se fait avec -u : git push -u origin main\n# -u relie main à sa copie sur GitHub, origin/main (le suivi). Ce dépôt l\'a déjà fait :\n$ git status\nOn branch main\nYour branch is up to date with \'origin/main\'.\n\nnothing to commit, working tree clean\n\n# « up to date » d\'après le dernier échange avec GitHub : git status ne le contacte pas.\n# Entre-temps, une collègue a poussé un commit. GitHub refuse d\'écraser son travail :\n$ git push\nTo https://github.com/user/mon-projet.git\n ! [rejected] main -> main (fetch first)\nerror: failed to push some refs to \'https://github.com/user/mon-projet.git\'\nhint: Updates were rejected because the remote contains work that you do not\nhint: have locally. This is usually caused by another repository pushing to\nhint: the same ref. If you want to integrate the remote changes, use\nhint: \'git pull\' before pushing again.\nhint: See the \'Note about fast-forwards\' in \'git push --help\' for details.', label: 'git push (Linux/macOS/Windows)', }, { type: 'code', - content: '# Une fois main envoyée avec -u, git pull sait d\'où récupérer\n$ git push -u origin main\nTo https://github.com/user/mon-projet.git\n * [new branch] main -> main\nbranch \'main\' set up to track \'origin/main\'.\n\n# Un collègue a poussé un commit : le récupérer ET l\'intégrer\n$ git pull\nFrom https://github.com/user/mon-projet\n a3f8c12..c9f1e34 main -> origin/main\nUpdating a3f8c12..c9f1e34\nFast-forward\n README.md | 3 +++\n 1 file changed, 3 insertions(+)\n\n# Pull avec rebase (historique linéaire) : rien de neuf cette fois\n$ git pull --rebase\nAlready up to date.\n\n# Vérifier qu\'il ne reste rien à intégrer : télécharger, puis lister ce qui manque (rien ici)\n$ git fetch && git log HEAD..origin/main --oneline', + content: '# Récupérer le commit de la collègue ET l\'intégrer\n$ git pull\nFrom https://github.com/user/mon-projet\n a3f8c12..e5d1a8c main -> origin/main\nUpdating a3f8c12..e5d1a8c\nFast-forward\n README.md | 4 ++++\n 1 file changed, 4 insertions(+)\n\n# Travailler, committer, puis envoyer : cette fois, GitHub accepte\n$ echo "Contact" > contact.html\n$ git add contact.html\n$ git commit -q -m "feat: ajoute la page contact"\n$ git push\nTo https://github.com/user/mon-projet.git\n# (suivi de e5d1a8c.. main -> main)\n# (Dans un vrai terminal, Git affiche aussi sa progression : Enumerating objects…, Writing objects…)\n\n# Plus rien de neuf sur GitHub\n$ git pull\nAlready up to date.', label: 'git pull', }, + { + type: 'code', + content: '# Vous avez committé AVANT de récupérer le commit de la collègue : les historiques divergent\n$ echo "Contact" > contact.html\n$ git add contact.html\n$ git commit -q -m "feat: ajoute la page contact"\n$ git pull\nFrom https://github.com/user/mon-projet\n a3f8c12..e5d1a8c main -> origin/main\nhint: You have divergent branches and need to specify how to reconcile them.\n...\nfatal: Need to specify how to reconcile divergent branches.\n\n# Fusionner les deux historiques en un commit de fusion\n# (--no-edit garde le message proposé : sans lui, un vrai terminal ouvre un éditeur)\n$ git pull --no-rebase --no-edit\nMerge made by the \'ort\' strategy.\n README.md | 4 ++++\n 1 file changed, 4 insertions(+)\n\n# Autre choix : git pull --rebase rejoue votre commit par-dessus celui de la collègue\n# (historique linéaire ; ce cas n\'est pas simulé dans ce terminal)', + label: 'Quand git pull refuse : branches divergentes', + }, { type: 'info', content: @@ -2769,11 +2810,45 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Envoyez vos commits vers GitHub avec `git push -u origin main`.', - hint: 'Tapez: git push -u origin main', - validate: validateGitPushPull, - setup: gitRepoWithRemote, - successMessage: 'Push réussi ! Vos commits sont maintenant sur GitHub, visibles par toute votre équipe.', + instruction: 'Une collègue a poussé un commit sur GitHub. Récupérez-le, ajoutez votre page, puis envoyez votre travail.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', + setup: gitRepoBehindRemote, + steps: [ + { + instruction: 'Partez du travail de l\'équipe : récupérez le commit de la collègue avec `git pull`.', + hint: 'Tapez : git pull', + check: ({ state }) => branchContains(state, 'main', onServer(state, 'origin/main')), + warn: ({ state, lines }) => { + if (!/\[rejected\]|divergent branches|Diverging branches/.test(printed(lines))) return undefined; + return (state.git?.commits.length ?? 0) > 1 + ? 'Vous avez committé avant de récupérer le commit de la collègue : les deux historiques ont divergé. Fusionnez-les avec git pull --no-rebase --no-edit (un commit de fusion).' + : 'GitHub refuse votre push : il contient le commit de la collègue, que vous n\'avez pas encore. Récupérez-le d\'abord avec git pull.'; + }, + }, + { + instruction: 'Créez votre page avec `echo "Contact" > contact.html`.', + instructionByEnv: { windows: 'Créez votre page avec `Set-Content contact.html "Contact"`.' }, + hint: 'Tapez : echo "Contact" > contact.html', + hintByEnv: { windows: 'Tapez : Set-Content contact.html "Contact"' }, + check: ({ state }) => repoFile(state, 'contact.html') !== null || state.git?.head?.['contact.html'] !== undefined, + }, + { + instruction: 'Préparez-la avec `git add contact.html`.', + hint: 'Tapez : git add contact.html', + check: ({ state }) => Boolean(state.git?.stagedFiles.includes('contact.html')) || state.git?.head?.['contact.html'] !== undefined, + }, + { + instruction: 'Enregistrez-la avec `git commit -m "feat: ajoute la page contact"`.', + hint: 'Tapez : git commit -m "feat: ajoute la page contact"', + check: ({ state }) => state.git?.head?.['contact.html'] !== undefined, + }, + { + instruction: 'Envoyez votre travail avec `git push` : `main` suit déjà `origin/main`, pas besoin de `-u`.', + hint: 'Tapez : git push', + check: ({ state }) => state.git?.branch === 'main' && pushed(state, 'main') && state.git.head?.['contact.html'] !== undefined, + }, + ], + successMessage: 'C\'est le rythme d\'une équipe : `git pull` pour partir du travail de tous, commit, puis `git push`. Quand GitHub refuse un push (« fetch first »), il contient un travail que vous n\'avez pas encore : `git pull`, puis `git push` à nouveau.', }, }, { @@ -2788,17 +2863,17 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Cloner un dépôt public\n$ git clone https://github.com/org/projet.git\nCloning into \'projet\'...\n# (Dans un vrai terminal, Git affiche aussi sa progression : remote: Enumerating objects…, Receiving objects…)\n\n# Cloner dans un dossier spécifique\n$ git clone https://github.com/org/projet.git mon-dossier\n\n# Cloner une branche spécifique (ici dans projet-develop)\n$ git clone -b develop https://github.com/org/projet.git projet-develop\n\n# Cloner en SSH (recommandé), dans un dossier qui n\'existe pas encore\n$ git clone git@github.com:org/projet.git projet-ssh', + content: '# On clone hors d\'un dépôt existant : depuis votre dossier personnel, par exemple\n$ cd ~\n\n# Cloner un dépôt public\n$ git clone https://github.com/org/projet.git\nCloning into \'projet\'...\n# (Dans un vrai terminal, Git affiche aussi sa progression : remote: Enumerating objects…, Receiving objects…)\n\n# Cloner dans un dossier spécifique\n$ git clone https://github.com/org/projet.git mon-dossier\n\n# Cloner une branche spécifique (ici dans projet-develop)\n$ git clone -b develop https://github.com/org/projet.git projet-develop\n\n# Cloner en SSH (recommandé), dans un dossier qui n\'existe pas encore\n$ git clone git@github.com:org/projet.git projet-ssh', label: 'git clone (Linux/macOS/Windows)', }, { type: 'code', - content: '# Fetch : télécharger sans intégrer (ici, un collègue a poussé un commit)\n$ git fetch origin\nFrom https://github.com/org/projet\n a3f8c12..c9f1e34 main -> origin/main\n\n# Voir ce qui a changé sur le remote\n$ git fetch && git log HEAD..origin/main --oneline\n\n# Voir toutes les branches distantes\n$ git fetch --all\n\n# Comparer local vs remote après fetch\n$ git diff main origin/main', + content: '# Fetch : télécharger sans intégrer (ici, une collègue a poussé un commit)\n$ git fetch origin\nFrom https://github.com/user/mon-projet\n a3f8c12..e5d1a8c main -> origin/main\n\n# Voir les commits arrivés sur origin/main, pas encore dans votre branche\n$ git log HEAD..origin/main --oneline\ne5d1a8c (origin/main, origin/HEAD) docs: ajoute la section Installation au README\n\n# Comparer votre branche à la copie de GitHub\n$ git diff main origin/main\ndiff --git a/README.md b/README.md\n...\n+## Installation\n+npm install\n\n# Intégrer quand vous êtes prêt\n$ git merge origin/main\nUpdating a3f8c12..e5d1a8c\nFast-forward\n README.md | 4 ++++\n 1 file changed, 4 insertions(+)\n\n# Récupérer tous les remotes d\'un coup (rien de neuf ici : aucune sortie)\n$ git fetch --all', label: 'git fetch', }, { type: 'code', - content: '# Workflow de contribution typique en open source\n# 1. Forker sur GitHub (via l\'interface web)\n\n# 2. Cloner votre fork\n$ git clone git@github.com:VOTRE-USER/projet.git\n$ cd projet\n\n# 3. Ajouter l\'upstream (projet original)\n$ git remote add upstream git@github.com:org/projet.git\n\n# 4. Synchroniser régulièrement\n$ git fetch upstream\n$ git merge upstream/main', + content: '# Workflow de contribution typique en open source\n# 1. Forker sur GitHub (via l\'interface web)\n\n# 2. Cloner votre fork, depuis votre dossier personnel\n$ cd ~\n$ git clone git@github.com:VOTRE-USER/projet.git\n$ cd projet\n\n# 3. Ajouter l\'upstream (projet original)\n$ git remote add upstream git@github.com:org/projet.git\n\n# 4. Synchroniser régulièrement\n$ git fetch upstream\n$ git merge upstream/main', label: 'Workflow fork & contribution', }, { @@ -2807,10 +2882,32 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Clonez un dépôt distant avec `git clone https://github.com/user/projet.git`.', - hint: 'Tapez: git clone https://github.com/user/projet.git', - validate: validateGitFetchClone, - successMessage: 'Dépôt cloné ! Vous pouvez maintenant travailler sur un projet existant avec tout son historique.', + instruction: 'Une collègue a poussé un commit. Téléchargez-le sans toucher à votre travail, regardez-le, puis intégrez-le.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', + setup: gitRepoBehindRemote, + steps: [ + { + instruction: 'Téléchargez les nouveautés de GitHub avec `git fetch`. Vos fichiers ne bougent pas.', + hint: 'Tapez : git fetch', + check: ({ state }) => { + const server = onServer(state, 'origin/main'); + return Boolean(server) && state.git?.remoteRefs?.['origin/main'] === server; + }, + }, + { + instruction: 'Voyez ce qui est arrivé avec `git log HEAD..origin/main --oneline` : les commits de `origin/main` que votre branche n\'a pas encore.', + hint: 'Tapez : git log HEAD..origin/main --oneline', + // Already merged: there is nothing left to look at. + check: ({ command, lines, state }) => (/^git\s+(log|diff|show)\b.*\borigin\/main\b/.test(command.trim()) && !printedError(lines)) + || branchContains(state, 'main', onServer(state, 'origin/main')), + }, + { + instruction: 'Intégrez-les dans votre branche avec `git merge origin/main`.', + hint: 'Tapez : git merge origin/main', + check: ({ state }) => branchContains(state, 'main', onServer(state, 'origin/main')), + }, + ], + successMessage: '`git fetch` télécharge sans rien changer à votre travail ; vous regardez, puis vous intégrez avec `git merge`. `git pull` fait les deux d\'un coup. Pour démarrer sur un projet existant, `git clone` télécharge tout le dépôt.', }, }, { @@ -2825,12 +2922,12 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Workflow complet pour ouvrir une PR\n\n# 1. Créer une branche feature\n$ git checkout -b feature/THI-28-git-modules\n\n# 2. Développer et committer\n$ git add .\n$ git commit -m "feat(curriculum): add git module"\n\n# 3. Pousser la branche\n$ git push -u origin feature/THI-28-git-modules\n\n# 4. Ouvrir la PR sur GitHub (interface web ou CLI)\n$ gh pr create --title "feat(curriculum): add git module" --body "..."', + content: '# Workflow complet pour ouvrir une PR\n\n# 1. Créer une branche pour le sujet\n$ git switch -c feature/contact\nSwitched to a new branch \'feature/contact\'\n\n# 2. Développer et committer\n$ echo "Contact" > contact.html\n$ git add contact.html\n$ git commit -q -m "feat: ajoute la page contact"\n\n# 3. Pousser la branche : GitHub répond avec le lien pour ouvrir la PR\n$ git push -u origin feature/contact\nremote:\nremote: Create a pull request for \'feature/contact\' on GitHub by visiting:\nremote: https://github.com/user/mon-projet/pull/new/feature/contact\nremote:\nTo https://github.com/user/mon-projet.git\n * [new branch] feature/contact -> feature/contact\nbranch \'feature/contact\' set up to track \'origin/feature/contact\'.\n\n# 4. Ouvrir la PR : le lien ci-dessus, ou GitHub CLI sur votre ordinateur\n# (gh n\'est pas simulé dans ce terminal : il agit sur votre compte GitHub)\n$ gh pr create --title "feat: ajoute la page contact" --body "..."', label: 'Workflow PR complet', }, { type: 'code', - content: '# GitHub CLI (gh) — travailler avec les PRs depuis le terminal\n\n# Lister les PRs ouvertes\n$ gh pr list\n\n# Voir une PR spécifique\n$ gh pr view 42\n\n# Checkout d\'une PR pour review locale\n$ gh pr checkout 42\n\n# Approuver une PR\n$ gh pr review 42 --approve\n\n# Merger une PR\n$ gh pr merge 42 --squash', + content: '# GitHub CLI (gh) — travailler avec les PRs depuis le terminal\n# (à installer sur votre ordinateur : https://cli.github.com ; non simulé ici)\n\n# Lister les PRs ouvertes\n$ gh pr list\n\n# Voir une PR spécifique\n$ gh pr view 42\n\n# Checkout d\'une PR pour review locale\n$ gh pr checkout 42\n\n# Approuver une PR\n$ gh pr review 42 --approve\n\n# Merger une PR\n$ gh pr merge 42 --squash', label: 'GitHub CLI — PR management', }, { @@ -2847,11 +2944,60 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Simulez le début d\'un workflow PR : créez une branche `feature/nouvelle-feature` avec `git checkout -b feature/nouvelle-feature`.', - hint: 'Tapez: git checkout -b feature/nouvelle-feature', - validate: validatePullRequests, - setup: gitRepoWithCommit, - successMessage: 'Branche feature créée ! Dans un vrai projet, vous développeriez ici puis ouvreriez une PR vers main.', + instruction: 'Préparez une Pull Request : votre travail sur une branche, envoyé sur GitHub.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', + setup: gitRepoPushed, + steps: [ + { + instruction: 'Créez une branche pour votre sujet avec `git switch -c feature/contact`.', + hint: 'Tapez : git switch -c feature/contact', + check: ({ state }) => Boolean(state.git && state.git.branch !== 'main' && state.git.refs?.[state.git.branch]), + warn: ({ state }) => (state.git?.branch === 'main' && state.git.head?.['contact.html'] !== undefined + ? 'contact.html vient d\'être commité sur main, avant la création de la branche. Cliquez sur « Réinitialiser » pour recommencer.' + : undefined), + }, + { + instruction: 'Créez la page avec `echo "Contact" > contact.html`.', + instructionByEnv: { windows: 'Créez la page avec `Set-Content contact.html "Contact"`.' }, + hint: 'Tapez : echo "Contact" > contact.html', + hintByEnv: { windows: 'Tapez : Set-Content contact.html "Contact"' }, + check: ({ state }) => repoFile(state, 'contact.html') !== null || state.git?.head?.['contact.html'] !== undefined, + }, + { + instruction: 'Préparez-la avec `git add contact.html`.', + hint: 'Tapez : git add contact.html', + check: ({ state }) => Boolean(state.git?.stagedFiles.includes('contact.html')) || state.git?.head?.['contact.html'] !== undefined, + }, + { + instruction: 'Enregistrez-la sur la branche avec `git commit -m "feat: ajoute la page contact"`.', + hint: 'Tapez : git commit -m "feat: ajoute la page contact"', + check: ({ state }) => state.git?.branch !== 'main' && state.git?.head?.['contact.html'] !== undefined, + warn: ({ state }) => { + const git = state.git; + if (git?.branch !== 'main') return undefined; + if (git.head?.['contact.html'] !== undefined) return 'Ce commit est parti sur main, pas sur la branche. Cliquez sur « Réinitialiser » pour recommencer.'; + const branch = Object.keys(git.refs ?? {}).find((b) => b !== 'main'); + if (branch && git.stagedFiles.includes('contact.html')) return `contact.html est préparé, mais vous êtes revenu sur main. Revenez avec git switch ${branch} (Git emporte le fichier préparé avec vous), puis commitez.`; + return undefined; + }, + }, + { + instruction: 'Envoyez la branche sur GitHub avec `git push -u origin feature/contact`. GitHub répond avec le lien pour ouvrir la Pull Request.', + hint: 'Tapez : git push -u origin feature/contact', + check: ({ state }) => { + const branch = state.git?.branch; + return Boolean(branch && branch !== 'main' && pushed(state, branch) && state.git?.upstream?.[branch] === `origin/${branch}` + && state.git.head?.['contact.html'] !== undefined); + }, + warn: ({ state }) => { + const branch = state.git?.branch; + return branch && branch !== 'main' && pushed(state, branch) && !state.git?.upstream?.[branch] + ? `La branche est sur GitHub, mais votre copie ne la suit pas : sans -u, git push et git pull ne savent pas où aller. Tapez git push -u origin ${branch}` + : undefined; + }, + }, + ], + successMessage: 'Votre branche est sur GitHub. Reste à ouvrir la Pull Request : le lien affiché par GitHub (`remote: Create a pull request…`), ou le bouton « Compare & pull request » sur la page du dépôt. Vos collègues relisent, commentent, puis fusionnent.', }, }, { @@ -2866,17 +3012,22 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Stratégie 1 : Merge commit (--no-ff)\n# Garde la branche comme un bloc identifiable dans l\'historique\n\n$ git checkout main\n$ git merge --no-ff feature/panier\nMerge made by the \'ort\' strategy.\n panier.html | 42 ++++++\n cart.js | 18 +++++\n 2 files changed, 60 insertions(+)\n\n$ git log --oneline --graph\n* d4f8a91 Merge branch \'feature/panier\'\n|\\\n| * b7e2d45 feat(panier): add cart UI\n| * a3f8c12 feat(panier): init cart model\n|/\n* c1d2e34 chore: bump version', + content: '# Stratégie 1 : Merge commit (--no-ff)\n# Garde la branche comme un bloc identifiable dans l\'historique\n\n$ git checkout main\nAlready on \'main\'\n$ git merge --no-ff --no-edit feature/ma-feature\nMerge made by the \'ort\' strategy.\n ma-feature.html | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 ma-feature.html\n# (--no-edit garde le message proposé : sans lui, un vrai terminal ouvre un éditeur)', label: '1️⃣ Merge commit (--no-ff)', }, { type: 'code', - content: '# Stratégie 2 : Squash merge (--squash)\n# Condense toute la branche en UN SEUL commit sur main\n\n$ git checkout main\n$ git merge --squash feature/panier\n$ git commit -m "feat(panier): add cart module (#42)"\n\n$ git log --oneline\n* e5f6a78 feat(panier): add cart module (#42)\n* c1d2e34 chore: bump version\n\n# Équivalent via GitHub CLI :\n$ gh pr merge 42 --squash --delete-branch', + content: '* d8fdc42 Merge branch \'feature/ma-feature\'\n|\\\n| * b7e2d45 feat(ma-feature): ajoute la page ma-feature.html\n|/\n* a3f8c12 feat: premier commit du projet', + label: 'Ce que montre ensuite git log --oneline --graph (le dessin n\'est pas simulé dans ce terminal)', + }, + { + type: 'code', + content: '# Stratégie 2 : Squash merge (--squash)\n# Condense toute la branche en UN SEUL commit sur main\n\n$ git merge --squash feature/ma-feature\nUpdating a3f8c12..b7e2d45\nFast-forward\nSquash commit -- not updating HEAD\n ma-feature.html | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 ma-feature.html\n$ git commit -q -m "feat: ajoute la page ma-feature (#42)"\n\n# Les commits de la branche ne sont pas dans main : git branch -d refuse\n$ git branch -d feature/ma-feature\nerror: the branch \'feature/ma-feature\' is not fully merged\nhint: If you are sure you want to delete it, run \'git branch -D feature/ma-feature\'\nhint: Disable this message with "git config set advice.forceDeleteBranch false"\n$ git branch -D feature/ma-feature\nDeleted branch feature/ma-feature (was b7e2d45).\n\n# Équivalent via GitHub CLI (non simulé dans ce terminal) :\n$ gh pr merge 42 --squash --delete-branch', label: '2️⃣ Squash merge (--squash)', }, { type: 'code', - content: '# Stratégie 3 : Rebase merge (--rebase)\n# Rejoue les commits de la branche au sommet de main (historique linéaire, pas de merge commit)\n\n$ git checkout feature/panier\n$ git rebase main\n$ git checkout main\n$ git merge feature/panier # fast-forward, linéaire\n\n$ git log --oneline\n* b7e2d45 feat(panier): add cart UI\n* a3f8c12 feat(panier): init cart model\n* c1d2e34 chore: bump version\n\n# Équivalent via GitHub CLI :\n$ gh pr merge 42 --rebase --delete-branch', + content: '# Stratégie 3 : Rebase (historique linéaire, pas de commit de fusion)\n# Rejoue les commits de la branche au sommet de main\n\n$ git checkout feature/ma-feature\nSwitched to branch \'feature/ma-feature\'\n$ git rebase main\nCurrent branch feature/ma-feature is up to date.\n# (main n\'a pas bougé depuis la création de la branche : rien à rejouer)\n$ git checkout main\nSwitched to branch \'main\'\n$ git merge feature/ma-feature\nUpdating a3f8c12..b7e2d45\nFast-forward\n ma-feature.html | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 ma-feature.html\n\n$ git log --oneline\nb7e2d45 (HEAD -> main, feature/ma-feature) feat(ma-feature): ajoute la page ma-feature.html\na3f8c12 feat: premier commit du projet\n\n# Équivalent via GitHub CLI (non simulé dans ce terminal) :\n$ gh pr merge 42 --rebase --delete-branch', label: '3️⃣ Rebase merge (--rebase)', }, { @@ -2894,11 +3045,36 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Fusionnez la branche `feature/ma-feature` avec un **merge commit explicite** (option `--no-ff`) : `git merge --no-ff feature/ma-feature`.', - hint: 'Tapez: git merge --no-ff feature/ma-feature', - validate: validateMergeStrategies, + instruction: 'Fusionnez `feature/ma-feature` en gardant la trace de la branche (un commit de fusion), puis rangez-la.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', setup: gitRepoWithBranch('feature/ma-feature'), - successMessage: 'Merge commit créé ! Votre branche reste identifiable dans l\'historique — utile pour retrouver le contexte d\'une feature 6 mois plus tard.', + steps: [ + { + instruction: 'Fusionnez avec un commit de fusion : `git merge --no-ff --no-edit feature/ma-feature`. Sans `--no-ff`, Git avancerait simplement `main` (« Fast-forward »).', + hint: 'Tapez : git merge --no-ff --no-edit feature/ma-feature', + check: ({ state }) => state.git?.branch === 'main' && (state.git.commits[0]?.parents?.length ?? 0) === 2 + && state.git.head?.['ma-feature.html'] !== undefined, + warn: ({ state }) => { + const git = state.git; + if (git?.branch !== 'main') return undefined; + if (branchContains(state, 'main', git.refs?.['feature/ma-feature'])) return 'Git a avancé main sans commit de fusion (« Fast-forward ») : il n\'y a plus rien à fusionner. Cliquez sur « Réinitialiser » pour recommencer avec --no-ff.'; + if (!git.refs?.['feature/ma-feature']) return 'La branche feature/ma-feature n\'existe plus (supprimée avec -D, ou renommée) et son travail n\'a pas été fusionné. Cliquez sur « Réinitialiser » pour recommencer.'; + // --squash copies the branch's changes without merging it (stratégie 2 of the lesson). + if (git.head?.['ma-feature.html'] !== undefined) return 'Ce commit vient de --squash : main a le travail de la branche, mais pas de commit de fusion. git merge --no-ff --no-edit feature/ma-feature en crée un quand même, ou cliquez sur « Réinitialiser » pour repartir proprement.'; + if (git.stagedFiles.includes('ma-feature.html')) return '--squash a préparé le travail de la branche sans fusionner, et Git refuse maintenant de fusionner par-dessus. Cliquez sur « Réinitialiser » pour recommencer avec --no-ff.'; + return undefined; + }, + }, + { + instruction: 'Supprimez la branche fusionnée avec `git branch -d feature/ma-feature`. Le commit de fusion garde sa trace dans l\'historique.', + hint: 'Tapez : git branch -d feature/ma-feature', + check: ({ state }) => !state.git?.refs?.['feature/ma-feature'] && state.git?.head?.['ma-feature.html'] !== undefined, + }, + ], + restart: ({ state }) => (state.git?.head?.['ma-feature.html'] === undefined + ? 'La fusion a été annulée : main n\'a plus le travail de la branche. On reprend à l\'étape 1.' + : undefined), + successMessage: 'Le commit de fusion garde la branche visible : dans un vrai terminal, `git log --oneline --graph` dessine son embranchement (ce dessin n\'est pas simulé ici). Sur GitHub, c\'est le bouton « Create a merge commit » d\'une Pull Request.', }, }, { @@ -3008,19 +3184,27 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Structure d\'un workflow GitHub Actions\n# Fichier : .github/workflows/ci.yml\n\nname: CI\non:\n push:\n branches: [main, develop]\n pull_request:\n branches: [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: \'20\'\n - run: npm install\n - run: npm run lint\n - run: npm test\n - run: npm run build', + content: '# Structure d\'un workflow GitHub Actions (versions d\'octobre 2026)\n# Fichier : .github/workflows/ci.yml\n\nname: CI\non:\n push:\n branches: [main, develop]\n pull_request:\n branches: [main]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n - uses: actions/setup-node@v7\n with:\n node-version: 24\n - run: npm ci\n - run: npm run lint\n - run: npm test\n - run: npm run build', label: 'Workflow CI/CD minimal', }, { type: 'code', - content: '# Workflow multi-jobs avec matrix\njobs:\n test:\n runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest, macos-latest]\n node: [18, 20, 22]\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: ${{ matrix.node }}\n - run: npm test\n\n deploy:\n needs: test # attend que tous les tests passent\n if: github.ref == \'refs/heads/main\'\n runs-on: ubuntu-latest\n steps:\n - run: echo "Déploiement en production"', + content: '# Workflow multi-jobs avec matrix\njobs:\n test:\n runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest, macos-latest]\n node: [22, 24]\n steps:\n - uses: actions/checkout@v7\n - uses: actions/setup-node@v7\n with:\n node-version: ${{ matrix.node }}\n - run: npm test\n\n deploy:\n needs: test # attend que tous les tests passent\n if: github.ref == \'refs/heads/main\'\n runs-on: ubuntu-latest\n steps:\n - run: echo "Déploiement en production"', label: 'Matrix + déploiement conditionnel', }, { type: 'code', - content: '# Commandes utiles avec GitHub CLI\n\n# Lister les workflows\n$ gh workflow list\n\n# Voir les runs récents\n$ gh run list\n\n# Voir les détails d\'un run\n$ gh run view 12345\n\n# Déclencher manuellement un workflow\n$ gh workflow run ci.yml\n\n# Télécharger les artifacts d\'un run\n$ gh run download 12345', + content: '# Commandes utiles avec GitHub CLI\n# (à installer sur votre ordinateur : https://cli.github.com ; non simulé ici)\n\n# Lister les workflows\n$ gh workflow list\n\n# Voir les runs récents\n$ gh run list\n\n# Voir les détails d\'un run\n$ gh run view 12345\n\n# Déclencher manuellement un workflow\n$ gh workflow run ci.yml\n\n# Télécharger les artifacts d\'un run\n$ gh run download 12345', label: 'Gestion via GitHub CLI', }, + { + type: 'code', + content: '# GitHub ne lit les workflows que dans .github/workflows/\n$ mkdir -p .github/workflows\n$ mv ci.yml .github/workflows/\n$ git add .github\n$ git commit -q -m "ci: ajoute le workflow de vérification"\n$ git push\nTo https://github.com/user/mon-projet.git\n# (suivi de a3f8c12.. main -> main)\n# Sur GitHub, ce push lance le workflow : onglet Actions du dépôt', + contentByEnv: { + windows: '# GitHub ne lit les workflows que dans .github/workflows/\nPS> New-Item -ItemType Directory .github/workflows\n# (PowerShell affiche le dossier créé)\nPS> Move-Item ci.yml .github/workflows/\nPS> git add .github\nPS> git commit -q -m "ci: ajoute le workflow de vérification"\nPS> git push\nTo https://github.com/user/mon-projet.git\n# (suivi de a3f8c12.. main -> main)\n# Sur GitHub, ce push lance le workflow : onglet Actions du dépôt', + }, + label: 'Ajouter le workflow au dépôt', + }, { type: 'info', content: @@ -3035,11 +3219,45 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Vérifiez l\'état de votre dépôt git avant un push avec `git status`.', - hint: 'Tapez: git status', - validate: validateGithubActions, - setup: gitRepoWithRemote, - successMessage: 'Parfait ! Avant chaque push, vérifiez toujours l\'état de votre dépôt. GitHub Actions fera ensuite tourner automatiquement vos tests et votre build.', + instruction: 'Un workflow `ci.yml` attend à la racine du dépôt. Placez-le là où GitHub le cherche, puis envoyez-le.', + hint: 'Suivez les étapes une par une : chacune donne sa commande.', + setup: gitRepoWithWorkflow, + steps: [ + { + instruction: 'Créez le dossier des workflows avec `mkdir -p .github/workflows` (`-p` crée aussi `.github`).', + instructionByEnv: { + windows: 'Créez le dossier des workflows avec `New-Item -ItemType Directory .github/workflows` (PowerShell crée aussi `.github`).', + }, + hint: 'Tapez : mkdir -p .github/workflows', + hintByEnv: { windows: 'Tapez : New-Item -ItemType Directory .github/workflows' }, + check: ({ state }) => homeDirExists(state, 'projets', '.github', 'workflows'), + warn: ({ state }) => workflowLost(state), + }, + { + instruction: 'Déplacez le workflow dedans avec `mv ci.yml .github/workflows/`.', + instructionByEnv: { windows: 'Déplacez le workflow dedans avec `Move-Item ci.yml .github/workflows/`.' }, + hint: 'Tapez : mv ci.yml .github/workflows/', + hintByEnv: { windows: 'Tapez : Move-Item ci.yml .github/workflows/' }, + check: ({ state }) => repoFile(state, '.github/workflows/ci.yml') !== null || state.git?.head?.['.github/workflows/ci.yml'] !== undefined, + warn: ({ state }) => workflowLost(state), + }, + { + instruction: 'Faites-le suivre par Git avec `git add .github`.', + hint: 'Tapez : git add .github', + check: ({ state }) => Boolean(state.git?.stagedFiles.includes('.github/workflows/ci.yml')) || state.git?.head?.['.github/workflows/ci.yml'] !== undefined, + }, + { + instruction: 'Enregistrez-le avec `git commit -m "ci: ajoute le workflow de vérification"`.', + hint: 'Tapez : git commit -m "ci: ajoute le workflow de vérification"', + check: ({ state }) => state.git?.head?.['.github/workflows/ci.yml'] !== undefined, + }, + { + instruction: 'Envoyez-le avec `git push` : sur GitHub, c\'est ce push qui lance le workflow.', + hint: 'Tapez : git push', + check: ({ state }) => state.git?.branch === 'main' && pushed(state, 'main') && state.git.head?.['.github/workflows/ci.yml'] !== undefined, + }, + ], + successMessage: 'Sur GitHub, l\'onglet Actions du dépôt montrerait maintenant le workflow CI en route : il récupère le code (`actions/checkout`) puis lance `bash script.sh`, à chaque push sur main et à chaque Pull Request. Ce terminal ne simule pas GitHub : votre push s\'arrête ici.', }, }, ], diff --git a/src/app/data/exerciseSteps.ts b/src/app/data/exerciseSteps.ts index 095eb6b..f776ecc 100644 --- a/src/app/data/exerciseSteps.ts +++ b/src/app/data/exerciseSteps.ts @@ -10,6 +10,7 @@ import type { EnvId, Exercise, ExerciseCheckContext, ExerciseStep } from './curriculum'; import type { OutputLine, TerminalState } from './commands/types'; import { nodeAt } from './commands/gitTree'; +import { isAncestor } from './commands/gitHistory'; import { exerciseAccepts, type ValidateFn } from './validators'; /** The command printed an error line: it did not do its job. */ @@ -51,6 +52,24 @@ export function repoFile(state: TerminalState, path: string): string | null { return node.content ? `${node.content}\n` : ''; } +/** What the remote itself holds for `ref` (`origin/main`): a push or a colleague put it there, a fetch is not needed. */ +export function onServer(state: TerminalState, ref: string): string | undefined { + const git = state.git; + return (git?.remoteServer ?? git?.remoteRefs ?? {})[ref]; +} + +/** The local `branch` contains the commit `hash` (it is the tip or one of its ancestors). */ +export function branchContains(state: TerminalState, branch: string, hash: string | undefined): boolean { + const tip = state.git?.refs?.[branch]; + return Boolean(hash && tip && isAncestor(state.git?.objects ?? {}, hash, tip)); +} + +/** The remote holds `branch` exactly as it is here: everything is pushed. */ +export function pushed(state: TerminalState, branch: string, remote = 'origin'): boolean { + const tip = state.git?.refs?.[branch]; + return Boolean(tip) && onServer(state, `${remote}/${branch}`) === tip; +} + /** Git's conflict markers, each at the start of a line. */ export const hasConflictMarkers = (text: string): boolean => /^(<{7}|={7}|>{7})( |$)/m.test(text); diff --git a/src/app/data/lessonSetup.ts b/src/app/data/lessonSetup.ts index 35e8325..ee85fc2 100644 --- a/src/app/data/lessonSetup.ts +++ b/src/app/data/lessonSetup.ts @@ -198,18 +198,6 @@ export function gitRepoWithConflict(branch: string): LessonSetup { }; } -/** - * A repository with one commit and an `origin` remote that has received - * nothing yet: the first `git push -u origin main` creates the branch there. - */ -export const gitRepoWithRemote: LessonSetup = { - apply: (s, env = 'linux') => withGit(s, env, { - remotes: { origin: 'https://github.com/user/mon-projet.git' }, - remoteRefs: {}, - }, true), - note: 'Dépôt Git prêt dans ~/projets (1 commit, remote origin ajouté, rien encore envoyé).', -}; - const REMOTE_URL = 'https://github.com/user/mon-projet.git'; const COLLEAGUE_HASH = 'e5d1a8c3f7b29e04d6a3c81f5b7e2d90c4a6f13b'; @@ -254,6 +242,25 @@ export const gitRepoBehindRemote: LessonSetup = { note: 'Dépôt Git prêt dans ~/projets (main envoyée sur GitHub et suivie ; depuis, une collègue a poussé un commit).', }; +/** + * A GitHub Actions workflow, as checked on 1 October 2026: actions/checkout + * v7 is the current major. It runs the repository's own script, so it would + * pass on GitHub as it is. + */ +const CI_WORKFLOW = 'name: CI\non:\n push:\n branches: [main]\n pull_request:\n\njobs:\n verifier:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n - run: bash script.sh'; + +/** + * `main` pushed and tracked, and a workflow written at the root of ~/projets + * (`ci.yml`, not tracked yet): GitHub only runs it from `.github/workflows/`. + */ +export const gitRepoWithWorkflow: LessonSetup = { + apply: (s, env = 'linux') => { + const state = gitRepoPushed.apply(s, env); + return { ...state, root: withNode(state.root, [...PROJECT_DIR, 'ci.yml'], file(CI_WORKFLOW, '-rw-r--r--')) }; + }, + note: 'Dépôt Git prêt dans ~/projets (main envoyée sur GitHub et suivie ; un workflow ci.yml attend à la racine, pas encore suivi).', +}; + /** * The PowerShell profile that `$PROFILE` points to (see terminalEngine), so * `cat $PROFILE` shows a real profile instead of "file not found". It lives in diff --git a/src/app/data/validators.ts b/src/app/data/validators.ts index da99889..33770de 100644 --- a/src/app/data/validators.ts +++ b/src/app/data/validators.ts @@ -284,28 +284,6 @@ export const validateGitConfig: ValidateFn = (cmd) => /^git\s+config\s+(--list|- export const validateGitStatusLog: ValidateFn = (cmd) => /^git\s+status(\s+(-\w+|--\w[\w-]*))*$/.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()); - -export const validateGitFetchClone: ValidateFn = (cmd) => /^git\s+clone\s+\S+/.test(cmd.trim().toLowerCase()); - -export const validatePullRequests: ValidateFn = (cmd) => { - const c = cmd.trim().toLowerCase(); - return /^git\s+checkout\s+-b\s+feature\/\S+/.test(c) || /^git\s+switch\s+-c\s+feature\/\S+/.test(c); - }; - -export const validateMergeStrategies: ValidateFn = (cmd) => { - const c = cmd.trim().toLowerCase(); - if (!/^git\s+merge\b/.test(c)) return false; - const tokens = c.split(/\s+/).slice(2); - if (!tokens.includes('--no-ff')) return false; - if (tokens.some((t) => t === '--squash' || t === '--ff-only' || t === '--ff')) return false; - return tokens.some((t) => !t.startsWith('-')); -}; - -export const validateGithubActions: ValidateFn = (cmd) => /^git\s+status/.test(cmd.trim().toLowerCase()); - // ── Module 11 — L'IA comme outil dev ───────────────────────────────────────── export const validateAiHelp: ValidateFn = (cmd) => /^ai-help(\s.*)?$/.test(cmd.trim().toLowerCase()); export const validateAiHelpCapabilities: ValidateFn = (cmd) => /^ai-help\s+capabilities$/.test(cmd.trim().toLowerCase()); diff --git a/src/test/exerciseSteps.test.ts b/src/test/exerciseSteps.test.ts index 92598c1..223ca7d 100644 --- a/src/test/exerciseSteps.test.ts +++ b/src/test/exerciseSteps.test.ts @@ -275,6 +275,173 @@ describe('the Git module, out of order', () => { }); }); +/** + * The GitHub module (1 October 2026). The outputs asserted here ("fetch first", + * the divergent-branches refusal, `push -u` on a branch already pushed) were + * checked against real Git 2.56 with a bare repository and two clones. + */ +describe('the GitHub module, out of order', () => { + const remote = () => session(find('github-collaboration', 'git-remote')); + const pushPull = () => session(find('github-collaboration', 'git-push-pull')); + + it('remote: set-url before looking, then git remote -v completes it', () => { + const t = remote(); + expect(t.type('git remote add origin https://github.com/user/mon-projet.git').index).toBe(1); + expect(t.type('git remote set-url origin git@github.com:user/mon-projet.git').index).toBe(1); + expect(t.type('git remote -v').completed).toBe(true); + }); + + it('remote: a remote with another name is flagged, and renaming it to origin counts', () => { + const t = remote(); + const other = t.type('git remote add github https://github.com/user/mon-projet.git'); + expect(other.index).toBe(0); + expect(other.messages[0].text).toBe('⚠ Ce remote s\'appelle github, pas origin. Renommez-le avec git remote rename github origin'); + expect(t.type('git remote rename github origin').index).toBe(1); + }); + + it('remote: removing origin starts over at step 1', () => { + const t = remote(); + t.type('git remote add origin https://github.com/user/mon-projet.git'); + const removed = t.type('git remote remove origin'); + expect(removed.index).toBe(0); + expect(removed.messages[0].text).toMatch(/^↺ Le remote origin n'existe plus/); + expect(t.type('git remote add origin https://github.com/user/mon-projet.git').index).toBe(1); + }); + + it('push & pull: pushing first is refused (fetch first), and the terminal says to pull', () => { + const t = pushPull(); + const push = t.type('git push'); + expect(push.output[1]).toBe(' ! [rejected] main -> main (fetch first)'); + expect(push.index).toBe(0); + expect(push.messages[0].text).toMatch(/^⚠ GitHub refuse votre push : .* git pull\.$/); + expect(t.type('git pull').index).toBe(1); + }); + + it('push & pull: committing before pulling diverges, and the advice (--no-rebase --no-edit) gets the learner out', () => { + const t = pushPull(); + t.type('echo "Contact" > contact.html'); + t.type('git add contact.html'); + expect(t.type('git commit -m "feat: ajoute la page contact"').index).toBe(0); + expect(t.type('git push').messages[0].text).toMatch(/divergé\. Fusionnez-les avec git pull --no-rebase --no-edit/); + const pull = t.type('git pull'); + expect(pull.output.slice(-1)[0]).toBe('fatal: Need to specify how to reconcile divergent branches.'); + expect(pull.messages[0].text).toMatch(/git pull --no-rebase --no-edit/); + expect(t.type('git pull --no-rebase --no-edit').index).toBe(4); + expect(t.type('git push').completed).toBe(true); + }); + + it('fetch & clone: git pull does fetch and merge at once, and completes the exercise', () => { + expect(session(find('github-collaboration', 'git-fetch-clone')).type('git pull').completed).toBe(true); + }); + + it('fetch & clone: merging without looking first still completes it', () => { + const t = session(find('github-collaboration', 'git-fetch-clone')); + expect(t.type('git fetch').index).toBe(1); + expect(t.type('git merge origin/main').completed).toBe(true); + }); + + it('pull requests: a push without -u is flagged, and pushing again with -u completes it', () => { + const t = session(find('github-collaboration', 'pull-requests')); + t.type('git checkout -b feature/contact'); + t.type('echo "Contact" > contact.html'); + t.type('git add contact.html'); + expect(t.type('git commit -m "feat: ajoute la page contact"').index).toBe(4); + const bare = t.type('git push origin feature/contact'); + expect(bare.index).toBe(4); + expect(bare.messages[0].text).toMatch(/^⚠ La branche est sur GitHub, mais votre copie ne la suit pas .* git push -u origin feature\/contact$/); + const again = t.type('git push -u origin feature/contact'); + expect(again.output).toEqual(['Everything up-to-date', "branch 'feature/contact' set up to track 'origin/feature/contact'."]); + expect(again.completed).toBe(true); + }); + + it('pull requests: the page committed on main before the branch is flagged', () => { + const t = session(find('github-collaboration', 'pull-requests')); + t.type('echo "Contact" > contact.html'); + t.type('git add contact.html'); + const early = t.type('git commit -m "feat: ajoute la page contact"'); + expect(early.index).toBe(0); + expect(early.messages[0].text).toMatch(/^⚠ contact\.html vient d'être commité sur main/); + }); + + it('merge strategies: a fast-forward is flagged, since nothing is left to merge', () => { + const t = session(find('github-collaboration', 'merge-strategies')); + const ff = t.type('git merge feature/ma-feature'); + expect(ff.output[1]).toBe('Fast-forward'); + expect(ff.index).toBe(0); + expect(ff.messages[0].text).toMatch(/^⚠ Git a avancé main sans commit de fusion/); + expect(t.type('git merge --no-ff --no-edit feature/ma-feature').output).toEqual(['Already up to date.']); + }); + + it('merge strategies: --squash staged blocks the merge (as in git 2.56), and the terminal says to start over', () => { + const t = session(find('github-collaboration', 'merge-strategies')); + const squash = t.type('git merge --squash feature/ma-feature'); + expect(squash.output[2]).toBe('Squash commit -- not updating HEAD'); + expect(squash.messages[0].text).toMatch(/^⚠ --squash a préparé le travail de la branche sans fusionner/); + expect(t.type('git merge --no-ff --no-edit feature/ma-feature').output[0]) + .toBe('error: Your local changes to the following files would be overwritten by merge:'); + }); + + it('merge strategies: --squash committed is flagged, and the advised merge --no-ff still completes it', () => { + const t = session(find('github-collaboration', 'merge-strategies')); + t.type('git merge --squash feature/ma-feature'); + const committed = t.type('git commit -m "feat: ajoute la page ma-feature (#42)"'); + expect(committed.index).toBe(0); + expect(committed.messages[0].text).toMatch(/^⚠ Ce commit vient de --squash/); + // git 2.56: the branch tip is not in main yet, so --no-ff makes a real merge commit. + expect(t.type('git merge --no-ff --no-edit feature/ma-feature')).toMatchObject({ index: 1, output: ["Merge made by the 'ort' strategy."] }); + }); + + it('merge strategies: undoing the merge with git reset --hard starts over at step 1', () => { + const t = session(find('github-collaboration', 'merge-strategies')); + t.type('git merge --no-ff --no-edit feature/ma-feature'); + const undone = t.type('git reset --hard HEAD~1'); + expect(undone.output).toEqual(['HEAD is now at a3f8c12 feat: premier commit du projet']); + expect(undone.index).toBe(0); + expect(undone.messages[0].text).toMatch(/^↺ La fusion a été annulée/); + expect(t.type('git merge --no-ff --no-edit feature/ma-feature').index).toBe(1); + }); + + it('push & pull: git pull --ff-only on diverged branches gets the same advice', () => { + const t = pushPull(); + t.type('echo "Contact" > contact.html'); + t.type('git add contact.html'); + t.type('git commit -m "feat: ajoute la page contact"'); + const ffOnly = t.type('git pull --ff-only'); + expect(ffOnly.output.slice(-1)[0]).toBe('fatal: Not possible to fast-forward, aborting.'); + expect(ffOnly.messages[0].text).toMatch(/git pull --no-rebase --no-edit/); + }); + + it('merge strategies: without --no-edit the merge commit counts too', () => { + const t = session(find('github-collaboration', 'merge-strategies')); + expect(t.type('git merge --no-ff feature/ma-feature').index).toBe(1); + expect(t.type('git branch -d feature/ma-feature').completed).toBe(true); + }); + + it('actions: mv before mkdir fails and changes nothing, then the steps go on', () => { + const t = session(find('github-collaboration', 'github-actions')); + const early = t.type('mv ci.yml .github/workflows/'); + expect(early.output).toEqual(["mv: cannot move 'ci.yml' to '.github/workflows/': No such file or directory"]); + expect(early.messages).toEqual([]); + expect(t.type('mkdir -p .github/workflows').index).toBe(1); + expect(t.type('mv ci.yml .github/workflows/').index).toBe(2); + expect(t.type('git add .').index).toBe(3); + }); + + it('actions: ci.yml renamed to .github/workflows (no such folder yet) is flagged', () => { + const t = session(find('github-collaboration', 'github-actions')); + t.type('mkdir .github'); + const renamed = t.type('mv ci.yml .github/workflows'); + expect(renamed.index).toBe(0); + expect(renamed.messages[0].text).toMatch(/^⚠ ci\.yml n'est plus à la racine ni dans \.github\/workflows\//); + }); + + it('actions: on Windows, New-Item creates .github too and Move-Item moves the workflow in', () => { + const t = session(find('github-collaboration', 'github-actions'), 'windows'); + expect(t.type('New-Item -ItemType Directory .github\\workflows').index).toBe(1); + expect(t.type('Move-Item ci.yml .github\\workflows\\').index).toBe(2); + }); +}); + 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/lessonSetup.test.ts b/src/test/lessonSetup.test.ts index ecd529d..ebb8a79 100644 --- a/src/test/lessonSetup.test.ts +++ b/src/test/lessonSetup.test.ts @@ -1,17 +1,19 @@ import { describe, it, expect } from 'vitest'; import { createInitialState, processCommand } from '../app/data/terminalEngine'; import { + gitRepoBehindRemote, gitRepoEmpty, + gitRepoPushed, gitRepoWithBranch, gitRepoWithCommit, - gitRepoWithRemote, + gitRepoWithWorkflow, powershellProfile, sshDirectory, } from '../app/data/lessonSetup'; describe('lesson setups', () => { it('never mutate the state they receive', () => { - for (const setup of [gitRepoEmpty, gitRepoWithCommit, gitRepoWithRemote, gitRepoWithBranch('feature/x'), sshDirectory, powershellProfile]) { + for (const setup of [gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch('feature/x'), gitRepoPushed, gitRepoBehindRemote, gitRepoWithWorkflow, sshDirectory, powershellProfile]) { const base = createInitialState(); const snapshot = JSON.stringify(base); setup.apply(base); @@ -35,11 +37,8 @@ describe('lesson setups', () => { expect(out.lines.map((l) => l.text)).toEqual([' feature/ma-feature', '* main']); }); - it('give push lessons a commit and an origin remote', () => { - const s = gitRepoWithRemote.apply(createInitialState()); - const out = processCommand(s, 'git remote -v', 'linux'); - expect(out.lines[0].text).toBe('origin\thttps://github.com/user/mon-projet.git (fetch)'); - // Nothing pushed yet: no origin/main, so the first push creates it (git 2.56, 1 October 2026). + it('a remote just added holds nothing: the first push creates main there (git 2.56, 1 October 2026)', () => { + const s = processCommand(gitRepoWithCommit.apply(createInitialState()), 'git remote add origin https://github.com/user/mon-projet.git', 'linux').newState; expect(processCommand(s, 'git log --oneline', 'linux').lines[0].text).toBe('a3f8c12 (HEAD -> main) feat: premier commit du projet'); expect(processCommand(s, 'git push -u origin main', 'linux').lines.map((l) => l.text)).toEqual([ 'To https://github.com/user/mon-projet.git', @@ -48,6 +47,20 @@ describe('lesson setups', () => { ]); }); + it('give the GitHub lessons main pushed and tracked, as after git push -u origin main', () => { + const s = gitRepoPushed.apply(createInitialState()); + expect(processCommand(s, 'git remote -v', 'linux').lines[0].text).toBe('origin\thttps://github.com/user/mon-projet.git (fetch)'); + expect(processCommand(s, 'git status', 'linux').lines[1].text).toBe("Your branch is up to date with 'origin/main'."); + expect(processCommand(s, 'git push', 'linux').lines.map((l) => l.text)).toEqual(['Everything up-to-date']); + }); + + it('leave the GitHub Actions workflow at the root, untracked, for the lesson to move', () => { + const s = gitRepoWithWorkflow.apply(createInitialState()); + const status = processCommand(s, 'git status -s', 'linux').lines.map((l) => l.text); + expect(status).toEqual(['?? ci.yml']); + expect(processCommand(s, 'cat ci.yml', 'linux').lines.map((l) => l.text)).toContain(' - uses: actions/checkout@v7'); + }); + it('create ~/.ssh with the permissions the lesson teaches (700 / 600 / 644)', () => { const s = sshDirectory.apply(createInitialState()); const out = processCommand(s, 'ls -la ~/.ssh', 'linux').lines.map((l) => l.text).join('\n'); diff --git a/src/test/lessonSolutions.ts b/src/test/lessonSolutions.ts index b89e350..1a61bd8 100644 --- a/src/test/lessonSolutions.ts +++ b/src/test/lessonSolutions.ts @@ -105,16 +105,27 @@ export const LESSON_SOLUTIONS: Record = { }, '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'] }, - 'github-collaboration/git-fetch-clone': { all: ['git clone https://github.com/user/projet.git'] }, - 'github-collaboration/pull-requests': { all: ['git checkout -b feature/nouvelle-feature'] }, - 'github-collaboration/merge-strategies': { all: ['git merge --no-ff feature/ma-feature'] }, + 'github-collaboration/git-remote': { + all: ['git remote add origin https://github.com/user/mon-projet.git', 'git remote -v', 'git remote set-url origin git@github.com:user/mon-projet.git'], + }, + 'github-collaboration/git-push-pull': { + all: ['git pull', 'echo "Contact" > contact.html', 'git add contact.html', 'git commit -m "feat: ajoute la page contact"', 'git push'], + windows: ['git pull', 'Set-Content contact.html "Contact"', 'git add contact.html', 'git commit -m "feat: ajoute la page contact"', 'git push'], + }, + 'github-collaboration/git-fetch-clone': { all: ['git fetch', 'git log HEAD..origin/main --oneline', 'git merge origin/main'] }, + 'github-collaboration/pull-requests': { + all: ['git switch -c feature/contact', 'echo "Contact" > contact.html', 'git add contact.html', 'git commit -m "feat: ajoute la page contact"', 'git push -u origin feature/contact'], + windows: ['git switch -c feature/contact', 'Set-Content contact.html "Contact"', 'git add contact.html', 'git commit -m "feat: ajoute la page contact"', 'git push -u origin feature/contact'], + }, + 'github-collaboration/merge-strategies': { all: ['git merge --no-ff --no-edit feature/ma-feature', 'git branch -d feature/ma-feature'] }, 'github-collaboration/conflicts': { all: ['git merge feature/nouvelle-feature', 'cat index.html', 'git checkout --theirs index.html', 'git add index.html', 'git commit --no-edit'], windows: ['git merge feature/nouvelle-feature', 'Get-Content index.html', 'git checkout --theirs index.html', 'git add index.html', 'git commit --no-edit'], }, - 'github-collaboration/github-actions': { all: ['git status'] }, + 'github-collaboration/github-actions': { + all: ['mkdir -p .github/workflows', 'mv ci.yml .github/workflows/', 'git add .github', 'git commit -m "ci: ajoute le workflow de vérification"', 'git push'], + windows: ['New-Item -ItemType Directory .github/workflows', 'Move-Item ci.yml .github/workflows/', 'git add .github', 'git commit -m "ci: ajoute le workflow de vérification"', 'git push'], + }, // ── ia-dev ── 'ia-dev/ia-dev-intro': { all: ['ai-help'] }, 'ia-dev/ia-dev-capacites': { all: ['ai-help capabilities'] }, diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts index 64ae18f..156bfa5 100644 --- a/src/test/lessonTheoryGaps.ts +++ b/src/test/lessonTheoryGaps.ts @@ -110,43 +110,9 @@ export const KNOWN_THEORY_GAPS = new Set([ "git/git-merge b1 [macos] git merge feature/panier", "git/git-merge b2 [linux] git merge feature/login", "git/git-merge b2 [macos] git merge feature/login", - // github-collaboration/git-remote - "github-collaboration/git-remote b1 [linux] git remote rename origin upstream", - "github-collaboration/git-remote b1 [macos] git remote rename origin upstream", - "github-collaboration/git-remote b2 [linux] git remote set-url origin git@github.com:user/repo.git", - "github-collaboration/git-remote b2 [macos] git remote set-url origin git@github.com:user/repo.git", - // github-collaboration/git-push-pull - "github-collaboration/git-push-pull b2 [linux] git pull", - "github-collaboration/git-push-pull b2 [macos] git pull", // github-collaboration/git-fetch-clone - "github-collaboration/git-fetch-clone b2 [linux] git fetch origin", - "github-collaboration/git-fetch-clone b2 [linux] git fetch && git log HEAD..origin/main --oneline", - "github-collaboration/git-fetch-clone b2 [linux] git fetch --all", - "github-collaboration/git-fetch-clone b2 [linux] git diff main origin/main", - "github-collaboration/git-fetch-clone b2 [macos] git fetch origin", - "github-collaboration/git-fetch-clone b2 [macos] git fetch && git log HEAD..origin/main --oneline", - "github-collaboration/git-fetch-clone b2 [macos] git fetch --all", - "github-collaboration/git-fetch-clone b2 [macos] git diff main origin/main", "github-collaboration/git-fetch-clone b3 [linux] git merge upstream/main", "github-collaboration/git-fetch-clone b3 [macos] git merge upstream/main", - // github-collaboration/pull-requests - "github-collaboration/pull-requests b1 [linux] git push -u origin feature/THI-28-git-modules", - "github-collaboration/pull-requests b1 [macos] git push -u origin feature/THI-28-git-modules", - // github-collaboration/merge-strategies - "github-collaboration/merge-strategies b1 [linux] git merge --no-ff feature/panier", - "github-collaboration/merge-strategies b1 [linux] git log --oneline --graph", - "github-collaboration/merge-strategies b1 [macos] git merge --no-ff feature/panier", - "github-collaboration/merge-strategies b1 [macos] git log --oneline --graph", - "github-collaboration/merge-strategies b2 [linux] git merge --squash feature/panier", - "github-collaboration/merge-strategies b2 [linux] git log --oneline", - "github-collaboration/merge-strategies b2 [macos] git merge --squash feature/panier", - "github-collaboration/merge-strategies b2 [macos] git log --oneline", - "github-collaboration/merge-strategies b3 [linux] git checkout feature/panier", - "github-collaboration/merge-strategies b3 [linux] git merge feature/panier", - "github-collaboration/merge-strategies b3 [linux] git log --oneline", - "github-collaboration/merge-strategies b3 [macos] git checkout feature/panier", - "github-collaboration/merge-strategies b3 [macos] git merge feature/panier", - "github-collaboration/merge-strategies b3 [macos] git log --oneline", // github-collaboration/conflicts "github-collaboration/conflicts b1 [linux] git merge feature/login", "github-collaboration/conflicts b1 [linux] git status", diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index cc88f28..214815d 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -42,12 +42,6 @@ import { validateScp, validateGitConfig, validateGitStatusLog, - validateGitRemote, - validateGitPushPull, - validateGitFetchClone, - validatePullRequests, - validateMergeStrategies, - validateGithubActions, validateAiHelp, validateAiHelpCapabilities, validateAiHelpLimits, @@ -413,61 +407,6 @@ describe('validateGitStatusLog', () => { it('rejects arbitrary args (git status foo bar)', () => expect(validateGitStatusLog('git status foo bar')).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)); - it('rejects "git remote -v"', () => expect(validateGitRemote('git remote -v')).toBe(false)); -}); - -describe('validateGitPushPull', () => { - it('accepts "git push"', () => expect(validateGitPushPull('git push')).toBe(true)); - it('accepts "git push -u origin main"', () => expect(validateGitPushPull('git push -u origin main')).toBe(true)); - it('accepts "git push origin main"', () => expect(validateGitPushPull('git push origin main')).toBe(true)); - it('rejects "git pull"', () => expect(validateGitPushPull('git pull')).toBe(false)); -}); - -describe('validateGitFetchClone', () => { - it('accepts "git clone https://github.com/user/repo"', () => expect(validateGitFetchClone('git clone https://github.com/user/repo')).toBe(true)); - it('rejects "git fetch"', () => expect(validateGitFetchClone('git fetch')).toBe(false)); -}); - -// ── GitHub Collaboration ────────────────────────────────────────────────────── -describe('validatePullRequests', () => { - it('accepts "git checkout -b feature/my-pr"', () => expect(validatePullRequests('git checkout -b feature/my-pr')).toBe(true)); - it('accepts "git switch -c feature/my-pr"', () => expect(validatePullRequests('git switch -c feature/my-pr')).toBe(true)); - it('rejects "git checkout -b fix/bug"', () => expect(validatePullRequests('git checkout -b fix/bug')).toBe(false)); -}); - -describe('validateMergeStrategies', () => { - it('accepts "git merge --no-ff feature/ma-feature"', () => - expect(validateMergeStrategies('git merge --no-ff feature/ma-feature')).toBe(true)); - it('accepts "git merge --no-ff bugfix/123"', () => - expect(validateMergeStrategies('git merge --no-ff bugfix/123')).toBe(true)); - it('accepts flag-after-branch "git merge feature/ma-feature --no-ff"', () => - expect(validateMergeStrategies('git merge feature/ma-feature --no-ff')).toBe(true)); - it('accepts "git merge --no-ff feature/ma-feature -m \"msg\""', () => - expect(validateMergeStrategies('git merge --no-ff feature/ma-feature -m "msg"')).toBe(true)); - it('accepts "git merge --no-ff --no-edit feature/x" (extra harmless flag)', () => - expect(validateMergeStrategies('git merge --no-ff --no-edit feature/x')).toBe(true)); - it('rejects plain "git merge feature/ma-feature" (no --no-ff)', () => - expect(validateMergeStrategies('git merge feature/ma-feature')).toBe(false)); - it('rejects "git merge --squash feature/ma-feature"', () => - expect(validateMergeStrategies('git merge --squash feature/ma-feature')).toBe(false)); - it('rejects "git merge --no-ff --squash feature/x" (conflicting strategies)', () => - expect(validateMergeStrategies('git merge --no-ff --squash feature/x')).toBe(false)); - it('rejects "git merge --no-ff --ff-only feature/x" (conflicting strategies)', () => - expect(validateMergeStrategies('git merge --no-ff --ff-only feature/x')).toBe(false)); - it('rejects bare "git merge --no-ff" (no branch arg)', () => - expect(validateMergeStrategies('git merge --no-ff')).toBe(false)); - it('rejects "git mergeit --no-ff feature/x" (typo on subcommand)', () => - expect(validateMergeStrategies('git mergeit --no-ff feature/x')).toBe(false)); -}); - -describe('validateGithubActions', () => { - it('accepts "git status"', () => expect(validateGithubActions('git status')).toBe(true)); - it('rejects "git push"', () => expect(validateGithubActions('git push')).toBe(false)); -}); - // ── AI Help (Module 11) ────────────────────────────────────────────────────── describe('validateAiHelp', () => { it('accepts "ai-help"', () => expect(validateAiHelp('ai-help')).toBe(true));