From 61f74564f19545d360349d43f7f7fb8c9983555c Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:39:43 +0200 Subject: [PATCH] fix(terminal): git reads real files, and every output matches Git 2.56 The simulated git kept a list of names: `git add` accepted missing files and printed `staged:`, `git status` never saw a modified file, and `git diff` always printed the same invented example. - Model: repository path, HEAD tree, index, file modes and stash, read from the simulated filesystem (new commands/gitTree.ts: working tree, .gitignore rules, Myers diff, unified hunks, SHA-1 blob ids). - status (long, -s, -b, --porcelain, -u), add, rm, restore, commit, diff (HEAD, --staged, --name-only/-status, --numstat, --shortstat, -U), check-ignore, reset, stash and init follow git's wording, exit codes and option parsing (`-rf`, `-am`, `-uno`, `--source HEAD~1`). - Lesson repositories have .git/, a .gitignore that keeps .env out, and index.html; the git diff lesson starts from a real change. - Lesson and reference examples no longer add missing files or run `git remote` outside the cloned directory. Theory gaps: 158 -> 148. - A deleted diff line is stdout shown red (new line type `removed`); the lesson terminal restarts when the environment changes. Expected values in src/test/gitFidelity.test.ts come from real Git 2.56. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 + STORY.md | 10 + src/app/components/LessonPage.tsx | 10 +- src/app/components/TerminalEmulator.tsx | 8 +- src/app/data/commandExamples.ts | 2 +- src/app/data/commands/git.ts | 1130 ++++++++++++++++++----- src/app/data/commands/gitTree.ts | 352 +++++++ src/app/data/commands/types.ts | 24 +- src/app/data/curriculum.ts | 18 +- src/app/data/lessonSetup.ts | 63 +- src/app/data/terminalEngine.ts | 14 +- src/test/commandReferenceReplay.ts | 9 +- src/test/gitFidelity.test.ts | 493 ++++++++++ src/test/lessonFidelity.test.ts | 4 +- src/test/lessonSetup.test.ts | 4 +- src/test/lessonTheoryGaps.ts | 11 - src/test/lessonTheoryReplay.ts | 4 +- src/test/terminalEngine.test.ts | 76 +- 18 files changed, 1957 insertions(+), 290 deletions(-) create mode 100644 src/app/data/commands/gitTree.ts create mode 100644 src/test/gitFidelity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1cd69..4b7f813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ --- +## 🌿 30 septembre 2026 — Git se comporte comme le vrai Git + +*Modules Git et GitHub · valeurs attendues relevées sur Git 2.56 · écarts de la théorie : 158 → 148* + +Le Git du simulateur tenait une simple liste de noms de fichiers. `git add` acceptait un fichier qui n'existait pas et répondait `staged:`, ce que le vrai Git n'écrit jamais. `git status` ignorait les fichiers modifiés, et `git diff` affichait toujours le même exemple inventé. Git lit maintenant le contenu réel des fichiers, et chaque sortie a été comparée à celle de Git 2.56 sur une copie du projet des leçons. + +- **`git status` dit la vérité.** Fichiers non suivis, modifiés, supprimés ou prêts à être commités, avec les mêmes phrases et le même ordre que Git. Depuis un sous-dossier, les chemins s'écrivent `../README.md`, comme dans Git. `git status -s` donne la version courte (`M`, `A`, `??`). +- **`git add` est silencieux quand tout va bien**, et refuse un fichier inexistant avec l'erreur de Git. Il respecte aussi le `.gitignore` : `git add .env` est refusé, sauf avec `--force`. +- **Le premier commit ressemble au vrai** : `[main (root-commit) …]`, le nombre de lignes ajoutées et supprimées, et une ligne `create mode` par nouveau fichier. `git commit -a`, `git restore`, `git rm`, `git reset` et `git stash` agissent sur les vrais fichiers. +- **`git diff` montre la vraie différence**, ligne par ligne, avec les identifiants de fichiers que calcule Git. Les lignes retirées s'affichent en rouge et les lignes ajoutées en vert. +- **Des leçons plus justes.** Dans les leçons, `~/projets` est un vrai dépôt, avec son dossier `.git/` et un `.gitignore` qui protège déjà le fichier `.env`. La leçon « git diff » part d'une modification à comparer. Les exemples de théorie qui ajoutaient des fichiers inexistants, ou qui enchaînaient `git remote` sans entrer dans le dossier cloné, sont corrigés, dans les leçons comme dans la page Référence. +- **Changer d'environnement redémarre le terminal de la leçon**, pour qu'une session Linux ne devienne pas une session PowerShell en cours de route. + +--- + ## 📱 29 septembre 2026 — Un terminal lisible sur téléphone comme sur ordinateur *Terminal des leçons · mobile 390 px, tablette 1024 px, ordinateur 1280 px* diff --git a/STORY.md b/STORY.md index 73fc168..43e1f25 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 --- +## Un Git qui ne ment plus (30 septembre 2026) + +Dans le simulateur, `git add fichier-qui-n-existe-pas.txt` répondait poliment `staged:`. Le vrai Git refuse : `fatal: pathspec … did not match any files`. Ce n'était pas un détail. Nos propres tests ajoutaient des fichiers imaginaires, et la leçon sur `git add .` avertissait que la commande embarque le fichier `.env`, alors que notre Git cachait justement les fichiers commençant par un point. La leçon et le moteur se contredisaient. + +J'ai reconstruit le modèle autour de ce que Git garde vraiment : le contenu du dernier commit, la zone de préparation et les fichiers du dossier. Pour chaque sortie, la valeur attendue vient d'un vrai Git lancé sur une copie du projet des leçons, jusqu'aux identifiants `index 7d825c5..6827029` d'un diff. Deux conventions ont demandé une décision. Le simulateur stocke les fichiers sans leur dernier saut de ligne, alors Git les relit avec. Le dépôt préparé contient maintenant un `.gitignore` qui protège `.env`, parce qu'un exercice ne doit jamais apprendre à commiter un secret. + +Le rejeu de la théorie a trouvé ce que personne n'avait vu. Une leçon clonait trois fois le même dépôt dans le même dossier, puis lançait `git remote` sans entrer dans le dossier cloné. Un vrai terminal aurait refusé les deux. Les exemples tapent maintenant `cd projet`, comme on le ferait vraiment. + +--- + ## Regarder l'écran, pas seulement les tests (29 septembre 2026) Tous les tests étaient verts quand Thierry m'a demandé de vérifier moi-même, dans un navigateur, sur téléphone et sur ordinateur, que chaque correction se voyait vraiment. J'ai tapé `Get-ScheduledTask` dans le terminal d'une leçon, en largeur de téléphone. Le tableau s'est effondré : trois colonnes coupées en morceaux, une ligne de tirets orpheline. Le moteur répondait exactement comme PowerShell ; c'est l'affichage qui trahissait la réponse. Aucun test ne pouvait le voir, puisque les tests lisent le texte, pas l'écran. diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index 3b36866..73d47e3 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -221,10 +221,10 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { ? [`📚 ${lesson.title}`, ``, ...setupNote, `✓ Exercice déjà complété — « Suivant » pour continuer, ou pratique librement ci-dessous.`, ``] : [`📚 ${lesson.title}`, ``, ...setupNote, `Exercice : ${stripInlineMarkdown(effectiveInstruction)}`, ``] : [`📚 ${lesson.title}`, ``, `Terminal libre — pratiquez les commandes ci-dessous.`, ``]; - // Read once per terminal mount (lesson change or « Réinitialiser » remount). + // Read once per terminal mount (lesson or environment change, « Réinitialiser »). const buildInitialState = useCallback( - () => (setup ? setup.apply(createInitialState()) : createInitialState()), - [setup], + () => (setup ? setup.apply(createInitialState(), selectedEnv) : createInitialState()), + [setup, selectedEnv], ); return ( @@ -385,7 +385,9 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { = { error: 'text-[var(--github-red)]', success: 'text-[#3fb950]', info: 'text-[#58a6ff]', + removed: 'text-[var(--github-red)]', }; let lineCounter = 0; @@ -227,7 +229,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us // A script can clear the screen and then print: keep what came after. setLines(result.lines.map((l: OutputLine) => ({ id: nextId(), - type: l.type === 'error' ? 'error' as const : l.type === 'success' ? 'success' as const : 'output' as const, + type: l.type === 'error' || l.type === 'success' || l.type === 'removed' ? l.type : 'output' as const, text: l.text, }))); setTermState(result.newState); @@ -240,7 +242,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us { id: nextId(), type: 'prompt', text: trimmed, prompt }, ...result.lines.map((l: OutputLine) => ({ id: nextId(), - type: l.type === 'error' ? 'error' as const : l.type === 'success' ? 'success' as const : 'output' as const, + type: l.type === 'error' || l.type === 'success' || l.type === 'removed' ? l.type : 'output' as const, text: l.text, })), ]; diff --git a/src/app/data/commandExamples.ts b/src/app/data/commandExamples.ts index 9bedff4..c1259ca 100644 --- a/src/app/data/commandExamples.ts +++ b/src/app/data/commandExamples.ts @@ -431,7 +431,7 @@ export const COMMAND_EXAMPLES: Record = { ], git_clone: [ { command: 'git clone https://github.com/alice/mon-projet.git', explanation: 'Copie le dépôt complet, avec tout son historique, dans un nouveau dossier mon-projet.' }, - { command: 'git clone git@github.com:alice/mon-projet.git', explanation: 'Même chose par SSH : il faut une clé SSH ajoutée à ton compte GitHub.' }, + { command: 'git clone git@github.com:alice/site.git', explanation: 'Même chose par SSH, ici pour le dépôt site : il faut une clé SSH ajoutée à ton compte GitHub.' }, ], git_rebase: [ { command: 'git rebase main', explanation: 'Rejoue tes commits par-dessus la dernière version de main, pour un historique en ligne droite.' }, diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 286d041..7b8fe30 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -1,6 +1,20 @@ -import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, GitCommit, DirectoryNode, FSNode } from './types'; +import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, GitCommit, GitState, DirectoryNode, FSNode } from './types'; +import { + blobId, + countChanges, + decidingRule, + fileMode, + ignoredBy, + nodeAt, + parseIgnore, + readTree, + unifiedHunks, + writeFile, + type IgnoreRule, + type Tree, +} from './gitTree'; -// Local copies of tiny helpers needed by git add — avoids circular import with terminalEngine. +// Local copy of a tiny helper — avoids circular import with terminalEngine. function displayPath(cwd: string[]): string { if (cwd.length === 0) return '/'; return '/' + cwd.join('/'); @@ -12,26 +26,18 @@ function gitPath(cwd: string[], env: TerminalEnv): string { return ['C:', ...(cwd[0] === 'home' ? ['Users', ...cwd.slice(1)] : cwd)].join('/'); } -function getNode(root: DirectoryNode, path: string[]): FSNode | null { - let current: FSNode = root; - for (const seg of path) { - if (current.type !== 'directory') return null; - const child: FSNode | undefined = current.children[seg]; - if (!child) return null; - current = child; - } - return current; +function makeHash(length = 7): string { + return Array.from({ length }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join(''); } -function makeHash(): string { - return Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join(''); -} +const today = () => new Date().toISOString().slice(0, 10); /** * The message of `git commit`, in every form git accepts: `-m msg`, `-mmsg`, * `-am msg` (short options bundled, m last), `--message=msg`, `--message msg`. + * Null when no message option is given (git would open an editor). */ -function commitMessage(args: string[]): string { +function commitMessage(args: string[]): string | null { for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--message') return args[i + 1] ?? ''; @@ -41,152 +47,663 @@ function commitMessage(args: string[]): string { return attached || (args[i + 1] ?? ''); } } - return ''; + return null; } -export function handleGit(newState: TerminalState, args: string[], env: TerminalEnv): CommandOutput { - const sub = args[0]?.toLowerCase() ?? ''; +// ─── Repository model ──────────────────────────────────────────────────────── + +/** What `git init` writes in `.git/` (Git 2.56); Git for Windows adds its own core settings. */ +export function dotGit(env: TerminalEnv, branch = 'main'): DirectoryNode { + const dir = (children: Record = {}): DirectoryNode => + ({ type: 'directory', children, permissions: 'drwxr-xr-x', owner: 'user', group: 'user' }); + const file = (content: string): FSNode => + ({ type: 'file', content, permissions: '-rw-r--r--', owner: 'user', group: 'user', size: content.length }); + const core = env === 'windows' + ? '\tfilemode = false\n\tbare = false\n\tlogallrefupdates = true\n\tsymlinks = false\n\tignorecase = true\n' + : `\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n${env === 'macos' ? '\tignorecase = true\n\tprecomposeunicode = true\n' : ''}`; + return dir({ + HEAD: file(`ref: refs/heads/${branch}\n`), + config: file(`[core]\n\trepositoryformatversion = 0\n${core}`), + description: file("Unnamed repository; edit this file 'description' to name the repository.\n"), + hooks: dir(), + info: dir({ exclude: file("# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostly in C, the following would be a\n# good set of exclude patterns (uncomment them if you want to use them):\n# *.[oa]\n# *~\n") }), + objects: dir({ info: dir(), pack: dir() }), + refs: dir({ heads: dir(), tags: dir() }), + }); +} + +/** Paths whose content differs between two trees, sorted as git lists them. */ +function changes(from: Tree, to: Tree): Array<{ path: string; kind: 'new file' | 'modified' | 'deleted' }> { + const paths = [...new Set([...Object.keys(from), ...Object.keys(to)])].sort(); + return paths + .filter((p) => from[p] !== to[p]) + .map((path) => ({ path, kind: !(path in from) ? 'new file' as const : !(path in to) ? 'deleted' as const : 'modified' as const })); +} + +/** `stagedFiles` mirrors the index: the paths a commit would record. */ +function synced(g: GitState): GitState { + return { ...g, stagedFiles: changes(g.head ?? {}, g.index ?? {}).map((c) => c.path) }; +} + +/** + * Fills what a git state may leave out (states written by hand in tests): + * the repository is the current directory, its `.git/` exists, what was + * committed is the files present if there are commits, and the index adds the + * files listed as staged. + */ +function withModel(state: TerminalState, env: TerminalEnv): TerminalState { + const g = state.git; + if (!g?.initialized || (g.repoPath && g.head && g.index && g.modes)) return state; + const repoPath = g.repoPath ?? state.cwd; + const files = readTree(state.root, repoPath); + const head = g.head ?? (g.commits.length ? { ...files } : {}); + const staged = g.stagedFiles.filter((f) => f in files).map((f) => [f, files[f]]); + const index = g.index ?? { ...head, ...Object.fromEntries(staged) }; + const modes = g.modes ?? Object.fromEntries(Object.keys({ ...head, ...index }) + .map((p) => [p, fileMode(nodeAt(state.root, [...repoPath, ...p.split('/')]), env)])); + const root = nodeAt(state.root, [...repoPath, '.git']) ? state.root : placeDir(state.root, [...repoPath, '.git'], dotGit(env)); + return { ...state, root, git: synced({ ...g, repoPath, head, index, modes }) }; +} + +/** A copy of `root` with `dir` placed at `path` (its parent must exist). */ +function placeDir(root: DirectoryNode, path: string[], dir: DirectoryNode): DirectoryNode { + const [head, ...rest] = path; + if (rest.length === 0) return { ...root, children: { ...root.children, [head]: dir } }; + const child = root.children[head]; + if (child?.type !== 'directory') return root; + return { ...root, children: { ...root.children, [head]: placeDir(child, rest, dir) } }; +} + +function isInside(cwd: string[], dir: string[]): boolean { + return cwd.length >= dir.length && dir.every((seg, i) => cwd[i] === seg); +} + +/** How git shows a repository path from the current directory (`../a.txt`, `./`). */ +function shown(path: string, cwdInRepo: string[], isDir = false): string { + const parts = path ? path.split('/') : []; + let i = 0; + while (i < cwdInRepo.length && i < parts.length && cwdInRepo[i] === parts[i]) i++; + const rel = [...Array(cwdInRepo.length - i).fill('..'), ...parts.slice(i)].join('/'); + return isDir ? `${rel || '.'}/` : rel; +} + +interface Repo { + g: GitState; + path: string[]; + head: Tree; + index: Tree; + work: Tree; + rules: IgnoreRule[]; + /** The current directory, relative to the repository. */ + cwdInRepo: string[]; +} - const requireRepo = (): OutputLine | null => { - if (!newState.git?.initialized) { - return { text: "fatal: not a git repository (or any parent up to mount point /)\nHint: Run 'git init' to create a new repository.", type: 'error' }; +function openRepo(state: TerminalState): Repo { + const g = state.git!; + const path = g.repoPath!; + const work = readTree(state.root, path); + return { + g, + path, + head: g.head!, + index: g.index!, + work, + rules: parseIgnore(work['.gitignore'] ?? ''), + cwdInRepo: state.cwd.slice(path.length), + }; +} + +/** Files of `tree` at `rel` or below it (`''` is the whole repository). */ +function under(tree: Tree, rel: string): string[] { + return Object.keys(tree).filter((p) => !rel || p === rel || p.startsWith(`${rel}/`)); +} + +/** + * Untracked paths as `git status` lists them: a directory whose files are all + * untracked shows as `dir/`, ignored files are left out, empty directories too. + */ +function untracked(state: TerminalState, repo: Repo, collapse = true): Array<{ path: string; dir: boolean }> { + const out: Array<{ path: string; dir: boolean }> = []; + const visible = (p: string) => !(p in repo.index) && !ignoredBy(repo.rules, p, false); + const walk = (node: DirectoryNode, rel: string) => { + for (const [name, child] of Object.entries(node.children)) { + if (name === '.git') continue; + const p = rel ? `${rel}/${name}` : name; + if (child.type === 'file') { + if (visible(p)) out.push({ path: p, dir: false }); + continue; + } + if (ignoredBy(repo.rules, p, true)) continue; + const files = under(repo.work, p).filter((f) => !ignoredBy(repo.rules, f, false)); + if (!files.length) continue; + if (collapse && files.every((f) => !(f in repo.index))) out.push({ path: p, dir: true }); + else walk(child, p); } - return null; }; + const start = nodeAt(state.root, repo.path); + if (start?.type === 'directory') walk(start, ''); + return out.sort((a, b) => (a.path + (a.dir ? '/' : '') < b.path + (b.dir ? '/' : '') ? -1 : 1)); +} + +/** Tracked files whose working copy differs from the index. */ +function unstaged(repo: Repo): Array<{ path: string; kind: 'modified' | 'deleted' }> { + return Object.keys(repo.index).sort() + .filter((p) => repo.work[p] !== repo.index[p]) + .map((path) => ({ path, kind: path in repo.work ? 'modified' as const : 'deleted' as const })); +} + +const pad = (kind: string) => `${kind}:`.padEnd(12); + +/** + * The long `git status` report (Git 2.56). `git commit` with nothing staged + * prints the same report, with "Initial commit" in place of "No commits yet". + */ +function statusLines(state: TerminalState, repo: Repo, forCommit = false, untrackedMode = 'normal'): OutputLine[] { + const { g } = repo; + const out: OutputLine[] = []; + const say = (text: string, type: OutputLine['type'] = 'output') => out.push({ text, type }); + say(`On branch ${g.branch}`); + if (g.commits.length === 0) { say(''); say(forCommit ? 'Initial commit' : 'No commits yet'); say(''); } + + const staged = changes(repo.head, repo.index); + const notStaged = unstaged(repo); + const newFiles = untrackedMode === 'no' ? [] : untracked(state, repo, untrackedMode !== 'all'); + if (staged.length) { + say('Changes to be committed:'); + say(g.commits.length ? ' (use "git restore --staged ..." to unstage)' : ' (use "git rm --cached ..." to unstage)'); + staged.forEach((c) => say(`\t${pad(c.kind)}${shown(c.path, repo.cwdInRepo)}`, 'success')); + say(''); + } + if (notStaged.length) { + say('Changes not staged for commit:'); + say(notStaged.some((c) => c.kind === 'deleted') + ? ' (use "git add/rm ..." to update what will be committed)' + : ' (use "git add ..." to update what will be committed)'); + say(' (use "git restore ..." to discard changes in working directory)'); + notStaged.forEach((c) => say(`\t${pad(c.kind)}${shown(c.path, repo.cwdInRepo)}`, 'removed')); + say(''); + } + if (newFiles.length) { + say('Untracked files:'); + say(' (use "git add ..." to include in what will be committed)'); + newFiles.forEach((f) => say(`\t${shown(f.path, repo.cwdInRepo, f.dir)}`, 'removed')); + say(''); + } + if (!staged.length) { + if (notStaged.length) say('no changes added to commit (use "git add" and/or "git commit -a")'); + else if (newFiles.length) say('nothing added to commit but untracked files present (use "git add" to track)'); + else if (untrackedMode === 'no') say('nothing to commit (use -u to show untracked files)'); + else if (g.commits.length) say('nothing to commit, working tree clean'); + else say('nothing to commit (create/copy files and use "git add" to track)'); + } + return out; +} + +/** The unified diff of every path that differs between two trees (`git diff`). */ +function diffLines(from: Tree, to: Tree, paths: string[] | null, state: TerminalState, repo: Repo, env: TerminalEnv, context = 3): OutputLine[] { + const out: OutputLine[] = []; + for (const c of changes(from, to)) { + if (paths && !paths.some((p) => !p || c.path === p || c.path.startsWith(`${p}/`))) continue; + const mode = repo.g.modes?.[c.path] ?? fileMode(nodeAt(state.root, [...repo.path, ...c.path.split('/')]), env); + const before = from[c.path] ?? ''; + const after = to[c.path] ?? ''; + out.push({ text: `diff --git a/${c.path} b/${c.path}`, type: 'output' }); + if (c.kind === 'new file') { + out.push({ text: `new file mode ${mode}`, type: 'output' }, { text: `index 0000000..${blobId(after).slice(0, 7)}`, type: 'output' }); + } else if (c.kind === 'deleted') { + out.push({ text: `deleted file mode ${mode}`, type: 'output' }, { text: `index ${blobId(before).slice(0, 7)}..0000000`, type: 'output' }); + } else { + out.push({ text: `index ${blobId(before).slice(0, 7)}..${blobId(after).slice(0, 7)} ${mode}`, type: 'output' }); + } + const hunks = unifiedHunks(before, after, context); + // An empty file added or removed has no lines, hence no hunk and no ---/+++ header. + if (!hunks.length) continue; + out.push( + { text: c.kind === 'new file' ? '--- /dev/null' : `--- a/${c.path}`, type: 'output' }, + { text: c.kind === 'deleted' ? '+++ /dev/null' : `+++ b/${c.path}`, type: 'output' }, + ); + hunks.forEach((text) => out.push({ + text, + type: text.startsWith('+') ? 'success' : text.startsWith('-') ? 'removed' : 'output', + })); + } + return out; +} + +/** ` 2 files changed, 3 insertions(+), 1 deletion(-)`, as git words it (both counts shown when both are 0). */ +function statLine(files: number, insertions: number, deletions: number): string { + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + let line = ` ${plural(files, 'file')} changed`; + if (insertions || !deletions) line += `, ${plural(insertions, 'insertion')}(+)`; + if (deletions || !insertions) line += `, ${plural(deletions, 'deletion')}(-)`; + return line; +} + +interface Options { + short: Set; + long: Map; + /** Values of the short options that take one (`-m msg`, `-b dev`, `-uno`). */ + values: Map; + /** Arguments that are not options, before `--`. */ + positional: string[]; + /** Arguments after `--`: always paths. */ + paths: string[]; + /** A short option that needs a value and got none (`git commit -m`). */ + missing: string | null; +} + +/** + * Options as git's parser reads them: `-rf` is `-r -f`, `--name=value` + * carries a value, `--` ends the options. Short options in `valued` take the + * rest of the word or the next word (`-mmsg`, `-m msg`); those in `attached` + * only take the rest of the word (`-uno`, `-u`); long options in `longValued` also take the next word. + */ +function parseOptions(args: string[], valued = '', attached = '', longValued: string[] = []): Options { + const o: Options = { short: new Set(), long: new Map(), values: new Map(), positional: [], paths: [], missing: null }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--') { o.paths.push(...args.slice(i + 1)); break; } + if (a.startsWith('--')) { + const eq = a.indexOf('='); + const name = eq > 0 ? a.slice(2, eq) : a.slice(2); + // `--source HEAD~1` as well as `--source=HEAD~1`, for the options that take a value. + o.long.set(name, eq > 0 ? a.slice(eq + 1) : longValued.includes(name) && i + 1 < args.length ? args[++i] : true); + } else if (a.startsWith('-') && a.length > 1) { + for (let j = 1; j < a.length; j++) { + const c = a[j]; + if (attached.includes(c)) { o.values.set(c, a.slice(j + 1)); break; } + if (valued.includes(c)) { + const value = a.slice(j + 1) || args[++i]; + if (value === undefined) o.missing = c; + else o.values.set(c, value); + break; + } + o.short.add(c); + } + } else o.positional.push(a); + } + return o; +} + +const text = (v: string | true | undefined) => (typeof v === 'string' ? v : undefined); + +const AMBIGUOUS = (arg: string): OutputLine[] => [ + { text: `fatal: ambiguous argument '${arg}': unknown revision or path not in the working tree.`, type: 'error' }, + { text: "Use '--' to separate paths from revisions, like this:", type: 'error' }, + { text: "'git [...] -- [...]'", type: 'error' }, +]; + +/** `git reset --bogus`: the option error and git 2.56's usage, on stderr. */ +const RESET_USAGE = [ + 'usage: git reset [--mixed | --soft | --hard | --merge | --keep] [-q] []', + ' or: git reset [-q] [] [--] ...', + ' or: git reset [-q] [--pathspec-from-file [--pathspec-file-nul]] []', + ' or: git reset --patch [] [--] [...]', + ' or: DEPRECATED: git reset [-q] [--stdin [-z]] []', + '', + ' -q, --[no-]quiet be quiet, only report errors', + ' --no-refresh skip refreshing the index after reset', + ' --refresh opposite of --no-refresh', + ' --mixed reset HEAD and index', + ' --soft reset only HEAD', + ' --hard reset HEAD, index and working tree', + ' --merge reset HEAD, index and working tree', + ' --keep reset HEAD but keep local changes', + ' --[no-]recurse-submodules[=]', + ' control recursive updating of submodules', + ' -p, --[no-]patch select hunks interactively', + ' --[no-]auto-advance auto advance to the next file when selecting hunks interactively', + ' -U, --unified generate diffs with lines context', + ' --inter-hunk-context ', + ' show context between diff hunks up to the specified number of lines', + ' -N, --[no-]intent-to-add', + ' record only the fact that removed paths will be added later', + ' --[no-]pathspec-from-file ', + ' read pathspec from file', + ' --[no-]pathspec-file-nul', + ' with --pathspec-from-file, pathspec elements are separated with NUL character', + ' -z DEPRECATED (use --pathspec-file-nul instead): paths are separated with NUL character', + ' --[no-]stdin DEPRECATED (use --pathspec-from-file=- instead): read paths from ', + '', +]; + +const NOT_A_REPO = 'fatal: not a git repository (or any of the parent directories): .git'; + +function notSimulated(what: string, newState: TerminalState): CommandOutput { + return { lines: [{ text: `${what} n'est pas simulé dans ce terminal d'entraînement.`, type: 'info' }], newState, status: 1 }; +} + +export function handleGit( + state: TerminalState, + args: string[], + env: TerminalEnv, + /** Resolves a path typed by the learner to an absolute path (the engine's `resolvePath`). */ + resolve: (path: string) => string[] = (p) => (p.startsWith('/') ? p.split('/').filter(Boolean) : [...state.cwd, ...p.split('/').filter((s) => s && s !== '.')]), +): CommandOutput { + const sub = args[0]?.toLowerCase() ?? ''; + let newState = withModel(state, env); + + /** The repository around the current directory, or null (git looks for `.git/` upwards). */ + const inRepo = (): boolean => { + const g = newState.git; + return !!g?.initialized && isInside(newState.cwd, g.repoPath!) && !!nodeAt(newState.root, [...g.repoPath!, '.git']); + }; + const notARepo = (): CommandOutput => ({ lines: [{ text: NOT_A_REPO, type: 'error' }], newState, status: 128 }); + const setGit = (git: GitState, root = newState.root) => { newState = { ...newState, root, git: synced(git) }; }; + + /** The repository path of `arg`, or null when it points outside the repository. */ + const repoRel = (arg: string): string | null => { + const abs = resolve(arg); + const repoPath = newState.git!.repoPath!; + return isInside(abs, repoPath) ? abs.slice(repoPath.length).join('/') : null; + }; + + /** The files of the commit `ref` names (HEAD, HEAD~N, HEAD^, a hash prefix), or null. */ + const commitTree = (ref: string): Tree | null => { + const g = newState.git!; + const back = ref.match(/^(?:HEAD|@)(?:~(\d*)|(\^))?$/); + const at = back + ? (back[2] ? 1 : back[1] === undefined ? 0 : Number(back[1] || 1)) + : ref.length >= 4 && /^[0-9a-f]+$/.test(ref) ? g.commits.findIndex((c) => c.hash.startsWith(ref)) : -1; + if (at < 0 || at >= g.commits.length) return null; + return g.commits[at].tree ?? g.head!; + }; + /** Whether `rel` names something git knows or sees: a file or directory, or a path in a tree. */ + const known = (rel: string | null, ...trees: Tree[]) => + rel !== null && (!!nodeAt(newState.root, [...newState.git!.repoPath!, ...rel.split('/').filter(Boolean)]) + || trees.some((t) => under(t, rel).length > 0)); switch (sub) { // ── git init ────────────────────────────────────────────────────────────── case 'init': { - if (newState.git?.initialized) { - return { - lines: [{ text: `Reinitialized existing Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'info' }], - newState, - }; + const o = parseOptions(args.slice(1), 'b', '', ['initial-branch']); + const quiet = o.short.has('q') || o.long.has('quiet'); + const initial = o.values.get('b') ?? text(o.long.get('initial-branch')) ?? 'main'; + const where = gitPath(newState.cwd, env); + if (newState.git?.initialized && newState.git.repoPath && displayPath(newState.git.repoPath) === displayPath(newState.cwd) + && nodeAt(newState.root, [...newState.cwd, '.git'])) { + return { lines: quiet ? [] : [{ text: `Reinitialized existing Git repository in ${where}/.git/`, type: 'output' }], newState }; } - newState = { - ...newState, - git: { - initialized: true, - branch: 'main', - branches: ['main'], - stagedFiles: [], - commits: [], - remotes: {}, - }, - }; - return { - lines: [ - { text: `Initialized empty Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'success' }, - { text: "Hint: Use 'git add ' to stage files, 'git commit -m' to record changes.", type: 'info' }, - ], - newState, + const git: GitState = { + initialized: true, + branch: initial, + branches: [initial], + stagedFiles: [], + commits: [], + remotes: {}, + repoPath: newState.cwd, + head: {}, + index: {}, + modes: {}, }; + setGit(git, placeDir(newState.root, [...newState.cwd, '.git'], dotGit(env, initial))); + return { lines: quiet ? [] : [{ text: `Initialized empty Git repository in ${where}/.git/`, type: 'success' }], newState }; } // ── git status ──────────────────────────────────────────────────────────── case 'status': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const g = newState.git!; - const lines: OutputLine[] = [{ text: `On branch ${g.branch}`, type: 'output' }]; - if (g.commits.length === 0) lines.push({ text: 'No commits yet', type: 'output' }); - if (g.stagedFiles.length > 0) { - lines.push({ text: '', type: 'output' }); - lines.push({ text: 'Changes to be committed:', type: 'success' }); - lines.push({ text: ' (use "git restore --staged " to unstage)', type: 'info' }); - g.stagedFiles.forEach((f) => lines.push({ text: `\tnew file: ${f}`, type: 'success' })); - } - if (g.stagedFiles.length === 0 && g.commits.length === 0) { - lines.push({ text: '', type: 'output' }); - lines.push({ text: 'Nothing to commit.', type: 'output' }); - lines.push({ text: "Hint: Stage files with 'git add ' or 'git add .'", type: 'info' }); - } - if (g.stagedFiles.length === 0 && g.commits.length > 0) { - lines.push({ text: '', type: 'output' }); - lines.push({ text: 'nothing to commit, working tree clean', type: 'output' }); + if (!inRepo()) return notARepo(); + const repo = openRepo(newState); + const g = repo.g; + const o = parseOptions(args.slice(1), '', 'u'); + const porcelain = o.long.has('porcelain'); + const untrackedMode = (o.values.get('u') ?? text(o.long.get('untracked-files'))) || 'normal'; + if (!porcelain && !o.short.has('s') && !o.long.has('short')) { + return { lines: statusLines(newState, repo, false, untrackedMode), newState }; + } + // XY path: X compares the index with HEAD, Y the working tree with the index. + // Porcelain paths start at the repository root, short ones at the current directory. + const from = porcelain ? [] : repo.cwdInRepo; + const letter = { 'new file': 'A', modified: 'M', deleted: 'D' } as const; + const x = new Map(changes(repo.head, repo.index).map((c) => [c.path, letter[c.kind]])); + const y = new Map(unstaged(repo).map((c) => [c.path, letter[c.kind]])); + const lines: OutputLine[] = []; + if (o.short.has('b') || o.long.has('branch')) { + lines.push({ text: `## ${g.commits.length ? g.branch : `No commits yet on ${g.branch}`}`, type: 'output' }); + } + [...new Set([...x.keys(), ...y.keys()])].sort() + .forEach((p) => lines.push({ text: `${x.get(p) ?? ' '}${y.get(p) ?? ' '} ${shown(p, from)}`, type: 'output' })); + if (untrackedMode !== 'no') { + untracked(newState, repo, untrackedMode !== 'all') + .forEach((f) => lines.push({ text: `?? ${shown(f.path, from, f.dir)}`, type: 'output' })); } return { lines, newState }; } // ── git add ─────────────────────────────────────────────────────────────── case 'add': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const target = args[1] ?? ''; - if (!target) { - return { lines: [{ text: 'Nothing specified, nothing added.\nHint: git add or git add .', type: 'error' }], newState }; + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1)); + if (o.short.has('p') || o.long.has('patch') || o.short.has('i') || o.long.has('interactive')) { + return notSimulated('Le mode interactif de git add (-p)', newState); } - const g = newState.git!; - let filesToStage: string[]; - if (target === '.' || target === '-A' || target === '--all') { - const cwdNode = getNode(newState.root, newState.cwd); - filesToStage = cwdNode?.type === 'directory' - ? Object.keys(cwdNode.children).filter((n) => !n.startsWith('.') && !g.stagedFiles.includes(n)) - : []; - } else { - filesToStage = [target].filter((f) => !g.stagedFiles.includes(f)); + const force = o.short.has('f') || o.long.has('force'); + const all = o.short.has('A') || o.long.has('all'); + const update = o.short.has('u') || o.long.has('update'); + const dryRun = o.short.has('n') || o.long.has('dry-run'); + const verbose = dryRun || o.short.has('v') || o.long.has('verbose'); + if (all && update) { + return { lines: [{ text: "fatal: options '-u/--update' and '-A/--all' cannot be used together", type: 'error' }], newState, status: 128 }; } - if (filesToStage.length === 0) { - return { lines: [{ text: `'${target}' already staged or no files to add.`, type: 'info' }], newState }; + const specs = [...o.positional, ...o.paths]; + if (!specs.length && !all && !update) { + return { + lines: [ + { text: 'Nothing specified, nothing added.', type: 'error' }, + { text: "hint: Maybe you wanted to say 'git add .'?", type: 'error' }, + { text: 'hint: Disable this message with "git config set advice.addEmptyPathspec false"', type: 'error' }, + ], + newState, + status: 0, + }; } - newState = { ...newState, git: { ...g, stagedFiles: [...g.stagedFiles, ...filesToStage] } }; + const repo = openRepo(newState); + // Without a path, -A and -u cover the whole repository. + const targets = specs.length ? specs.map((arg) => ({ arg, rel: repoRel(arg) })) : [{ arg: '', rel: '' as string | null }]; + for (const t of targets) { + if (!known(t.rel, repo.index)) { + return { lines: [{ text: `fatal: pathspec '${t.arg}' did not match any files`, type: 'error' }], newState, status: 128 }; + } + } + const index = { ...repo.index }; + const modes = { ...repo.g.modes }; + const refused: string[] = []; + const said: OutputLine[] = []; + for (const t of targets) { + const rel = t.rel!; + const isFile = rel in repo.work; + if (isFile && !force && !(rel in index) && ignoredBy(repo.rules, rel, false)) { refused.push(t.arg); continue; } + if (!isFile && rel && !force && ignoredBy(repo.rules, rel, true)) { refused.push(t.arg); continue; } + for (const p of new Set([...under(repo.work, rel), ...under(index, rel)])) { + if (update && !(p in index)) continue; + if (!(p in repo.work)) { + if (p in index) said.push({ text: `remove '${p}'`, type: 'output' }); + delete index[p]; + continue; + } + if (!(p in index) && !force && ignoredBy(repo.rules, p, false)) continue; + if (index[p] !== repo.work[p]) said.push({ text: `add '${p}'`, type: 'output' }); + index[p] = repo.work[p]; + modes[p] = fileMode(nodeAt(newState.root, [...repo.path, ...p.split('/')]), env); + } + } + if (!dryRun) setGit({ ...repo.g, index, modes }); + const lines = verbose ? said : []; + if (!refused.length) return { lines, newState }; return { - lines: filesToStage.map((f) => ({ text: `staged: ${f}`, type: 'success' as const })), + lines: [ + ...lines, + { text: 'The following paths are ignored by one of your .gitignore files:', type: 'error' }, + ...refused.map((path) => ({ text: path, type: 'error' as const })), + { text: 'hint: Use -f if you really want to add them.', type: 'error' }, + { text: 'hint: Disable this message with "git config set advice.addIgnoredFile false"', type: 'error' }, + ], newState, + status: 1, }; } + // ── git rm ──────────────────────────────────────────────────────────────── + case 'rm': { + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1)); + const cached = o.long.has('cached'); + const force = o.short.has('f') || o.long.has('force'); + const quiet = o.short.has('q') || o.long.has('quiet'); + const specs = [...o.positional, ...o.paths]; + if (!specs.length) return { lines: [{ text: 'usage: git rm [] [--] ...', type: 'error' }], newState, status: 129 }; + const repo = openRepo(newState); + const paths: string[] = []; + for (const arg of specs) { + const rel = repoRel(arg); + const found = rel === null ? [] : under(repo.index, rel); + if (!found.length) return { lines: [{ text: `fatal: pathspec '${arg}' did not match any files`, type: 'error' }], newState, status: 128 }; + if (found.some((p) => p !== rel) && !o.short.has('r')) { + return { lines: [{ text: `fatal: not removing '${arg}' recursively without -r`, type: 'error' }], newState, status: 128 }; + } + paths.push(...found); + } + if (!force) { + // Git refuses to lose work: a change staged, or a change on disk, not committed anywhere. + const both: string[] = []; + const staged: string[] = []; + const local: string[] = []; + for (const p of paths) { + const stagedChange = repo.index[p] !== repo.head[p]; + const diskChange = p in repo.work && repo.work[p] !== repo.index[p]; + if (stagedChange && diskChange && repo.work[p] !== repo.head[p]) both.push(p); + else if (!cached && stagedChange) staged.push(p); + else if (!cached && diskChange) local.push(p); + } + const block = (list: string[], one: string, many: string, hint: string): OutputLine[] => (list.length + ? [...(list.length === 1 ? one : many).split('\n'), ...list.map((p) => ` ${p}`), hint].map((t) => ({ text: t, type: 'error' as const })) + : []); + const lines = [ + ...block(both, 'error: the following file has staged content different from both the\nfile and the HEAD:', + 'error: the following files have staged content different from both the\nfile and the HEAD:', '(use -f to force removal)'), + ...block(staged, 'error: the following file has changes staged in the index:', + 'error: the following files have changes staged in the index:', '(use --cached to keep the file, or -f to force removal)'), + ...block(local, 'error: the following file has local modifications:', + 'error: the following files have local modifications:', '(use --cached to keep the file, or -f to force removal)'), + ]; + if (lines.length) return { lines, newState, status: 1 }; + } + const index = { ...repo.index }; + let root = newState.root; + for (const p of paths) { + delete index[p]; + if (!cached) root = writeFile(root, [...repo.path, ...p.split('/')], null); + } + setGit({ ...repo.g, index }, root); + return { lines: quiet ? [] : paths.map((p) => ({ text: `rm '${p}'`, type: 'output' as const })), newState }; + } + // ── git restore ─────────────────────────────────────────────────────────── case 'restore': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const staged = args.includes('--staged'); - const file = args.find((a) => !a.startsWith('-')) ?? ''; - if (!file) return { lines: [{ text: 'Usage: git restore [--staged] ', type: 'error' }], newState }; - if (staged) { - const g = newState.git!; - newState = { ...newState, git: { ...g, stagedFiles: g.stagedFiles.filter((f) => f !== file) } }; - return { lines: [{ text: `unstaged: ${file}`, type: 'info' }], newState }; - } - return { lines: [{ text: `Restored '${file}' (simulated)`, type: 'info' }], newState }; + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1), 's', '', ['source']); + const staged = o.short.has('S') || o.long.has('staged'); + const worktree = !staged || o.short.has('W') || o.long.has('worktree'); + const specs = [...o.positional, ...o.paths]; + if (!specs.length) return { lines: [{ text: 'fatal: you must specify path(s) to restore', type: 'error' }], newState, status: 128 }; + const repo = openRepo(newState); + const sourceRef = o.values.get('s') ?? text(o.long.get('source')); + const source = sourceRef ? commitTree(sourceRef) : null; + if (sourceRef && !source) { + return { lines: [{ text: `fatal: could not resolve ${sourceRef}`, type: 'error' }], newState, status: 128 }; + } + const index = { ...repo.index }; + let root = newState.root; + for (const arg of specs) { + const rel = repoRel(arg); + const paths = rel === null ? [] : [...new Set([...under(source ?? repo.head, rel), ...under(repo.index, rel)])]; + if (!paths.length) { + return { lines: [{ text: `error: pathspec '${arg}' did not match any file(s) known to git`, type: 'error' }], newState, status: 1 }; + } + for (const p of paths) { + // --staged takes HEAD (or the source); the working tree takes the index (or the source). + if (staged) { + const from = source ?? repo.head; + if (p in from) index[p] = from[p]; + else delete index[p]; + } + if (worktree) { + const from = source && !staged ? source : index; + const want = p in from ? from[p] : null; + if ((p in repo.work ? repo.work[p] : null) !== want && (want !== null || p in repo.index)) { + root = writeFile(root, [...repo.path, ...p.split('/')], want); + } + } + } + } + setGit({ ...repo.g, index }, root); + return { lines: [], newState }; } // ── git commit ──────────────────────────────────────────────────────────── case 'commit': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const g = newState.git!; + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1), 'm'); + if (o.missing === 'm') return { lines: [{ text: "error: switch `m' requires a value", type: 'error' }], newState, status: 129 }; + if (o.long.has('amend')) return notSimulated('git commit --amend', newState); + const quiet = o.short.has('q') || o.long.has('quiet'); + const allowEmpty = o.long.has('allow-empty'); const message = commitMessage(args); - if (!message) { - return { lines: [{ text: 'Abort: empty commit message.\nUsage: git commit -m "your message"', type: 'error' }], newState }; + let repo = openRepo(newState); + if (o.short.has('a') || o.long.has('all')) { + const index = { ...repo.index }; + for (const p of Object.keys(index)) { + if (p in repo.work) index[p] = repo.work[p]; + else delete index[p]; + } + setGit({ ...repo.g, index }); + repo = openRepo(newState); } - if (g.stagedFiles.length === 0) { - // Git prints the short status and fails (exit 1) when nothing is staged. - const text = g.commits.length > 0 - ? [`On branch ${g.branch}`, 'nothing to commit, working tree clean'] - : [`On branch ${g.branch}`, '', 'Initial commit', '', 'nothing to commit (create/copy files and use "git add" to track)']; - return { lines: text.map((t) => ({ text: t, type: 'output' as const })), newState, status: 1 }; + const recorded = changes(repo.head, repo.index); + if (!recorded.length && !allowEmpty) { + // Git prints the status and fails (exit 1) when nothing is staged. + return { lines: statusLines(newState, repo, true), newState, status: 1 }; } + if (message === null) { + return { + lines: [{ text: 'Sans -m, git commit ouvre un éditeur pour écrire le message : il n\'est pas simulé ici. Écrivez git commit -m "votre message".', type: 'info' }], + newState, + status: 1, + }; + } + if (!message.trim()) { + return { lines: [{ text: 'Aborting commit due to empty commit message.', type: 'error' }], newState, status: 1 }; + } + const g = repo.g; const hash = makeHash(); - const commit: GitCommit = { hash, message, author: newState.user, date: new Date().toISOString().slice(0, 10) }; - const fileCount = g.stagedFiles.length; - newState = { ...newState, git: { ...g, commits: [commit, ...g.commits], stagedFiles: [] } }; - return { - lines: [ - { text: `[${g.branch} ${hash}] ${message}`, type: 'success' }, - { text: ` ${fileCount} file${fileCount !== 1 ? 's' : ''} changed`, type: 'output' }, - ], - newState, - }; + const tree = { ...repo.index }; + const commit: GitCommit = { hash, message, author: newState.user, date: today(), tree }; + let insertions = 0; + let deletions = 0; + for (const c of recorded) { + const n = countChanges(repo.head[c.path] ?? '', repo.index[c.path] ?? ''); + insertions += n.insertions; + deletions += n.deletions; + } + const lines: OutputLine[] = [ + { text: `[${g.branch}${g.commits.length ? '' : ' (root-commit)'} ${hash}] ${message.split('\n')[0]}`, type: 'success' }, + ]; + if (recorded.length) lines.push({ text: statLine(recorded.length, insertions, deletions), type: 'output' }); + for (const c of recorded) { + if (c.kind === 'modified') continue; + const mode = g.modes?.[c.path] ?? fileMode(nodeAt(newState.root, [...repo.path, ...c.path.split('/')]), env); + lines.push({ text: ` ${c.kind === 'new file' ? 'create' : 'delete'} mode ${mode} ${c.path}`, type: 'output' }); + } + setGit({ ...g, commits: [commit, ...g.commits], head: tree }); + return { lines: quiet ? [] : lines, newState }; } // ── git log ─────────────────────────────────────────────────────────────── case 'log': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; if (g.commits.length === 0) { - return { lines: [{ text: 'fatal: your current branch has no commits yet.', type: 'error' }], newState }; + return { lines: [{ text: `fatal: your current branch '${g.branch}' does not have any commits yet`, type: 'error' }], newState, status: 128 }; } const oneline = args.includes('--oneline'); const lines: OutputLine[] = []; @@ -207,31 +724,80 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git diff ────────────────────────────────────────────────────────────── case 'diff': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const g = newState.git!; - if (g.stagedFiles.length === 0 && g.commits.length > 0) { - return { lines: [{ text: '(nothing to diff — working tree clean)', type: 'info' }], newState }; + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1), 'U'); + if (o.long.has('stat')) return notSimulated('git diff --stat', newState); + const repo = openRepo(newState); + if (o.positional.some((s) => repo.g.branches.includes(s))) return notSimulated('La comparaison entre branches (git diff branche1 branche2)', newState); + // Leading arguments that name commits are revisions; the others must be paths. + const trees: Tree[] = []; + const rels: string[] = []; + for (const arg of o.positional) { + const tree = !rels.length && trees.length < 2 ? commitTree(arg) : null; + if (tree) { trees.push(tree); continue; } + const rel = repoRel(arg); + if (!known(rel, repo.index, repo.head)) return { lines: AMBIGUOUS(arg), newState, status: 128 }; + rels.push(rel!); } - return { - lines: [ - { text: 'diff --git a/fichier.txt b/fichier.txt', type: 'output' }, - { text: '--- a/fichier.txt', type: 'output' }, - { text: '+++ b/fichier.txt', type: 'output' }, - { text: '@@ -1,3 +1,4 @@', type: 'info' }, - { text: ' Ligne existante', type: 'output' }, - { text: '+Nouvelle ligne ajoutée', type: 'success' }, - { text: '-Ligne supprimée', type: 'error' }, - { text: ' Autre ligne', type: 'output' }, - ], - newState, - }; + for (const arg of o.paths) { const rel = repoRel(arg); if (rel !== null) rels.push(rel); } + const staged = o.long.has('staged') || o.long.has('cached'); + /** The working tree as git compares it: the files it tracks in `these` trees. */ + const onDisk = (...these: Tree[]) => Object.fromEntries(Object.keys(Object.assign({}, ...these)) + .filter((p) => p in repo.work).map((p) => [p, repo.work[p]])); + const [from, to] = trees.length === 2 ? [trees[0], trees[1]] + : trees.length === 1 ? [trees[0], staged ? repo.index : onDisk(trees[0], repo.index)] + : staged ? [repo.head, repo.index] : [repo.index, onDisk(repo.index)]; + const paths = rels.length || o.paths.length ? rels : null; + const picked = changes(from, to).filter((c) => !paths || paths.some((p) => !p || c.path === p || c.path.startsWith(`${p}/`))); + const counts = (c: { path: string }) => countChanges(from[c.path] ?? '', to[c.path] ?? ''); + if (o.long.has('name-only')) return { lines: picked.map((c) => ({ text: c.path, type: 'output' as const })), newState }; + if (o.long.has('name-status')) { + const letter = { 'new file': 'A', modified: 'M', deleted: 'D' } as const; + return { lines: picked.map((c) => ({ text: `${letter[c.kind]}\t${c.path}`, type: 'output' as const })), newState }; + } + if (o.long.has('numstat')) { + return { lines: picked.map((c) => { const n = counts(c); return { text: `${n.insertions}\t${n.deletions}\t${c.path}`, type: 'output' as const }; }), newState }; + } + if (o.long.has('shortstat')) { + if (!picked.length) return { lines: [], newState }; + const total = picked.map(counts).reduce((t, n) => ({ insertions: t.insertions + n.insertions, deletions: t.deletions + n.deletions }), { insertions: 0, deletions: 0 }); + return { lines: [{ text: statLine(picked.length, total.insertions, total.deletions), type: 'output' }], newState }; + } + const context = Number(o.values.get('U') ?? text(o.long.get('unified')) ?? 3); + return { lines: diffLines(from, to, paths, newState, repo, env, Number.isFinite(context) ? context : 3), newState }; + } + + // ── git check-ignore ────────────────────────────────────────────────────── + case 'check-ignore': { + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1)); + const verbose = o.short.has('v') || o.long.has('verbose'); + const quiet = o.short.has('q') || o.long.has('quiet'); + const nonMatching = o.short.has('n') || o.long.has('non-matching'); + if (nonMatching && !verbose) return { lines: [{ text: 'fatal: --non-matching is only valid with --verbose', type: 'error' }], newState, status: 128 }; + const specs = [...o.positional, ...o.paths]; + if (!specs.length) return { lines: [{ text: 'fatal: no path specified', type: 'error' }], newState, status: 128 }; + const repo = openRepo(newState); + const lines: OutputLine[] = []; + let matched = false; + for (const arg of specs) { + const rel = repoRel(arg); + if (rel === null) continue; + const node = nodeAt(newState.root, [...repo.path, ...rel.split('/').filter(Boolean)]); + // -v names the deciding rule, a `!` rule included; without -v only ignored paths are listed. + // A tracked file is never ignored, whatever the rules say. + const rule = rel in repo.index ? null + : verbose ? decidingRule(repo.rules, rel, node?.type === 'directory') : ignoredBy(repo.rules, rel, node?.type === 'directory'); + if (rule && !rule.negate) matched = true; + if (rule) lines.push({ text: verbose ? `.gitignore:${rule.line}:${rule.pattern}\t${arg}` : arg, type: 'output' }); + else if (nonMatching) lines.push({ text: `::\t${arg}`, type: 'output' }); + } + return { lines: quiet ? [] : lines, newState, status: matched ? 0 : 1 }; } // ── git branch ──────────────────────────────────────────────────────────── case 'branch': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const deleteFlag = args.includes('-d') || args.includes('-D'); const branchName = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; @@ -252,14 +818,14 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal if (!g.branches.includes(branchName)) { return { lines: [{ text: `error: branch '${branchName}' not found.`, type: 'error' }], newState }; } - newState = { ...newState, git: { ...g, branches: g.branches.filter((b) => b !== branchName) } }; + setGit({ ...g, branches: g.branches.filter((b) => b !== branchName) }); return { lines: [{ text: `Deleted branch ${branchName}.`, type: 'success' }], newState }; } if (branchName) { if (g.branches.includes(branchName)) { return { lines: [{ text: `fatal: A branch named '${branchName}' already exists.`, type: 'error' }], newState }; } - newState = { ...newState, git: { ...g, branches: [...g.branches, branchName] } }; + setGit({ ...g, branches: [...g.branches, branchName] }); return { lines: [{ text: `Branch '${branchName}' created.`, type: 'success' }], newState }; } return { lines: [{ text: 'Usage: git branch [] [-d ]', type: 'error' }], newState }; @@ -267,8 +833,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git checkout ────────────────────────────────────────────────────────── case 'checkout': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const createFlag = args.includes('-b') || args.includes('-B'); const branchName = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; @@ -277,21 +842,20 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal if (g.branches.includes(branchName)) { return { lines: [{ text: `fatal: A branch named '${branchName}' already exists.`, type: 'error' }], newState }; } - newState = { ...newState, git: { ...g, branch: branchName, branches: [...g.branches, branchName] } }; + setGit({ ...g, branch: branchName, branches: [...g.branches, branchName] }); return { lines: [{ text: `Switched to a new branch '${branchName}'`, type: 'success' }], newState }; } if (!g.branches.includes(branchName)) { - return { lines: [{ text: `error: pathspec '${branchName}' did not match any branch known to git.`, type: 'error' }], newState }; + return { lines: [{ text: `error: pathspec '${branchName}' did not match any file(s) known to git`, type: 'error' }], newState, status: 1 }; } if (branchName === g.branch) return { lines: [{ text: `Already on '${branchName}'`, type: 'info' }], newState }; - newState = { ...newState, git: { ...g, branch: branchName } }; + setGit({ ...g, branch: branchName }); return { lines: [{ text: `Switched to branch '${branchName}'`, type: 'success' }], newState }; } // ── git switch ──────────────────────────────────────────────────────────── case 'switch': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const createFlag = args.includes('-c') || args.includes('-C'); const branchName = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; @@ -300,20 +864,19 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal if (g.branches.includes(branchName)) { return { lines: [{ text: `fatal: A branch named '${branchName}' already exists.`, type: 'error' }], newState }; } - newState = { ...newState, git: { ...g, branch: branchName, branches: [...g.branches, branchName] } }; + setGit({ ...g, branch: branchName, branches: [...g.branches, branchName] }); return { lines: [{ text: `Switched to a new branch '${branchName}'`, type: 'success' }], newState }; } if (!g.branches.includes(branchName)) { return { lines: [{ text: `fatal: invalid reference: ${branchName}`, type: 'error' }], newState }; } - newState = { ...newState, git: { ...g, branch: branchName } }; + setGit({ ...g, branch: branchName }); return { lines: [{ text: `Switched to branch '${branchName}'`, type: 'success' }], newState }; } // ── git merge ───────────────────────────────────────────────────────────── case 'merge': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const branchName = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; if (!branchName) return { lines: [{ text: 'Usage: git merge ', type: 'error' }], newState }; @@ -326,9 +889,10 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal hash, message: `Merge branch '${branchName}' into ${g.branch}`, author: newState.user, - date: new Date().toISOString().slice(0, 10), + date: today(), + tree: g.head, }; - newState = { ...newState, git: { ...g, commits: [mergeCommit, ...g.commits] } }; + setGit({ ...g, commits: [mergeCommit, ...g.commits] }); return { lines: [ { text: `Merge made by the 'ort' strategy.`, type: 'success' }, @@ -340,8 +904,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git remote ──────────────────────────────────────────────────────────── case 'remote': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const remoteSub = args[1]?.toLowerCase() ?? ''; if (!remoteSub || remoteSub === '-v' || remoteSub === '--verbose') { @@ -360,7 +923,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal const url = args[3] ?? ''; if (!name || !url) return { lines: [{ text: 'Usage: git remote add ', type: 'error' }], newState }; if (g.remotes[name]) return { lines: [{ text: `error: remote '${name}' already exists.`, type: 'error' }], newState }; - newState = { ...newState, git: { ...g, remotes: { ...g.remotes, [name]: url } } }; + setGit({ ...g, remotes: { ...g.remotes, [name]: url } }); return { lines: [{ text: `Remote '${name}' added (${url})`, type: 'success' }], newState }; } if (remoteSub === 'remove' || remoteSub === 'rm') { @@ -369,7 +932,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal if (!g.remotes[name]) return { lines: [{ text: `error: No such remote '${name}'`, type: 'error' }], newState }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [name]: _r, ...rest } = g.remotes; - newState = { ...newState, git: { ...g, remotes: rest } }; + setGit({ ...g, remotes: rest }); return { lines: [{ text: `Remote '${name}' removed.`, type: 'success' }], newState }; } return { lines: [{ text: 'Usage: git remote add|remove|[-v]', type: 'error' }], newState }; @@ -377,8 +940,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git push ────────────────────────────────────────────────────────────── case 'push': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const hasRemote = Object.keys(g.remotes).length > 0; const remoteName = args.find((a) => !a.startsWith('-') && a !== sub) ?? 'origin'; @@ -404,8 +966,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git pull ────────────────────────────────────────────────────────────── case 'pull': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; if (Object.keys(g.remotes).length === 0) { return { lines: [{ text: "There is no tracking information for the current branch.\nHint: git remote add origin ", type: 'error' }], newState }; @@ -423,8 +984,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git fetch ───────────────────────────────────────────────────────────── case 'fetch': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const remote = args.find((a) => !a.startsWith('-') && a !== sub) ?? 'origin'; if (!g.remotes[remote]) { @@ -442,28 +1002,48 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git clone ───────────────────────────────────────────────────────────── case 'clone': { - const url = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; + // Options that take a value (`-b develop`) are not the URL or the directory. + const positional: string[] = []; + let branch = 'main'; + for (let i = 1; i < args.length; i++) { + const a = args[i]; + if (a === '-b' || a === '--branch') branch = args[++i] ?? branch; + else if (a === '--depth' || a === '-o' || a === '--origin') i++; + else if (!a.startsWith('-')) positional.push(a); + } + const [url = '', dirArg] = positional; if (!url) return { lines: [{ text: 'Usage: git clone [directory]', type: 'error' }], newState }; - const dirName = args.find((a) => !a.startsWith('-') && a !== sub && a !== url) - ?? url.split('/').pop()?.replace(/\.git$/, '') ?? 'repo'; - newState = { - ...newState, - git: { - initialized: true, - branch: 'main', - branches: ['main'], - stagedFiles: [], - commits: [{ hash: makeHash(), message: 'Initial commit', author: 'remote', date: new Date().toISOString().slice(0, 10) }], - remotes: { origin: url }, - }, - }; + const dirName = dirArg ?? url.split(/[/:]/).pop()?.replace(/\.git$/, '') ?? 'repo'; + const target = resolve(dirName); + const existing = nodeAt(newState.root, target); + if (existing && (existing.type === 'file' || Object.keys(existing.children).length > 0)) { + return { lines: [{ text: `fatal: destination path '${dirName}' already exists and is not an empty directory.`, type: 'error' }], newState, status: 128 }; + } + // The simulated remote holds one commit with a README, placed in its own directory as git does. + const readme = `# ${dirName}\n`; + const tree: Tree = { 'README.md': readme }; + let root = placeDir(newState.root, target, { type: 'directory', children: {}, permissions: 'drwxr-xr-x', owner: 'user', group: 'user' }); + root = placeDir(root, [...target, '.git'], dotGit(env)); + root = writeFile(root, [...target, 'README.md'], readme); + setGit({ + initialized: true, + branch, + branches: [branch], + stagedFiles: [], + commits: [{ hash: makeHash(), message: 'Initial commit', author: 'remote', date: today(), tree }], + remotes: { origin: url }, + repoPath: target, + head: tree, + index: tree, + modes: { 'README.md': '100644' }, + }, root); return { lines: [ { text: `Cloning into '${dirName}'...`, type: 'output' }, - { text: 'remote: Enumerating objects: 12, done.', type: 'output' }, - { text: 'remote: Counting objects: 100% (12/12), done.', type: 'output' }, - { text: 'Receiving objects: 100% (12/12), done.', type: 'success' }, - { text: `Repository cloned into '${dirName}'`, type: 'success' }, + { text: 'remote: Enumerating objects: 3, done.', type: 'output' }, + { text: 'remote: Counting objects: 100% (3/3), done.', type: 'output' }, + { text: 'remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0', type: 'output' }, + { text: 'Receiving objects: 100% (3/3), done.', type: 'output' }, ], newState, }; @@ -471,28 +1051,93 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git stash ───────────────────────────────────────────────────────────── case 'stash': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const stashSub = args[1]?.toLowerCase() ?? 'push'; - if (stashSub === 'push' || stashSub === '') { - const g = newState.git!; - if (g.stagedFiles.length === 0) return { lines: [{ text: 'No local changes to save.', type: 'info' }], newState }; - newState = { ...newState, git: { ...g, stagedFiles: [] } }; - return { lines: [{ text: `Saved working directory and index state WIP on ${g.branch}: stash@{0}`, type: 'success' }], newState }; - } - if (stashSub === 'pop' || stashSub === 'apply') { - return { lines: [{ text: 'Applied stash@{0} (simulated)', type: 'success' }], newState }; - } - if (stashSub === 'list') { - return { lines: [{ text: 'stash@{0}: WIP on main (simulated)', type: 'output' }], newState }; - } - return { lines: [{ text: 'Usage: git stash [push|pop|apply|list]', type: 'error' }], newState }; + if (!inRepo()) return notARepo(); + const rest = args.slice(1); + const action = rest[0] && !rest[0].startsWith('-') ? rest.shift()!.toLowerCase() : 'push'; + const o = parseOptions(rest, 'm', '', ['message']); + const quiet = o.short.has('q') || o.long.has('quiet'); + const repo = openRepo(newState); + const g = repo.g; + const stash = g.stash ?? []; + /** `stash@{n}` (or `n`) → its position; the latest when absent. */ + const pick = (): number | CommandOutput => { + const ref = o.positional[0]; + if (!stash.length) return { lines: [{ text: 'No stash entries found.', type: 'error' }], newState, status: 128 }; + if (!ref) return 0; + const n = Number(ref.match(/^(?:stash@\{(\d+)\}|(\d+))$/)?.slice(1).find((v) => v !== undefined) ?? NaN); + if (Number.isNaN(n)) return { lines: [{ text: `error: ${ref} is not a valid reference`, type: 'error' }], newState, status: 128 }; + if (n >= stash.length) return { lines: [{ text: `fatal: log for 'stash' only has ${stash.length} entries`, type: 'error' }], newState, status: 128 }; + return n; + }; + if (action === 'push' || action === 'save') { + if (o.short.has('u') || o.long.has('include-untracked') || o.short.has('p') || o.long.has('patch')) { + return notSimulated('git stash -u / -p', newState); + } + if (!g.commits.length) return { lines: [{ text: 'You do not have the initial commit yet', type: 'error' }], newState, status: 1 }; + const paths = [...new Set([...Object.keys(repo.head), ...Object.keys(repo.index)])]; + const dirty = changes(repo.head, repo.index).length > 0 || paths.some((p) => repo.work[p] !== repo.index[p] && p in repo.index); + if (!dirty) return { lines: [{ text: 'No local changes to save', type: 'output' }], newState }; + const note = o.values.get('m') ?? text(o.long.get('message')) ?? (action === 'save' ? o.positional.join(' ') : ''); + const message = note ? `On ${g.branch}: ${note}` : `WIP on ${g.branch}: ${g.commits[0].hash} ${g.commits[0].message}`; + const work = Object.fromEntries(paths.map((p) => [p, p in repo.work ? repo.work[p] : null])); + const newFiles = Object.fromEntries(Object.keys(repo.index).filter((p) => !(p in repo.head)).map((p) => [p, repo.index[p]])); + // The working tree and the index go back to the last commit. + let root = newState.root; + for (const p of paths) { + const committed = p in repo.head ? repo.head[p] : null; + if ((p in repo.work ? repo.work[p] : null) !== committed) root = writeFile(root, [...repo.path, ...p.split('/')], committed); + } + setGit({ ...g, index: { ...repo.head }, stash: [{ message, work, newFiles, base: { ...repo.head } }, ...stash] }, root); + return { lines: quiet ? [] : [{ text: `Saved working directory and index state ${message}`, type: 'output' }], newState }; + } + if (action === 'pop' || action === 'apply') { + const at = pick(); + if (typeof at !== 'number') return at; + const entry = stash[at]; + const base = entry.base ?? repo.head; + // Only what the stash changed is applied, on top of the current files. + const touched = Object.keys(entry.work).filter((p) => entry.work[p] !== (p in base ? base[p] : null)); + const busy = touched.filter((p) => repo.work[p] !== repo.head[p] || repo.index[p] !== repo.head[p]).sort(); + if (busy.length) { + const lines: OutputLine[] = [ + { text: 'error: Your local changes to the following files would be overwritten by merge:', type: 'error' }, + ...busy.map((p) => ({ text: `\t${p}`, type: 'error' as const })), + { text: 'Please commit your changes or stash them before you merge.', type: 'error' }, + { text: 'Aborting', type: 'error' }, + ...statusLines(newState, repo), + ]; + if (action === 'pop') lines.push({ text: 'The stash entry is kept in case you need it again.', type: 'output' }); + return { lines, newState, status: 128 }; + } + let root = newState.root; + for (const p of touched) root = writeFile(root, [...repo.path, ...p.split('/')], entry.work[p]); + // Files that were new come back staged; other changes come back unstaged. + const kept = action === 'pop' ? stash.filter((_, i) => i !== at) : stash; + setGit({ ...g, index: { ...repo.index, ...entry.newFiles }, stash: kept }, root); + if (quiet) return { lines: [], newState }; + const lines = statusLines(newState, openRepo(newState)); + if (action === 'pop') lines.push({ text: `Dropped refs/stash@{${at}} (${makeHash(40)})`, type: 'output' }); + return { lines, newState }; + } + if (action === 'drop') { + const at = pick(); + if (typeof at !== 'number') return at; + setGit({ ...g, stash: stash.filter((_, i) => i !== at) }); + return { lines: quiet ? [] : [{ text: `Dropped refs/stash@{${at}} (${makeHash(40)})`, type: 'output' }], newState }; + } + if (action === 'clear') { + setGit({ ...g, stash: [] }); + return { lines: [], newState }; + } + if (action === 'list') { + return { lines: stash.map((s, i) => ({ text: `stash@{${i}}: ${s.message}`, type: 'output' as const })), newState }; + } + return notSimulated(`git stash ${action}`, newState); } // ── git tag ─────────────────────────────────────────────────────────────── case 'tag': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const tagName = args.find((a) => !a.startsWith('-') && a !== sub) ?? ''; if (!tagName) return { lines: [{ text: 'Usage: git tag ', type: 'error' }], newState }; const g = newState.git!; @@ -502,8 +1147,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git show ────────────────────────────────────────────────────────────── case 'show': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; if (g.commits.length === 0) return { lines: [{ text: 'fatal: No commits yet.', type: 'error' }], newState }; const c = g.commits[0]; @@ -521,13 +1165,73 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git reset ───────────────────────────────────────────────────────────── case 'reset': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; - const g = newState.git!; - const hard = args.includes('--hard'); - newState = { ...newState, git: { ...g, stagedFiles: [] } }; + if (!inRepo()) return notARepo(); + const o = parseOptions(args.slice(1)); + const unknown = [...o.short].find((c) => c !== 'q') ?? [...o.long.keys()].find((k) => !['soft', 'mixed', 'hard', 'keep', 'merge', 'quiet'].includes(k)); + if (unknown) { + const name = unknown.length === 1 ? `switch \`${unknown}'` : `option \`${unknown}'`; + return { lines: [`error: unknown ${name}`, ...RESET_USAGE].map((t) => ({ text: t, type: 'error' as const })), newState, status: 129 }; + } + const repo = openRepo(newState); + const g = repo.g; + const mode = o.long.has('hard') ? 'hard' : o.long.has('soft') ? 'soft' : 'mixed'; + const quiet = o.short.has('q') || o.long.has('quiet'); + // A first argument that names a commit moves HEAD; the others are paths. + let back = 0; + let rest = o.positional; + if (rest.length && commitTree(rest[0])) { + const ref = rest[0]; + const m = ref.match(/^(?:HEAD|@)(?:~(\d*)|(\^))?$/); + back = m ? (m[2] ? 1 : m[1] === undefined ? 0 : Number(m[1] || 1)) : g.commits.findIndex((c) => c.hash.startsWith(ref)); + rest = rest.slice(1); + } + for (const arg of rest) { + if (!known(repoRel(arg), repo.index, repo.head)) return { lines: AMBIGUOUS(arg), newState, status: 128 }; + } + const paths = [...rest, ...o.paths]; + if (paths.length && mode !== 'mixed') { + return { lines: [{ text: `fatal: Cannot do ${mode} reset with paths.`, type: 'error' }], newState, status: 128 }; + } + if (o.long.has('keep') || o.long.has('merge')) { + // Without a commit to move to, both keep every file as it is. + if (!back && !paths.length) return { lines: [], newState }; + return notSimulated('git reset --keep / --merge', newState); + } + // With paths, only the index changes: HEAD stays where it is. + const source = back ? g.commits[back]?.tree ?? repo.head : repo.head; + const commits = paths.length ? g.commits : g.commits.slice(back); + const head = paths.length ? repo.head : source; + let index: Tree; + if (paths.length) { + index = { ...repo.index }; + for (const arg of paths) { + const rel = repoRel(arg) ?? ''; + for (const p of new Set([...under(repo.index, rel), ...under(source, rel)])) { + if (p in source) index[p] = source[p]; + else delete index[p]; + } + } + } else index = mode === 'soft' ? repo.index : { ...head }; + let root = newState.root; + if (mode === 'hard') { + for (const p of new Set([...Object.keys(repo.index), ...Object.keys(head)])) { + const content = p in head ? head[p] : null; + if ((p in repo.work ? repo.work[p] : null) !== content) root = writeFile(root, [...repo.path, ...p.split('/')], content); + } + } + setGit({ ...g, commits, head, index }, root); + if (quiet || mode === 'soft') return { lines: [], newState }; + if (mode === 'hard') { + const top = commits[0]; + return { lines: top ? [{ text: `HEAD is now at ${top.hash} ${top.message}`, type: 'output' }] : [], newState }; + } + const left = unstaged(openRepo(newState)); + if (!left.length) return { lines: [], newState }; return { - lines: [{ text: hard ? 'HEAD is now at (hard reset simulated). Working tree clean.' : 'Unstaged all changes (simulated).', type: 'info' }], + lines: [ + { text: 'Unstaged changes after reset:', type: 'output' }, + ...left.map((c) => ({ text: `${c.kind === 'deleted' ? 'D' : 'M'}\t${c.path}`, type: 'output' as const })), + ], newState, }; } @@ -557,8 +1261,7 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal // ── git cherry-pick ───────────────────────────────────────────────────────── case 'cherry-pick': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; // No commits → no ref can ever be valid; give the contextual error first (like git log/tag). if (g.commits.length === 0) { @@ -588,18 +1291,17 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal let commits = g.commits; for (const source of picked) { const hash = makeHash(); - commits = [{ hash, message: source.message, author: newState.user, date: new Date().toISOString().slice(0, 10) }, ...commits]; + commits = [{ hash, message: source.message, author: newState.user, date: today(), tree: g.head }, ...commits]; lines.push({ text: `[${g.branch} ${hash}] ${source.message}`, type: 'success' }); lines.push({ text: ` (cherry-pické depuis ${source.hash} — le commit est ré-appliqué sur ${g.branch})`, type: 'info' }); } - newState = { ...newState, git: { ...g, commits } }; + setGit({ ...g, commits }); return { lines, newState }; } // ── git rebase ──────────────────────────────────────────────────────────── case 'rebase': { - const repoErr = requireRepo(); - if (repoErr) return { lines: [repoErr], newState }; + if (!inRepo()) return notARepo(); const g = newState.git!; const interactive = args.includes('-i') || args.includes('--interactive'); if (interactive) { @@ -653,10 +1355,13 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal { text: ' init Initialiser un nouveau dépôt', type: 'output' }, { text: ' clone Cloner un dépôt distant', type: 'output' }, { text: ' add Ajouter des fichiers à l\'index (staging)', type: 'output' }, + { text: ' rm Retirer des fichiers du suivi (et du disque)', type: 'output' }, + { text: ' restore Annuler une modification ou un git add', type: 'output' }, { text: ' commit Enregistrer les modifications indexées', type: 'output' }, { text: ' status Afficher l\'état du répertoire de travail', type: 'output' }, { text: ' log Afficher l\'historique des commits', type: 'output' }, { text: ' diff Comparer les modifications', type: 'output' }, + { text: ' check-ignore Dire quelle règle du .gitignore ignore un fichier', type: 'output' }, { text: ' branch Lister, créer ou supprimer des branches', type: 'output' }, { text: ' checkout Changer de branche ou restaurer des fichiers', type: 'output' }, { text: ' switch Changer de branche (commande moderne)', type: 'output' }, @@ -678,8 +1383,9 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal default: return { - lines: [{ text: `git: '${sub}' is not a git command. See 'git help'.`, type: 'error' }], + lines: [{ text: `git: '${sub}' is not a git command. See 'git --help'.`, type: 'error' }], newState, + status: 1, }; } } diff --git a/src/app/data/commands/gitTree.ts b/src/app/data/commands/gitTree.ts new file mode 100644 index 0000000..40cbf65 --- /dev/null +++ b/src/app/data/commands/gitTree.ts @@ -0,0 +1,352 @@ +/** + * The file side of the simulated git: reading the working tree, `.gitignore` + * rules, line diffs and blob ids. Everything here mirrors what git itself + * computes, so `git status`, `git diff` and `git commit` show what a real + * repository would. + */ +import type { DirectoryNode, FSNode, TerminalEnv } from './types'; + +/** Files of a tree: path relative to the repository (`docs/n.txt`) → content. */ +export type Tree = Record; + +export function nodeAt(root: DirectoryNode, path: string[]): FSNode | null { + let current: FSNode = root; + for (const seg of path) { + if (current.type !== 'directory') return null; + const child: FSNode | undefined = current.children[seg]; + if (!child) return null; + current = child; + } + return current; +} + +/** + * The simulator stores text without its final newline (see `textCounts` in + * terminalEngine): the bytes git sees on disk end with one, empty files aside. + */ +const onDisk = (text: string) => (text ? `${text}\n` : ''); +const stored = (disk: string) => (disk.endsWith('\n') ? disk.slice(0, -1) : disk); + +/** Every file under `dir` as git reads it, keyed by its path below it. Git never looks inside `.git`. */ +export function readTree(root: DirectoryNode, dir: string[]): Tree { + const tree: Tree = {}; + const walk = (node: FSNode, prefix: string) => { + if (node.type === 'file') { + tree[prefix] = onDisk(node.content); + return; + } + for (const [name, child] of Object.entries(node.children)) { + if (name === '.git') continue; + walk(child, prefix ? `${prefix}/${name}` : name); + } + }; + const start = nodeAt(root, dir); + if (start?.type === 'directory') walk(start, ''); + return tree; +} + +/** + * A copy of `root` where the file at `path` holds `content` (bytes as git + * sees them), or is removed when `content` is null. Missing parent + * directories are created, as git does when it restores a file. + */ +export function writeFile(root: DirectoryNode, path: string[], disk: string | null): DirectoryNode { + return place(root, path, disk === null ? null : stored(disk)); +} + +function place(root: DirectoryNode, path: string[], content: string | null): DirectoryNode { + const [head, ...rest] = path; + const existing = root.children[head]; + if (rest.length === 0) { + const children = { ...root.children }; + if (content === null) delete children[head]; + else { + const permissions = existing?.type === 'file' ? existing.permissions : '-rw-r--r--'; + children[head] = { type: 'file', content, permissions, owner: 'user', group: 'user', size: content.length }; + } + return { ...root, children }; + } + const dir: DirectoryNode = existing?.type === 'directory' + ? existing + : { type: 'directory', children: {}, permissions: 'drwxr-xr-x', owner: 'user', group: 'user' }; + if (content === null && existing?.type !== 'directory') return root; + const child = place(dir, rest, content); + // Git removes the directories a deletion leaves empty. The repository root + // always keeps `.git/`, so this never climbs out of the repository. + if (content === null && Object.keys(child.children).length === 0) { + const children = { ...root.children }; + delete children[head]; + return { ...root, children }; + } + return { ...root, children: { ...root.children, [head]: child } }; +} + +/** Git's file mode: executable files are 100755, except on Windows where git ignores the bit. */ +export function fileMode(node: FSNode | null, env: TerminalEnv): string { + return env !== 'windows' && node?.type === 'file' && node.permissions[3] === 'x' ? '100755' : '100644'; +} + +// ─── .gitignore ────────────────────────────────────────────────────────────── + +export interface IgnoreRule { + /** The pattern as written, for `git check-ignore -v`. */ + pattern: string; + line: number; + negate: boolean; + dirOnly: boolean; + /** A pattern with a slash matches from the repository root, otherwise any name. */ + anchored: boolean; + regex: RegExp; +} + +function globToRegex(glob: string): RegExp { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*' && glob[i + 1] === '*') { + const slashAfter = glob[i + 2] === '/'; + re += slashAfter ? '(?:.*/)?' : '.*'; + i += slashAfter ? 2 : 1; + } else if (c === '*') re += '[^/]*'; + else if (c === '?') re += '[^/]'; + else if (c === '[') { + const end = glob.indexOf(']', i + 1); + if (end < 0) re += '\\['; + else { + re += `[${glob.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`; + i = end; + } + } else re += c.replace(/[.+^${}()|\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +/** The rules of a `.gitignore` file, in order. Comments and blank lines are skipped. */ +export function parseIgnore(text: string): IgnoreRule[] { + const rules: IgnoreRule[] = []; + text.split('\n').forEach((raw, i) => { + const pattern = raw.replace(/\r$/, '').replace(/(?= 0; i--) { + const r = rules[i]; + if (r.dirOnly && !isDir) continue; + if (r.regex.test(r.anchored ? path : name)) return r; + } + return null; +} + +/** + * The rule that makes git ignore `path`, or null. The last matching pattern + * wins, and a file inside an ignored directory stays ignored: git never looks + * inside that directory, so a later `!` pattern cannot bring the file back. + */ +export function ignoredBy(rules: IgnoreRule[], path: string, isDir: boolean): IgnoreRule | null { + const rule = decidingRule(rules, path, isDir); + return rule && !rule.negate ? rule : null; +} + +/** + * The rule that decides `path`, a `!` pattern included: `git check-ignore -v` + * names it even when it brings the file back. + */ +export function decidingRule(rules: IgnoreRule[], path: string, isDir: boolean): IgnoreRule | null { + if (!rules.length) return null; + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + const rule = lastMatch(rules, parts.slice(0, i).join('/'), true); + if (rule && !rule.negate) return rule; + } + return lastMatch(rules, path, isDir); +} + +// ─── Line diff ─────────────────────────────────────────────────────────────── + +/** + * Lines with their end of line kept, so that a last line without one + * (`l3`) differs from the same text followed by a newline (`l3\n`), as in git. + */ +export function splitLines(content: string): string[] { + if (!content) return []; + const lines = content.split('\n').map((l) => `${l}\n`); + if (content.endsWith('\n')) lines.pop(); + else lines[lines.length - 1] = lines[lines.length - 1].slice(0, -1); + return lines; +} + +export interface DiffOp { + kind: ' ' | '-' | '+'; + line: string; + /** Index of the line in the old file (for ' ' and '-') or the new one (for '+'). */ + index: number; +} + +/** + * Myers' shortest edit script, the default algorithm of git. Within a run of + * changes, deletions come before insertions, as git prints them. + */ +export function diffLines(a: string[], b: string[]): DiffOp[] { + const n = a.length; + const m = b.length; + const max = n + m; + const off = max + 1; + const v = new Array(2 * max + 3).fill(0); + const trace: number[][] = []; + let found = false; + for (let d = 0; d <= max && !found; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1; + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { x++; y++; } + v[off + k] = x; + if (x >= n && y >= m) { found = true; break; } + } + } + // Walk the trace back from the end to recover the edits. + const ops: DiffOp[] = []; + let x = n; + let y = m; + for (let d = trace.length - 1; d >= 0; d--) { + const vd = trace[d]; + const k = x - y; + const down = k === -d || (k !== d && vd[off + k - 1] < vd[off + k + 1]); + const prevK = down ? k + 1 : k - 1; + const prevX = d === 0 ? 0 : vd[off + prevK]; + const prevY = prevX - prevK; + while (x > prevX && y > prevY) { x--; y--; ops.push({ kind: ' ', line: a[x], index: x }); } + if (d > 0) { + if (down) { y--; ops.push({ kind: '+', line: b[y], index: y }); } + else { x--; ops.push({ kind: '-', line: a[x], index: x }); } + } + } + ops.reverse(); + // Deletions first within each run of changes. + const out: DiffOp[] = []; + let run: DiffOp[] = []; + const flush = () => { + out.push(...run.filter((o) => o.kind === '-'), ...run.filter((o) => o.kind === '+')); + run = []; + }; + for (const op of ops) { + if (op.kind === ' ') { flush(); out.push(op); } else run.push(op); + } + flush(); + return out; +} + +export function countChanges(a: string, b: string): { insertions: number; deletions: number } { + const ops = diffLines(splitLines(a), splitLines(b)); + return { + insertions: ops.filter((o) => o.kind === '+').length, + deletions: ops.filter((o) => o.kind === '-').length, + }; +} + + + +function range(start: number, count: number): string { + // An empty range names the line before it: `-0,0` for a new file. + const first = count === 0 ? start : start + 1; + return count === 1 ? `${first}` : `${first},${count}`; +} + +/** + * The hunks of a unified diff with git's defaults: `context` lines around each change (3, or `-U`), + * hunks closer than that merged, and the nearest line above each hunk that + * starts with a letter, `_` or `$` shown after `@@` as its "function" context. + */ +export function unifiedHunks(oldText: string, newText: string, context = 3): string[] { + const a = splitLines(oldText); + const b = splitLines(newText); + const ops = diffLines(a, b); + const changed = ops.map((o, i) => (o.kind === ' ' ? -1 : i)).filter((i) => i >= 0); + if (!changed.length) return []; + + // Group changes whose context would touch or overlap. + const groups: Array<[number, number]> = []; + for (const i of changed) { + const last = groups[groups.length - 1]; + if (last && i - last[1] <= 2 * context + 1) last[1] = i; + else groups.push([i, i]); + } + + const lines: string[] = []; + for (const [first, lastChange] of groups) { + const from = Math.max(0, first - context); + const to = Math.min(ops.length - 1, lastChange + context); + const slice = ops.slice(from, to + 1); + // Position of the hunk in each file: lines of that file before `from`. + const before = ops.slice(0, from); + const oldStart = before.filter((o) => o.kind !== '+').length; + const newStart = before.filter((o) => o.kind !== '-').length; + const oldCount = slice.filter((o) => o.kind !== '+').length; + const newCount = slice.filter((o) => o.kind !== '-').length; + let heading = ''; + for (let i = oldStart - 1; i >= 0; i--) { + if (/^[A-Za-z_$]/.test(a[i])) { heading = ` ${a[i].replace(/\n$/, '').slice(0, 80).trimEnd()}`; break; } + } + lines.push(`@@ -${range(oldStart, oldCount)} +${range(newStart, newCount)} @@${heading}`); + for (const op of slice) { + lines.push(`${op.kind}${op.line.replace(/\n$/, '')}`); + if (!op.line.endsWith('\n')) lines.push('\\ No newline at end of file'); + } + } + return lines; +} + +// ─── Blob ids ──────────────────────────────────────────────────────────────── + +/** SHA-1 (FIPS 180-4). Only names file contents in `git diff`, never protects anything. */ +export function sha1(data: Uint8Array): string { + const h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + const bitLength = data.length * 8; + const msg = [...data, 0x80]; + while (msg.length % 64 !== 56) msg.push(0); + const hi = Math.floor(bitLength / 2 ** 32); + const lo = bitLength >>> 0; + msg.push(hi >>> 24, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, lo >>> 24, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255); + const rotl = (x: number, n: number) => (x << n) | (x >>> (32 - n)); + const w = new Array(80); + for (let off = 0; off < msg.length; off += 64) { + for (let i = 0; i < 16; i++) { + const j = off + i * 4; + w[i] = (msg[j] << 24) | (msg[j + 1] << 16) | (msg[j + 2] << 8) | msg[j + 3]; + } + for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + let [a, b, c, d, e] = h; + for (let i = 0; i < 80; i++) { + const [f, k] = i < 20 ? [(b & c) | (~b & d), 0x5a827999] + : i < 40 ? [b ^ c ^ d, 0x6ed9eba1] + : i < 60 ? [(b & c) | (b & d) | (c & d), 0x8f1bbcdc] + : [b ^ c ^ d, 0xca62c1d6]; + const t = (rotl(a, 5) + f + e + k + w[i]) | 0; + e = d; d = c; c = rotl(b, 30); b = a; a = t; + } + [a, b, c, d, e].forEach((val, i) => { h[i] = (h[i] + val) | 0; }); + } + return h.map((val) => (val >>> 0).toString(16).padStart(8, '0')).join(''); +} + +/** The object id git gives a file's content: SHA-1 of `blob \0`. */ +export function blobId(content: string): string { + const body = new TextEncoder().encode(content); + const header = new TextEncoder().encode(`blob ${body.length}\0`); + const all = new Uint8Array(header.length + body.length); + all.set(header); + all.set(body, header.length); + return sha1(all); +} diff --git a/src/app/data/commands/types.ts b/src/app/data/commands/types.ts index 0b6bf4c..824ddd6 100644 --- a/src/app/data/commands/types.ts +++ b/src/app/data/commands/types.ts @@ -27,6 +27,17 @@ export interface GitCommit { message: string; author: string; date: string; + /** Files recorded by the commit (path relative to the repository → content). */ + tree?: Record; +} + +/** A `git stash` entry: tracked files as they were (null = deleted) and the files that were newly staged. */ +export interface GitStashEntry { + message: string; + work: Record; + newFiles: Record; + /** Files of HEAD when the entry was made: `pop` applies only what differs from them. */ + base?: Record; } /** @@ -38,9 +49,19 @@ export interface GitState { initialized: boolean; branch: string; branches: string[]; + /** Paths a commit would record: where the index differs from HEAD. Kept in sync with `index`. */ stagedFiles: string[]; commits: GitCommit[]; remotes: Record; + /** Absolute path of the working tree, where `git init` ran. */ + repoPath?: string[]; + /** Files of the last commit (path relative to the repository → content). */ + head?: Record; + /** The index (staging area): what the next commit will record. */ + index?: Record; + /** Git file mode of each path ever staged (`100755` for an executable), kept for its deletion. */ + modes?: Record; + stash?: GitStashEntry[]; } export interface TerminalState { @@ -73,5 +94,6 @@ export interface CommandOutput { export interface OutputLine { text: string; - type: 'output' | 'error' | 'success' | 'info'; + /** `removed` is standard output that git shows in red (a deleted line in a diff, a change not staged yet). */ + type: 'output' | 'error' | 'success' | 'info' | 'removed'; } diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 6f893d1..1331b62 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -14,7 +14,7 @@ import { validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow, } from './validators'; import { - gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, powershellProfile, sshDirectory, + gitRepoEmpty, gitRepoWithChange, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, powershellProfile, sshDirectory, type LessonSetup, } from './lessonSetup'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; @@ -2293,12 +2293,12 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Voir l\'état actuel\n$ git status\nOn branch main\nUntracked files:\n (use "git add " to include)\n\tfichier.txt\n\n# Ajouter un fichier spécifique\n$ git add fichier.txt\n\n# Ajouter tous les fichiers du répertoire courant\n$ git add .\n\n# Ajouter interactivement (sélection fine)\n$ git add -p', + content: '# Voir l\'état actuel\n$ git status\nOn branch main\n\nNo commits yet\n\nUntracked files:\n (use "git add ..." to include in what will be committed)\n\t.gitignore\n\tREADME.md\n\tindex.html\n\tscript.sh\n\nnothing added to commit but untracked files present (use "git add" to track)\n\n# Ajouter un fichier spécifique (git add n\'affiche rien quand tout va bien)\n$ git add README.md\n\n# Ajouter tous les fichiers du répertoire courant\n$ git add .\n\n# Ajouter interactivement (sélection fine)\n$ git add -p', label: 'Staging (Linux/macOS/Windows)', }, { type: 'code', - content: '# Créer un commit avec message en ligne\n$ git commit -m "feat: ajouter la page d\'accueil"\n[main a3f8c12] feat: ajouter la page d\'accueil\n 1 file changed, 25 insertions(+)\n\n# Commit avec titre + description\n$ git commit -m "feat: authentification utilisateur" -m "Ajoute login/logout avec JWT et refresh token automatique"', + content: '# Préparer tous les fichiers, puis créer le premier commit\n$ git add .\n$ git commit -m "feat: ajouter la page d\'accueil"\n[main (root-commit) a3f8c12] feat: ajouter la page d\'accueil\n 4 files changed, 20 insertions(+)\n create mode 100644 .gitignore\n create mode 100644 README.md\n create mode 100644 index.html\n create mode 100755 script.sh\n\n# Commit avec titre + description\n$ git commit -m "feat: authentification utilisateur" -m "Ajoute login/logout avec JWT et refresh token automatique"', label: 'Créer un commit', }, { @@ -2335,7 +2335,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# État du répertoire de travail\n$ git status\nOn branch main\nChanges to be committed:\n (use "git restore --staged" to unstage)\n\tnew file: index.html\n\nChanges not staged for commit:\n modified: style.css\n\nUntracked files:\n script.js', + content: '# Modifier un fichier suivi, puis en créer un nouveau\n$ echo "

Bienvenue

" >> index.html\n$ touch style.css\n\n# État du répertoire de travail\n$ git status\nOn branch main\nChanges not staged for commit:\n (use "git add ..." to update what will be committed)\n (use "git restore ..." to discard changes in working directory)\n\tmodified: index.html\n\nUntracked files:\n (use "git add ..." to include in what will be committed)\n\tstyle.css\n\nno changes added to commit (use "git add" and/or "git commit -a")\n\n# Version courte : une ligne par fichier (M modifié, ?? pas suivi)\n$ git status -s\n M index.html\n?? style.css', label: 'git status', }, { @@ -2373,7 +2373,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Voir les modifications non-stagées\n$ git diff\ndiff --git a/style.css b/style.css\n--- a/style.css\n+++ b/style.css\n@@ -1,3 +1,4 @@\n body {\n+ font-family: sans-serif;\n margin: 0;\n }\n\n# Voir les modifications stagées (avant commit)\n$ git diff --staged\n\n# Comparer deux branches\n$ git diff main feature/login', + content: '# Voir les modifications non-stagées\n$ git diff\ndiff --git a/README.md b/README.md\nindex 7d825c5..6827029 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,6 +1,6 @@\n # Mes Projets\n \n-Bienvenue dans mon répertoire de projets.\n+Bienvenue dans mon répertoire de projets Git.\n \n ## Projets actuels\n - script.sh : Script de démonstration\n\n# Voir les modifications stagées (avant commit)\n$ git diff --staged\n\n# Comparer deux branches\n$ git diff main feature/login', label: 'git diff', }, { @@ -2383,7 +2383,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Vérifier si un fichier est ignoré\n$ git check-ignore -v .env\n.gitignore:4:.env\t.env\n\n# Forcer l\'ajout d\'un fichier ignoré (rarement conseillé)\n$ git add --force fichier.log\n\n# Utiliser github.com/github/gitignore pour des templates\n# Ex. gitignore pour Node, Python, Rust, etc.', + content: '# Vérifier si un fichier est ignoré, et par quelle ligne du .gitignore\n$ git check-ignore -v .env\n.gitignore:2:.env\t.env\n\n# Git refuse d\'ajouter un fichier ignoré\n$ git add .env\nThe following paths are ignored by one of your .gitignore files:\n.env\nhint: Use -f if you really want to add them.\nhint: Disable this message with "git config set advice.addIgnoredFile false"\n\n# git add --force passerait outre : à éviter pour un fichier de secrets\n\n# Utiliser github.com/github/gitignore pour des templates\n# Ex. gitignore pour Node, Python, Rust, etc.', label: 'Vérification et debug', }, { @@ -2399,7 +2399,7 @@ export const curriculum: Module[] = [ instruction: 'Visualisez les différences actuelles dans votre dépôt avec `git diff`.', hint: 'Tapez: git diff', validate: validateGitDiffGitignore, - setup: gitRepoWithCommit, + setup: gitRepoWithChange, successMessage: 'Vous savez lire un diff Git. Les lignes en vert (+) sont les ajouts, en rouge (-) les suppressions.', }, }, @@ -2594,7 +2594,7 @@ 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\'...\nremote: Enumerating objects: 1247, done.\nReceiving objects: 100% (1247/1247), done.\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\n$ git clone -b develop https://github.com/org/projet.git\n\n# Cloner en SSH (recommandé)\n$ git clone git@github.com:org/projet.git', + content: '# Cloner un dépôt public\n$ git clone https://github.com/org/projet.git\nCloning into \'projet\'...\nremote: Enumerating objects: 3, done.\nremote: Counting objects: 100% (3/3), done.\nremote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0\nReceiving objects: 100% (3/3), done.\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)', }, { @@ -2604,7 +2604,7 @@ export const curriculum: Module[] = [ }, { 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\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\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', }, { diff --git a/src/app/data/lessonSetup.ts b/src/app/data/lessonSetup.ts index dd5f87d..233025e 100644 --- a/src/app/data/lessonSetup.ts +++ b/src/app/data/lessonSetup.ts @@ -13,11 +13,13 @@ * A setup is a pure function of the initial state, plus a one-line note shown * in the terminal welcome message so the learner knows what is already there. */ -import type { DirectoryNode, FSNode, GitCommit, GitState, TerminalState } from './commands/types'; +import type { DirectoryNode, FSNode, GitCommit, GitState, TerminalEnv, TerminalState } from './commands/types'; +import { dotGit } from './commands/git'; +import { fileMode, nodeAt, readTree, type Tree } from './commands/gitTree'; export interface LessonSetup { /** Pure: returns a new state, never mutates the one it receives. */ - apply: (state: TerminalState) => TerminalState; + apply: (state: TerminalState, env?: TerminalEnv) => TerminalState; /** Shown in the welcome message, e.g. "Dépôt Git prêt dans ~/projets". Empty = no line. */ note: string; /** Per-environment override of `note`, like `instructionByEnv` on an exercise. */ @@ -35,17 +37,44 @@ const INITIAL_COMMIT: GitCommit = { const PROJECT_DIR = ['home', 'user', 'projets']; -function withGit(state: TerminalState, git: Partial): TerminalState { +/** Keeps `.env` (the fake secrets of ~/projets) out of every prepared repository. */ +const GITIGNORE = '# Secrets : jamais dans Git\n.env'; + +/** The page the merge and conflict lessons talk about (`Mon App`). */ +const INDEX_HTML = '\n\n\n Mon App\n\n\n

Mon App

\n\n'; + +/** + * ~/projets as a repository: `.git/` and a `.gitignore`, and, when `committed`, + * a first commit that holds every file (so `git status` starts clean). + */ +function withGit(state: TerminalState, env: TerminalEnv, git: Partial, committed: boolean): TerminalState { + let root = withNode(state.root, [...PROJECT_DIR, '.gitignore'], file(GITIGNORE, '-rw-r--r--')); + root = withNode(root, [...PROJECT_DIR, 'index.html'], file(INDEX_HTML, '-rw-r--r--')); + root = withNode(root, [...PROJECT_DIR, '.git'], dotGit(env)); + const files = readTree(root, PROJECT_DIR); + const head: Tree = {}; + if (committed) { + for (const [path, content] of Object.entries(files)) if (path !== '.env') head[path] = content; + } + // Modes read now: a lesson may delete script.sh before its first git command. + const modes = Object.fromEntries(Object.keys(files) + .map((path) => [path, fileMode(nodeAt(root, [...PROJECT_DIR, ...path.split('/')]), env)])); + const commits = committed ? [{ ...INITIAL_COMMIT, tree: head }] : []; return { ...state, + root, cwd: PROJECT_DIR, git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: [], - commits: [], + commits, remotes: {}, + repoPath: PROJECT_DIR, + head, + index: { ...head }, + modes, ...git, }, }; @@ -66,31 +95,39 @@ function file(content: string, permissions: string): FSNode { /** A fresh repository: nothing committed yet, files ready to be staged. */ export const gitRepoEmpty: LessonSetup = { - apply: (s) => withGit(s, {}), - note: 'Dépôt Git prêt dans ~/projets (initialisé, aucun commit).', + apply: (s, env = 'linux') => withGit(s, env, {}, false), + note: 'Dépôt Git prêt dans ~/projets (initialisé, aucun commit ; un .gitignore protège déjà .env).', }; /** A repository with one commit on `main`. */ export const gitRepoWithCommit: LessonSetup = { - apply: (s) => withGit(s, { commits: [INITIAL_COMMIT] }), + apply: (s, env = 'linux') => withGit(s, env, {}, true), note: 'Dépôt Git prêt dans ~/projets (branche main, 1 commit).', }; +/** One commit, then a line of README.md changed and not staged: `git diff` has something to show. */ +export const gitRepoWithChange: LessonSetup = { + apply: (s, env = 'linux') => { + const state = withGit(s, env, {}, true); + const node = nodeAt(state.root, [...PROJECT_DIR, 'README.md']); + const readme = node?.type === 'file' ? node.content : ''; + const edited = readme.replace('Bienvenue dans mon répertoire de projets.', 'Bienvenue dans mon répertoire de projets Git.'); + return { ...state, root: withNode(state.root, [...PROJECT_DIR, 'README.md'], file(edited, '-rw-r--r--')) }; + }, + note: 'Dépôt Git prêt dans ~/projets (1 commit, puis une ligne de README.md modifiée).', +}; + /** A repository whose `main` has one commit and an extra branch to merge. */ export function gitRepoWithBranch(branch: string): LessonSetup { return { - apply: (s) => withGit(s, { commits: [INITIAL_COMMIT], branches: ['main', branch] }), + apply: (s, env = 'linux') => withGit(s, env, { branches: ['main', branch] }, true), note: `Dépôt Git prêt dans ~/projets (vous êtes sur main, la branche ${branch} existe).`, }; } /** A repository with one commit and an `origin` remote. */ export const gitRepoWithRemote: LessonSetup = { - apply: (s) => - withGit(s, { - commits: [INITIAL_COMMIT], - remotes: { origin: 'https://github.com/user/mon-projet.git' }, - }), + apply: (s, env = 'linux') => withGit(s, env, { remotes: { origin: 'https://github.com/user/mon-projet.git' } }, true), note: 'Dépôt Git prêt dans ~/projets (1 commit, remote origin configuré).', }; diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 6097be2..083b960 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -2552,15 +2552,23 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com // ── Git (Modules 9 & 10) → commands/git.ts ─────────────────────────────── case 'git': { // `git init ` creates the directory and initialises the repository inside it. - const initDir = args[0]?.toLowerCase() === 'init' ? args.slice(1).find((a) => !a.startsWith('-')) : undefined; + // The value of `-b main` or `--template dir` is not the directory. + let initAt = -1; + if (args[0]?.toLowerCase() === 'init') { + for (let i = 1; i < args.length && initAt < 0; i++) { + if (['-b', '--initial-branch', '--template', '--separate-git-dir', '--object-format'].includes(args[i])) i++; + else if (!args[i].startsWith('-')) initAt = i; + } + } + const initDir = initAt > 0 ? args[initAt] : undefined; if (initDir) { const made = cmdMkdir(newState, ['-p', initDir]); if (!made.newRoot) return { lines: made.lines, newState }; const inside = { ...newState, root: made.newRoot, cwd: resolvePath(newState, initDir) }; - const r = handleGit(inside, ['init'], env); + const r = handleGit(inside, args.filter((_, i) => i !== initAt), env); return { ...r, newState: { ...r.newState, cwd: newState.cwd } }; } - return handleGit(newState, args, env); + return handleGit(newState, args, env, (p) => resolvePath(newState, p)); } // ── IA (Module 11) → commands/ai.ts ────────────────────────────────────── diff --git a/src/test/commandReferenceReplay.ts b/src/test/commandReferenceReplay.ts index 92fce0d..ccc7686 100644 --- a/src/test/commandReferenceReplay.ts +++ b/src/test/commandReferenceReplay.ts @@ -6,7 +6,7 @@ */ import { commandCatalogue } from '../app/data/commandCatalogue'; import { createInitialState, processCommand } from '../app/data/terminalEngine'; -import { gitRepoWithRemote } from '../app/data/lessonSetup'; +import { gitRepoWithChange } from '../app/data/lessonSetup'; import type { TerminalState } from '../app/data/commands/types'; import type { EnvironmentId } from '../app/types/curriculum'; @@ -29,10 +29,11 @@ const HISTORY = [ function startState(categoryId: string): TerminalState { const base = createInitialState(); if (!GIT_CATEGORIES.has(categoryId)) return base; - const repo = gitRepoWithRemote.apply(base); - if (!repo.git) throw new Error('gitRepoWithRemote must create a repository'); + const repo = gitRepoWithChange.apply(base); + if (!repo.git) throw new Error('gitRepoWithChange must create a repository'); const commits = [...HISTORY.map((c) => ({ ...c, author: 'user', date: '2026-01-20' })), ...repo.git.commits]; - return { ...repo, git: { ...repo.git, commits, branches: [...repo.git.branches, 'feature/login'] } }; + const remotes = { origin: 'https://github.com/user/mon-projet.git' }; + return { ...repo, git: { ...repo.git, commits, remotes, branches: [...repo.git.branches, 'feature/login'] } }; } export interface ReplayedExample { diff --git a/src/test/gitFidelity.test.ts b/src/test/gitFidelity.test.ts new file mode 100644 index 0000000..54e58cf --- /dev/null +++ b/src/test/gitFidelity.test.ts @@ -0,0 +1,493 @@ +/** + * The simulated git against real git. Every expected value below was printed + * by Git 2.56 (Git for Windows, `core.autocrlf false`) on a copy of the + * lesson's ~/projets, on 30 September 2026, with the files ending in a + * newline as the simulator assumes. Tabs are real tabs. + */ +import { describe, it, expect } from 'vitest'; +import { createInitialState, processCommand } from '../app/data/terminalEngine'; +import type { TerminalEnv, TerminalState } from '../app/data/commands/types'; +import { gitRepoEmpty, gitRepoWithChange, gitRepoWithCommit } from '../app/data/lessonSetup'; +import { blobId, diffLines, ignoredBy, parseIgnore, splitLines, unifiedHunks } from '../app/data/commands/gitTree'; + +function run(state: TerminalState, commands: string[], env: TerminalEnv = 'linux') { + let s = state; + let last = processCommand(s, 'true', env); + for (const c of commands) { + last = processCommand(s, c, env); + s = last.newState; + } + return last; +} +const texts = (r: { lines: { text: string }[] }) => r.lines.map((l) => l.text); + +describe('git status, add and commit on the lesson repository', () => { + it('lists untracked files, the .gitignore hiding .env', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git status']); + expect(texts(r)).toEqual([ + 'On branch main', '', 'No commits yet', '', + 'Untracked files:', + ' (use "git add ..." to include in what will be committed)', + '\t.gitignore', '\tREADME.md', '\tindex.html', '\tscript.sh', '', + 'nothing added to commit but untracked files present (use "git add" to track)', + ]); + }); + + it('git add . prints nothing, then status lists the new files', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git add .', 'git status']); + expect(texts(r)).toEqual([ + 'On branch main', '', 'No commits yet', '', + 'Changes to be committed:', + ' (use "git rm --cached ..." to unstage)', + '\tnew file: .gitignore', '\tnew file: README.md', '\tnew file: index.html', '\tnew file: script.sh', '', + ]); + expect(run(gitRepoEmpty.apply(createInitialState()), ['git add .']).lines).toEqual([]); + }); + + it('the first commit is a root commit with its stats and file modes', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git add .', 'git commit -m "feat: premier commit"']); + expect(texts(r)[0]).toMatch(/^\[main \(root-commit\) [0-9a-f]{7}\] feat: premier commit$/); + expect(texts(r).slice(1)).toEqual([ + ' 4 files changed, 20 insertions(+)', + ' create mode 100644 .gitignore', + ' create mode 100644 README.md', + ' create mode 100644 index.html', + ' create mode 100755 script.sh', + ]); + }); + + it('Git for Windows ignores the executable bit', () => { + const r = run(gitRepoEmpty.apply(createInitialState(), 'windows'), ['git add .', 'git commit -m "x"'], 'windows'); + expect(texts(r)).toContain(' create mode 100644 script.sh'); + }); + + it('a prepared commit leaves a clean tree', () => { + expect(texts(run(gitRepoWithCommit.apply(createInitialState()), ['git status']))).toEqual([ + 'On branch main', 'nothing to commit, working tree clean', + ]); + }); + + it('a modified file is listed as not staged', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git status']); + expect(texts(r)).toEqual([ + 'On branch main', + 'Changes not staged for commit:', + ' (use "git add ..." to update what will be committed)', + ' (use "git restore ..." to discard changes in working directory)', + '\tmodified: README.md', '', + 'no changes added to commit (use "git add" and/or "git commit -a")', + ]); + }); + + it('a modification commits as one insertion and one deletion', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git add README.md', 'git commit -m "docs: readme"']); + expect(texts(r)[0]).toMatch(/^\[main [0-9a-f]{7}\] docs: readme$/); + expect(texts(r)[1]).toBe(' 1 file changed, 1 insertion(+), 1 deletion(-)'); + }); + + it('a deleted file uses git add/rm, and paths are shown from the current directory', () => { + let s = gitRepoWithCommit.apply(createInitialState()); + s = run(s, ['rm script.sh', 'echo x > README.md', 'mkdir docs', 'echo n > docs/n.txt', 'cd docs']).newState; + expect(texts(run(s, ['git status']))).toEqual([ + 'On branch main', + 'Changes not staged for commit:', + ' (use "git add/rm ..." to update what will be committed)', + ' (use "git restore ..." to discard changes in working directory)', + '\tmodified: ../README.md', '\tdeleted: ../script.sh', '', + 'Untracked files:', + ' (use "git add ..." to include in what will be committed)', + '\t./', '', + 'no changes added to commit (use "git add" and/or "git commit -a")', + ]); + }); + + it('commit -a records modifications and deletions, with delete mode', () => { + let s = gitRepoWithCommit.apply(createInitialState()); + s = run(s, ['rm script.sh']).newState; + const r = run(s, ['git commit -am "chore: remove script"']); + expect(texts(r).slice(1)).toEqual([' 1 file changed, 3 deletions(-)', ' delete mode 100755 script.sh']); + }); + + it('status of the git-status-log lesson, long and short (-s)', () => { + const s = run(gitRepoWithCommit.apply(createInitialState()), ['echo "

Bienvenue

" >> index.html', 'touch style.css']).newState; + expect(texts(run(s, ['git status']))).toEqual([ + 'On branch main', + 'Changes not staged for commit:', + ' (use "git add ..." to update what will be committed)', + ' (use "git restore ..." to discard changes in working directory)', + '\tmodified: index.html', '', + 'Untracked files:', + ' (use "git add ..." to include in what will be committed)', + '\tstyle.css', '', + 'no changes added to commit (use "git add" and/or "git commit -a")', + ]); + expect(texts(run(s, ['git status -s']))).toEqual([' M index.html', '?? style.css']); + }); + + it('commit with nothing staged prints the status and fails', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git commit -m "x"']); + expect(texts(r).slice(0, 5)).toEqual(['On branch main', '', 'Initial commit', '', 'Untracked files:']); + expect(r.status).toBe(1); + }); +}); + +describe('git add errors', () => { + it('a path that does not exist', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git add nope.txt']); + expect(r.lines).toEqual([{ text: "fatal: pathspec 'nope.txt' did not match any files", type: 'error' }]); + expect(r.status).toBe(128); + }); + + it('an ignored file named explicitly is refused unless forced', () => { + const s = gitRepoEmpty.apply(createInitialState()); + const r = run(s, ['git add .env README.md']); + expect(texts(r)).toEqual([ + 'The following paths are ignored by one of your .gitignore files:', + '.env', + 'hint: Use -f if you really want to add them.', + 'hint: Disable this message with "git config set advice.addIgnoredFile false"', + ]); + expect(r.status).toBe(1); + expect(r.newState.git?.stagedFiles).toEqual(['README.md']); + expect(run(s, ['git add -f .env']).newState.git?.stagedFiles).toEqual(['.env']); + }); + + it('nothing specified', () => { + const r = run(gitRepoEmpty.apply(createInitialState()), ['git add']); + expect(texts(r)).toEqual([ + 'Nothing specified, nothing added.', + "hint: Maybe you wanted to say 'git add .'?", + 'hint: Disable this message with "git config set advice.addEmptyPathspec false"', + ]); + expect(r.status).toBe(0); + }); + + it('outside a repository', () => { + const r = run(createInitialState(), ['git status']); + expect(r.lines).toEqual([{ text: 'fatal: not a git repository (or any of the parent directories): .git', type: 'error' }]); + expect(r.status).toBe(128); + }); +}); + +describe('git diff', () => { + it('shows the unstaged change with blob ids and context', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git diff']); + expect(texts(r)).toEqual([ + 'diff --git a/README.md b/README.md', + 'index 7d825c5..6827029 100644', + '--- a/README.md', + '+++ b/README.md', + '@@ -1,6 +1,6 @@', + ' # Mes Projets', + ' ', + '-Bienvenue dans mon répertoire de projets.', + '+Bienvenue dans mon répertoire de projets Git.', + ' ', + ' ## Projets actuels', + ' - script.sh : Script de démonstration', + ]); + expect(r.lines.find((l) => l.text.startsWith('-B'))?.type).toBe('removed'); + expect(r.lines.find((l) => l.text.startsWith('+B'))?.type).toBe('success'); + }); + + it('moves to --staged once the file is added', () => { + const s = run(gitRepoWithChange.apply(createInitialState()), ['git add README.md']).newState; + expect(run(s, ['git diff']).lines).toEqual([]); + expect(texts(run(s, ['git diff --staged']))[1]).toBe('index 7d825c5..6827029 100644'); + }); + + it('a new file in --staged', () => { + const s = run(gitRepoWithCommit.apply(createInitialState()), ['echo x > new.txt', 'echo y >> new.txt', 'git add new.txt']).newState; + expect(texts(run(s, ['git diff --staged new.txt']))).toEqual([ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + 'index 0000000..b77b4eb', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1,2 @@', + '+x', + '+y', + ]); + }); + + it('prints nothing on a clean tree', () => { + expect(run(gitRepoWithCommit.apply(createInitialState()), ['git diff']).lines).toEqual([]); + }); +}); + +describe('git restore, rm, reset, check-ignore', () => { + it('restore brings the committed content back, silently', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git restore README.md']); + expect(r.lines).toEqual([]); + expect(texts(run(r.newState, ['git status']))).toEqual(['On branch main', 'nothing to commit, working tree clean']); + }); + + it('restore --staged unstages without touching the file', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git add README.md', 'git restore --staged README.md']); + expect(r.lines).toEqual([]); + expect(r.newState.git?.stagedFiles).toEqual([]); + expect(texts(run(r.newState, ['git status']))).toContain('\tmodified: README.md'); + }); + + it('restore of an unknown path', () => { + const r = run(gitRepoWithCommit.apply(createInitialState()), ['git restore nope.txt']); + expect(texts(r)).toEqual(["error: pathspec 'nope.txt' did not match any file(s) known to git"]); + expect(r.status).toBe(1); + }); + + it('rm and rm --cached', () => { + const s = gitRepoWithCommit.apply(createInitialState()); + const rm = run(s, ['git rm script.sh']); + expect(texts(rm)).toEqual(["rm 'script.sh'"]); + expect(run(rm.newState, ['ls script.sh']).lines[0].type).toBe('error'); + const cached = run(s, ['git rm --cached script.sh', 'git status']); + expect(texts(cached)).toContain('\tdeleted: script.sh'); + expect(texts(cached)).toContain('\tscript.sh'); + }); + + it('reset unstages and lists what is left', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git add README.md', 'git reset']); + expect(texts(r)).toEqual(['Unstaged changes after reset:', 'M\tREADME.md']); + }); + + it('reset --hard goes back to the last commit', () => { + const s = gitRepoWithChange.apply(createInitialState()); + const r = run(s, ['git reset --hard']); + expect(texts(r)).toEqual(['HEAD is now at a3f8c12 feat: premier commit du projet']); + expect(run(r.newState, ['git diff']).lines).toEqual([]); + }); + + it('check-ignore -v names the rule', () => { + const s = gitRepoWithCommit.apply(createInitialState()); + expect(texts(run(s, ['git check-ignore -v .env']))).toEqual(['.gitignore:2:.env\t.env']); + const none = run(s, ['git check-ignore README.md']); + expect(none.lines).toEqual([]); + expect(none.status).toBe(1); + }); +}); + +describe('git init and clone', () => { + it('git init creates .git/ as git does', () => { + const r = run(createInitialState(), ['mkdir p', 'cd p', 'git init', 'ls -A .git']); + expect(texts(r).join(' ').split(/\s+/)).toEqual(['HEAD', 'config', 'description', 'hooks', 'info', 'objects', 'refs']); + }); + + it('git clone creates the directory; git works inside it, not outside', () => { + const s = run(createInitialState(), ['git clone https://github.com/user/projet.git']).newState; + expect(run(s, ['git status']).status).toBe(128); + expect(texts(run(s, ['cd projet', 'git status']))).toEqual(['On branch main', 'nothing to commit, working tree clean']); + }); + + it('git clone -b takes a branch, not a directory, and refuses an existing destination', () => { + const s = run(createInitialState(), ['git clone -b develop https://github.com/org/projet.git']).newState; + expect(texts(run(s, ['cd projet', 'git branch']))).toEqual(['* develop']); + const again = run(s, ['git clone https://github.com/org/projet.git']); + expect(texts(again)).toEqual(["fatal: destination path 'projet' already exists and is not an empty directory."]); + expect(again.status).toBe(128); + }); +}); + +// Second batch: cases the terminal-fidelity-auditor found, values from Git 2.56. +describe('git options as git parses them', () => { + const committed = () => gitRepoWithCommit.apply(createInitialState()); + + it('status -sb and -b -s name the branch; --porcelain starts paths at the root', () => { + const empty = run(gitRepoEmpty.apply(createInitialState()), ['git status -sb']); + expect(texts(empty)[0]).toBe('## No commits yet on main'); + const s = run(committed(), ['mkdir sub', 'echo s > sub/s.txt', 'echo x >> index.html', 'cd sub']).newState; + expect(texts(run(s, ['git status -s']))).toEqual([' M ../index.html', '?? ./']); + expect(texts(run(s, ['git status --porcelain']))).toEqual([' M index.html', '?? sub/']); + expect(texts(run(s, ['git status -b -s']))[0]).toBe('## main'); + }); + + it('status -uno hides untracked files', () => { + const r = run(committed(), ['touch z', 'git status -uno']); + expect(texts(r)).toEqual(['On branch main', 'nothing to commit (use -u to show untracked files)']); + }); + + it('git rm refuses to lose work, in git\'s words', () => { + const s = run(committed(), ['echo x >> README.md', 'echo y >> index.html', 'git add index.html']).newState; + const r = run(s, ['git rm README.md index.html']); + expect(texts(r)).toEqual([ + 'error: the following file has changes staged in the index:', + ' index.html', + '(use --cached to keep the file, or -f to force removal)', + 'error: the following file has local modifications:', + ' README.md', + '(use --cached to keep the file, or -f to force removal)', + ]); + expect(r.status).toBe(1); + expect(run(s, ['git rm README.md index.html', 'ls README.md']).lines[0].type).toBe('output'); + const plural = run(committed(), ['echo x >> README.md', 'echo y >> index.html', 'git rm README.md index.html']); + expect(texts(plural).slice(0, 3)).toEqual(['error: the following files have local modifications:', ' README.md', ' index.html']); + }); + + it('git rm with staged content different from both the file and HEAD', () => { + const r = run(committed(), ['echo x >> README.md', 'git add README.md', 'echo y >> README.md', 'git rm --cached README.md']); + expect(texts(r)).toEqual([ + 'error: the following file has staged content different from both the', + 'file and the HEAD:', + ' README.md', + '(use -f to force removal)', + ]); + }); + + it('combined short options: rm -rf, add -fA, add -Au', () => { + const s = run(committed(), ['mkdir d', 'echo a > d/a', 'git add d', 'git commit -m d']).newState; + expect(texts(run(s, ['git rm -rf d']))).toEqual(["rm 'd/a'"]); + expect(run(gitRepoEmpty.apply(createInitialState()), ['git add -fA .env']).newState.git?.stagedFiles).toEqual(['.env']); + const both = run(committed(), ['git add -Au']); + expect(texts(both)).toEqual(["fatal: options '-u/--update' and '-A/--all' cannot be used together"]); + expect(both.status).toBe(128); + }); + + it('git diff against HEAD, and its name and stat modes', () => { + const s = run(committed(), ['echo a2 >> README.md', 'echo b2 >> index.html', 'git add README.md']).newState; + expect(texts(run(s, ['git diff HEAD --name-only']))).toEqual(['README.md', 'index.html']); + expect(texts(run(s, ['git diff HEAD --name-only -- index.html']))).toEqual(['index.html']); + expect(texts(run(s, ['git diff --name-only']))).toEqual(['index.html']); + expect(texts(run(s, ['git diff HEAD --name-status']))).toEqual(['M\tREADME.md', 'M\tindex.html']); + expect(texts(run(s, ['git diff HEAD --numstat']))).toEqual(['1\t0\tREADME.md', '1\t0\tindex.html']); + expect(texts(run(s, ['git diff HEAD --shortstat']))).toEqual([' 2 files changed, 2 insertions(+)']); + }); + + it('git diff -U1 keeps one line of context', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git diff -U1']); + expect(texts(r).slice(4)).toEqual([ + '@@ -2,3 +2,3 @@', + ' ', + '-Bienvenue dans mon répertoire de projets.', + '+Bienvenue dans mon répertoire de projets Git.', + ' ', + ]); + }); + + it('git init -b names the branch, -q is silent, neither makes a directory', () => { + const r = run(createInitialState(), ['mkdir x2', 'cd x2', 'git init -b dev', 'git status -sb']); + expect(texts(r)).toEqual(['## No commits yet on dev']); + expect(texts(run(r.newState, ['ls -a'])).join(' ').split(/\s+/)).toEqual(['.', '..', '.git']); + expect(run(r.newState, ['git init -q']).lines).toEqual([]); + }); + + it('git reset: unknown path and unknown option', () => { + const path = run(committed(), ['git reset nope.txt']); + expect(texts(path)[0]).toBe("fatal: ambiguous argument 'nope.txt': unknown revision or path not in the working tree."); + expect(path.status).toBe(128); + const option = run(committed(), ['git reset --bogus']); + expect(texts(option).slice(0, 2)).toEqual(["error: unknown option `bogus'", 'usage: git reset [--mixed | --soft | --hard | --merge | --keep] [-q] []']); + expect(option.status).toBe(129); + }); + + it('commit: -m without a value, --allow-empty, -q', () => { + const noValue = run(committed(), ['git commit -m']); + expect(texts(noValue)).toEqual(["error: switch `m' requires a value"]); + expect(noValue.status).toBe(129); + const empty = run(committed(), ['git commit --allow-empty -m empty']); + expect(texts(empty)).toHaveLength(1); + expect(texts(empty)[0]).toMatch(/^\[main [0-9a-f]{7}\] empty$/); + expect(run(gitRepoWithChange.apply(createInitialState()), ['git commit -qam x']).lines).toEqual([]); + }); + + it('stash -m, a missing entry, and a pop that would overwrite local changes', () => { + const s = run(committed(), ['echo q >> README.md', 'git stash push -m mymsg']).newState; + expect(texts(run(s, ['git stash list']))).toEqual(['stash@{0}: On main: mymsg']); + const missing = run(s, ['git stash pop stash@{3}']); + expect(texts(missing)).toEqual(["fatal: log for 'stash' only has 1 entries"]); + expect(missing.status).toBe(128); + const busy = run(s, ['echo other >> README.md', 'git stash pop']); + expect(texts(busy).slice(0, 4)).toEqual([ + 'error: Your local changes to the following files would be overwritten by merge:', + '\tREADME.md', + 'Please commit your changes or stash them before you merge.', + 'Aborting', + ]); + expect(texts(busy)[texts(busy).length - 1]).toBe('The stash entry is kept in case you need it again.'); + expect(busy.status).toBe(128); + }); + + it('two stashes popped in turn both come back', () => { + const r = run(committed(), ['echo 1 >> README.md', 'git stash', 'echo 2 >> index.html', 'git stash', 'git stash pop', 'git stash pop', 'git status -s']); + expect(texts(r)).toEqual([' M README.md', ' M index.html']); + }); + + it('check-ignore -v names a ! rule; -q is silent; -n needs -v', () => { + const s = run(committed(), ['echo "*.log" >> .gitignore', 'echo "!keep.log" >> .gitignore', 'touch keep.log app.log']).newState; + expect(texts(run(s, ['git check-ignore -v keep.log app.log']))).toEqual(['.gitignore:4:!keep.log\tkeep.log', '.gitignore:3:*.log\tapp.log']); + expect(run(s, ['git check-ignore -q app.log']).lines).toEqual([]); + expect(texts(run(s, ['git check-ignore -n app.log']))).toEqual(['fatal: --non-matching is only valid with --verbose']); + }); +}); + +// Points from the code review, checked against Git 2.56. +describe('git — review fixes', () => { + const committed = () => gitRepoWithCommit.apply(createInitialState()); + + it('hunk headers are standard output: they reach a file', () => { + const r = run(gitRepoWithChange.apply(createInitialState()), ['git diff > p.patch', 'grep @@ p.patch']); + expect(texts(r)).toEqual(['@@ -1,6 +1,6 @@']); + }); + + it('reset with paths refuses --hard and --soft, and never moves HEAD', () => { + const s = run(committed(), ['echo a2 >> README.md', 'git commit -qam two']).newState; + expect(texts(run(s, ['git reset --hard HEAD~1 -- README.md']))).toEqual(['fatal: Cannot do hard reset with paths.']); + expect(texts(run(s, ['git reset --soft README.md']))).toEqual(['fatal: Cannot do soft reset with paths.']); + const r = run(s, ['git reset HEAD~1 -- README.md']); + expect(texts(r)).toEqual(['Unstaged changes after reset:', 'M\tREADME.md']); + expect(r.newState.git?.commits).toHaveLength(2); + expect(texts(run(r.newState, ['git status -s']))).toEqual(['MM README.md']); + expect(run(s, ['git reset --keep']).lines).toEqual([]); + }); + + it('long options take the next word: restore --source, stash push --message', () => { + const s = run(committed(), ['echo a2 >> README.md', 'git commit -qam two']).newState; + const restored = run(s, ['git restore --source HEAD~1 README.md', 'git diff --name-only']); + expect(texts(restored)).toEqual(['README.md']); + const stashed = run(committed(), ['echo q >> README.md', 'git stash push --message "note"', 'git stash list']); + expect(texts(stashed)).toEqual(['stash@{0}: On main: note']); + }); + + it('git init -b dev dev keeps the branch name', () => { + expect(texts(run(createInitialState(), ['git init -b dev dev', 'cd dev', 'git status -sb']))).toEqual(['## No commits yet on dev']); + }); + + it('check-ignore never reports a tracked file', () => { + const r = run(committed(), ['echo "*.md" >> .gitignore', 'touch notes.md', 'git check-ignore README.md notes.md']); + expect(texts(r)).toEqual(['notes.md']); + expect(run(r.newState, ['git check-ignore README.md']).status).toBe(1); + }); + + it('git rm -r removes the directories it empties', () => { + const r = run(committed(), ['mkdir -p d/e', 'echo x > d/e/f', 'git add d', 'git commit -qm d', 'git rm -q -r d', 'ls d']); + expect(r.lines[0].type).toBe('error'); + }); +}); + +describe('gitTree', () => { + it('blob ids are SHA-1 of `blob \\0` (git hash-object)', () => { + expect(blobId('x\ny\n').slice(0, 7)).toBe('b77b4eb'); + expect(blobId('')).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'); + }); + + it('a line without newline differs from the same line with one', () => { + expect(splitLines('l1\nl2')).toEqual(['l1\n', 'l2']); + expect(diffLines(splitLines('a\nb'), splitLines('a\nb\n')).map((o) => o.kind)).toEqual([' ', '-', '+']); + }); + + it('distant changes make two hunks, the second with its function context', () => { + const before = 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\n'; + const after = 'a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nk\nL\nm\n'; + expect(unifiedHunks(before, after)).toEqual([ + '@@ -1,5 +1,5 @@', ' a', '-b', '+B', ' c', ' d', ' e', + '@@ -9,5 +9,5 @@ h', ' i', ' j', ' k', '-l', '+L', ' m', + ]); + }); + + it('.gitignore: names, directories, globs and the last matching rule', () => { + const rules = parseIgnore('node_modules/\n# secrets\n.env\n*.log\n!keep.log\n'); + expect(ignoredBy(rules, '.env', false)?.line).toBe(3); + expect(ignoredBy(rules, 'node_modules/x/i.js', false)?.pattern).toBe('node_modules/'); + expect(ignoredBy(rules, 'logs/app.log', false)?.pattern).toBe('*.log'); + expect(ignoredBy(rules, 'keep.log', false)).toBeNull(); + expect(ignoredBy(rules, 'README.md', false)).toBeNull(); + }); +}); diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts index 98d363f..a7a2e7c 100644 --- a/src/test/lessonFidelity.test.ts +++ b/src/test/lessonFidelity.test.ts @@ -65,7 +65,7 @@ describe('lesson fidelity — the lesson command validates and prints no error', run(id, () => { const solution = LESSON_SOLUTIONS[key][env] ?? LESSON_SOLUTIONS[key].all ?? []; let state: TerminalState = createInitialState(); - if (exercise.setup) state = exercise.setup.apply(state); + if (exercise.setup) state = exercise.setup.apply(state, env); const errors: string[] = []; for (const cmd of solution) { const out = processCommand(state, cmd, env); @@ -104,7 +104,7 @@ describe('lesson fidelity — Windows paths written with backslashes', () => { it(`${key} [${env}] — same result with \\ as separator`, () => { const solution = (LESSON_SOLUTIONS[key].windows ?? LESSON_SOLUTIONS[key].all ?? []).map(withBackslashes); let state: TerminalState = createInitialState(); - if (exercise.setup) state = exercise.setup.apply(state); + if (exercise.setup) state = exercise.setup.apply(state, env); const errors: string[] = []; for (const cmd of solution) { const out = processCommand(state, cmd, env); diff --git a/src/test/lessonSetup.test.ts b/src/test/lessonSetup.test.ts index 611f853..6131844 100644 --- a/src/test/lessonSetup.test.ts +++ b/src/test/lessonSetup.test.ts @@ -22,8 +22,10 @@ describe('lesson setups', () => { it('start Git lessons inside ~/projets, where the project files are', () => { const s = gitRepoEmpty.apply(createInitialState()); expect(s.cwd).toEqual(['home', 'user', 'projets']); + // Real git: `git add .` prints nothing; the .gitignore keeps .env out. const out = processCommand(s, 'git add .', 'linux'); - expect(out.lines.map((l) => l.text)).toEqual(['staged: script.sh', 'staged: README.md']); + expect(out.lines).toEqual([]); + expect(out.newState.git?.stagedFiles).toEqual(['.gitignore', 'README.md', 'index.html', 'script.sh']); }); it('prepare the branch a merge lesson asks for, with main checked out', () => { diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts index 2bfc13f..dfd3509 100644 --- a/src/test/lessonTheoryGaps.ts +++ b/src/test/lessonTheoryGaps.ts @@ -85,22 +85,13 @@ export const KNOWN_THEORY_GAPS = new Set([ "git/git-config b1 [linux] git config --list", "git/git-config b1 [macos] git config --list", // git/git-add-commit - "git/git-add-commit b1 [linux] git status", - "git/git-add-commit b1 [macos] git status", "git/git-add-commit b2 [linux] git commit -m \"feat: ajouter la page d'accueil\"", "git/git-add-commit b2 [macos] git commit -m \"feat: ajouter la page d'accueil\"", // git/git-status-log - "git/git-status-log b1 [linux] git status", - "git/git-status-log b1 [macos] git status", "git/git-status-log b2 [linux] git log", "git/git-status-log b2 [linux] git log --oneline", "git/git-status-log b2 [macos] git log", "git/git-status-log b2 [macos] git log --oneline", - // git/git-diff-gitignore - "git/git-diff-gitignore b1 [linux] git diff", - "git/git-diff-gitignore b1 [macos] git diff", - "git/git-diff-gitignore b3 [linux] git check-ignore -v .env", - "git/git-diff-gitignore b3 [macos] git check-ignore -v .env", // git/git-branch "git/git-branch b1 [linux] git branch", "git/git-branch b1 [linux] git checkout -b feature/panier", @@ -130,8 +121,6 @@ export const KNOWN_THEORY_GAPS = new Set([ "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 b1 [linux] git clone https://github.com/org/projet.git", - "github-collaboration/git-fetch-clone b1 [macos] git clone https://github.com/org/projet.git", "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", diff --git a/src/test/lessonTheoryReplay.ts b/src/test/lessonTheoryReplay.ts index 4317265..9b0eb93 100644 --- a/src/test/lessonTheoryReplay.ts +++ b/src/test/lessonTheoryReplay.ts @@ -13,7 +13,7 @@ export const ENVS: EnvId[] = ['linux', 'macos', 'windows']; /** `user@host:~$ cmd`, `$ cmd`, `% cmd`, `PS> cmd`, `PS C:\…> cmd`. */ export const PROMPT = /^(?:[\w.-]+@[\w.-]+:[^$]*\$|\$|%|PS(?:\s[^>]*)?>)\s+(.+)$/; /** The lesson shows an error on purpose (and so should the terminal). */ -const SHOWS_ERROR = /error|erreur|introuvable|not found|No such|cannot|denied|refus|fatal/i; +const SHOWS_ERROR = /error|erreur|introuvable|not found|No such|cannot|denied|refus|fatal|are ignored by/i; const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); export interface TheoryRow { @@ -35,7 +35,7 @@ export function replayTheory(): TheoryRow[] { const lines = text.split('\n'); if (!lines.some((x) => PROMPT.test(x))) continue; // a file, not a terminal session const bashShownOnWindows = env === 'windows' && !b.contentByEnv?.windows && /^\$\s/m.test(b.content); - let state: TerminalState = l.exercise?.setup ? l.exercise.setup.apply(createInitialState()) : createInitialState(); + let state: TerminalState = l.exercise?.setup ? l.exercise.setup.apply(createInitialState(), env) : createInitialState(); for (let i = 0; i < lines.length; i++) { const match = lines[i].match(PROMPT); if (!match) continue; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 49663af..1938c0b 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -28,6 +28,11 @@ function makeState(overrides: Partial = {}): TerminalState { }; } +/** Creates each file (content = its name) in the current directory, as `echo name > name`. */ +function withFiles(state: TerminalState, ...names: string[]): TerminalState { + return names.reduce((s, name) => processCommand(s, `echo ${name} > ${name}`).newState, state); +} + // Pre-populated filesystem for filesystem-command tests function makeStateWithFS(): TerminalState { return makeState({ @@ -2026,6 +2031,7 @@ describe('git', () => { it('git status shows staged files', () => { let s = processCommand(makeState(), 'git init').newState; + s = withFiles(s, 'fichier.txt'); s = processCommand(s, 'git add fichier.txt').newState; const r = processCommand(s, 'git status'); expect(r.lines.some((l) => l.text.includes('fichier.txt'))).toBe(true); @@ -2051,7 +2057,7 @@ describe('git', () => { }); it('git add stages a specific file', () => { - const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: [], commits: [], remotes: {} } }); + const s = withFiles(makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: [], commits: [], remotes: {} } }), 'README.md'); const r = processCommand(s, 'git add README.md'); expect(r.newState.git?.stagedFiles).toContain('README.md'); }); @@ -2070,6 +2076,7 @@ describe('git', () => { it('git commit -m creates a commit and clears staging', () => { let s = processCommand(makeState(), 'git init').newState; + s = withFiles(s, 'mon-fichier.txt'); s = processCommand(s, 'git add mon-fichier.txt').newState; const r = processCommand(s, 'git commit -m "feat: initial commit"'); expect(r.lines[0].type).toBe('success'); @@ -2078,11 +2085,21 @@ describe('git', () => { expect(r.newState.git?.stagedFiles).toHaveLength(0); }); - it('git commit without -m returns error', () => { - const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: ['a.txt'], commits: [], remotes: {} } }); + it('git commit without -m explains that the editor is not simulated, and records nothing', () => { + const s = withFiles(makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: ['a.txt'], commits: [], remotes: {} } }), 'a.txt'); const r = processCommand(s, 'git commit'); - expect(r.lines[0].type).toBe('error'); - expect(r.lines[0].text).toContain('empty commit message'); + expect(r.lines[0].type).toBe('info'); + expect(r.lines[0].text).toContain('git commit -m'); + expect(r.status).toBe(1); + expect(r.newState.git?.commits).toHaveLength(0); + }); + + // Git 2.56: with something staged, an empty -m aborts (exit 1). + it('git commit -m "" aborts on the empty message', () => { + const s = withFiles(makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: ['a.txt'], commits: [], remotes: {} } }), 'a.txt'); + const r = processCommand(s, 'git commit -m ""'); + expect(r.lines).toEqual([{ text: 'Aborting commit due to empty commit message.', type: 'error' }]); + expect(r.status).toBe(1); }); // Expected values from git in a sandbox (29 September 2026): both cases exit 1. @@ -2315,18 +2332,28 @@ describe('git', () => { }); // ── git diff ────────────────────────────────────────────────────────────────── - it('git diff shows simulated diff output', () => { - const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: ['fichier.txt'], commits: [], remotes: {} } }); - const r = processCommand(s, 'git diff'); - expect(r.lines.some((l) => l.text.includes('diff --git'))).toBe(true); + it('git diff shows the unstaged change of a tracked file, and nothing once it is staged', () => { + let s = withFiles(processCommand(makeState(), 'git init').newState, 'fichier.txt'); + s = processCommand(processCommand(s, 'git add fichier.txt').newState, 'git commit -m "base"').newState; + s = processCommand(s, 'echo autre > fichier.txt').newState; + expect(processCommand(s, 'git diff').lines.map((l) => l.text)).toContain('+autre'); + s = processCommand(s, 'git add fichier.txt').newState; + expect(processCommand(s, 'git diff').lines).toEqual([]); }); // ── git stash ───────────────────────────────────────────────────────────────── - it('git stash clears staged files', () => { - const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: ['a.txt'], commits: [], remotes: {} } }); - const r = processCommand(s, 'git stash'); - expect(r.newState.git?.stagedFiles).toHaveLength(0); - expect(r.lines[0].type).toBe('success'); + it('git stash puts changes aside and git stash pop brings them back', () => { + let s = withFiles(processCommand(makeState(), 'git init').newState, 'a.txt'); + s = processCommand(processCommand(s, 'git add a.txt').newState, 'git commit -m "base"').newState; + s = processCommand(s, 'echo change > a.txt').newState; + s = processCommand(s, 'git add a.txt').newState; + const stash = processCommand(s, 'git stash'); + expect(stash.lines[0].text).toBe(`Saved working directory and index state WIP on main: ${s.git!.commits[0].hash} base`); + expect(stash.newState.git?.stagedFiles).toHaveLength(0); + expect(processCommand(stash.newState, 'cat a.txt').lines[0].text).toBe('a.txt'); + const pop = processCommand(stash.newState, 'git stash pop'); + expect(processCommand(pop.newState, 'cat a.txt').lines[0].text).toBe('change'); + expect(pop.lines[pop.lines.length - 1].text).toMatch(/^Dropped refs\/stash@\{0\} \([0-9a-f]{40}\)$/); }); // ── git config ─────────────────────────────────────────────────────────────── @@ -2366,7 +2393,7 @@ describe('git', () => { // HEAD~3 needs three commits below HEAD: four commits in all. let s = processCommand(makeState(), 'git init').newState; for (const f of ['a', 'b', 'c', 'd']) { - s = processCommand(s, `git add ${f}`).newState; + s = processCommand(withFiles(s, f), `git add ${f}`).newState; s = processCommand(s, `git commit -m "${f}"`).newState; } const r = processCommand(s, 'git rebase -i HEAD~3'); @@ -2380,7 +2407,7 @@ describe('git', () => { // Real git 2.x, with 0 or 1 commit: "fatal: invalid upstream 'HEAD~3'" (exit 128). let s = processCommand(makeState(), 'git init').newState; expect(processCommand(s, 'git rebase -i HEAD~3').lines).toEqual([{ text: "fatal: invalid upstream 'HEAD~3'", type: 'error' }]); - s = processCommand(processCommand(s, 'git add a').newState, 'git commit -m "a"').newState; + s = processCommand(processCommand(withFiles(s, 'a'), 'git add a').newState, 'git commit -m "a"').newState; expect(processCommand(s, 'git rebase -i HEAD~3').lines[0].text).toBe("fatal: invalid upstream 'HEAD~3'"); }); @@ -2392,7 +2419,7 @@ describe('git', () => { // ── git cherry-pick (THI-305) ───────────────────────────────────────────────── it('git cherry-pick without a ref returns usage (repo has commits)', () => { - let s = processCommand(makeState(), 'git init').newState; + let s = withFiles(processCommand(makeState(), 'git init').newState, 'mon-fichier.txt'); s = processCommand(s, 'git add mon-fichier.txt').newState; s = processCommand(s, 'git commit -m "base"').newState; const r = processCommand(s, 'git cherry-pick'); @@ -2406,7 +2433,7 @@ describe('git', () => { }); it('git cherry-pick a bad revision returns fatal', () => { - let s = processCommand(makeState(), 'git init').newState; + let s = withFiles(processCommand(makeState(), 'git init').newState, 'mon-fichier.txt'); s = processCommand(s, 'git add mon-fichier.txt').newState; s = processCommand(s, 'git commit -m "first"').newState; const r = processCommand(s, 'git cherry-pick deadbee'); @@ -2414,7 +2441,7 @@ describe('git', () => { }); it('git cherry-pick re-applies the commit on the current branch', () => { - let s = processCommand(makeState(), 'git init').newState; + let s = withFiles(processCommand(makeState(), 'git init').newState, 'mon-fichier.txt'); s = processCommand(s, 'git add mon-fichier.txt').newState; s = processCommand(s, 'git commit -m "feat: base"').newState; const { hash } = s.git!.commits[0]; @@ -2434,10 +2461,10 @@ describe('git', () => { // ── full workflow integration ───────────────────────────────────────────────── it('full git workflow: init → add → commit → branch → push', () => { let s = processCommand(makeState(), 'git init').newState; - s = processCommand(s, 'git add README.md').newState; + s = processCommand(withFiles(s, 'README.md'), 'git add README.md').newState; s = processCommand(s, 'git commit -m "chore: initial commit"').newState; s = processCommand(s, 'git checkout -b feature/auth').newState; - s = processCommand(s, 'git add auth.ts').newState; + s = processCommand(withFiles(s, 'auth.ts'), 'git add auth.ts').newState; s = processCommand(s, 'git commit -m "feat: add auth"').newState; s = processCommand(s, 'git remote add origin https://github.com/user/repo.git').newState; const push = processCommand(s, 'git push -u origin feature/auth'); @@ -2991,15 +3018,16 @@ describe('reference replay gaps — engine matches the real shells', () => { it('git commit reads the message of -am, -mMSG and --message=MSG', () => { for (const commit of ['git commit -am "fix: x"', 'git commit -m"fix: x"', 'git commit --message="fix: x"', 'git commit --message "fix: x"']) { - const r = run(['git init', 'git add README.md', commit]); - expect(r.lines[0].text, commit).toMatch(/^\[main [0-9a-f]{7}\] fix: x$/); + const r = run(['cd projets', 'git init', 'git add README.md', commit]); + // The first commit of a repository: git 2.56 prints `(root-commit)`. + expect(r.lines[0].text, commit).toMatch(/^\[main \(root-commit\) [0-9a-f]{7}\] fix: x$/); } }); it('git cherry-pick A..B picks the commits after A up to B, oldest first', () => { let s = run(['git init']).newState; for (const m of ['one', 'two', 'three']) { - s = processCommand(s, `git add ${m}`).newState; + s = processCommand(withFiles(s, m), `git add ${m}`).newState; s = processCommand(s, `git commit -m "${m}"`).newState; } const [three, two, one] = s.git!.commits.map((c) => c.hash);