From 8952857a68f9ce74514a0dd030565cf5b5b65401 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:41:11 +0200 Subject: [PATCH] feat(terminal): real git branches, merges and conflicts, matching Git 2.56 A branch was only a name: switching changed no file, git merge recorded a fake merge commit, and the conflicts lesson never showed a conflict. Git is now a commit graph with refs, real object ids, three-way merges and conflict states. - gitHistory.ts: commit graph, merge base, log order, three-way merge with conflict markers, the --stat graph, tree/commit/tag ids as git computes - gitUsage.ts: usage texts generated from Git 2.56 - branch/switch/checkout move the working tree, carry or refuse local changes; merge fast-forwards, commits, squashes or stops on conflicts; status/add/rm/restore/reset/commit handle the merge state - log/show/diff/tag/cherry-pick/rebase work on the graph (ranges, paths, --graph on a straight history, A...B, tag filters, rebase ) - options git knows but the simulator does not do say "not simulated" - lessons: gitRepoWithBranch is one commit ahead, new gitRepoWithConflict gives the conflicts lesson a real conflict; replayable theory examples - 60 tests in gitBranches.test.ts, every expected value from real git - CHANGELOG/STORY, and the #399 entries dated 29 September, not 30 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +- STORY.md | 14 +- src/app/data/commands/git.ts | 1746 +++++++++++++++++++++++---- src/app/data/commands/gitHistory.ts | 320 +++++ src/app/data/commands/gitUsage.ts | 238 ++++ src/app/data/commands/types.ts | 35 + src/app/data/curriculum.ts | 10 +- src/app/data/lessonSetup.ts | 112 +- src/test/commandReferenceReplay.ts | 65 +- src/test/gitBranches.test.ts | 1242 +++++++++++++++++++ src/test/gitFidelity.test.ts | 2 +- src/test/lessonSetup.test.ts | 6 +- src/test/terminalEngine.test.ts | 134 +- 13 files changed, 3607 insertions(+), 336 deletions(-) create mode 100644 src/app/data/commands/gitHistory.ts create mode 100644 src/app/data/commands/gitUsage.ts create mode 100644 src/test/gitBranches.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7f813..03307af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ --- -## 🌿 30 septembre 2026 — Git se comporte comme le vrai Git +## 🌳 29 septembre 2026 — Les branches Git existent pour de vrai + +*Modules Git et GitHub · valeurs attendues relevées sur Git 2.56 · identifiants de commit identiques à ceux du vrai Git* + +Jusqu'ici, une branche du simulateur n'était qu'un nom. Changer de branche ne changeait aucun fichier, `git merge` inventait un commit de fusion sans rien fusionner, et la leçon sur les conflits n'en montrait jamais un. Chaque branche a maintenant ses commits et ses fichiers, et les fusions se calculent comme dans Git. + +- **Changer de branche change les fichiers.** `git switch` et `git checkout` remettent le dossier dans l'état de la branche. Une modification en cours suit l'élève, et Git la signale (`M` devant `README.md`). Si elle risque d'être écrasée, le changement est refusé avec le message exact de Git. +- **Des fusions calculées, pas mimées.** `git merge` fait simplement avancer la branche quand c'est possible (`Fast-forward`). Si les deux branches ont avancé chacune de leur côté, il crée un commit de fusion. Deux modifications qui ne se touchent pas sont gardées toutes les deux. +- **De vrais conflits.** Quand deux branches changent la même ligne, Git s'arrête et écrit les deux versions dans le fichier, entre `<<<<<<<`, `=======` et `>>>>>>>`. `git status` montre le fichier en conflit, `git add` puis `git commit` concluent la fusion, et `git merge --abort` l'annule. La leçon « Conflits de merge » en provoque un vrai : `main` et la branche ont chacune changé le titre de `index.html`. +- **Un historique lisible comme dans Git.** `git log` retrouve son vrai format : identifiant, auteur, date, et à côté du commit les noms de branche et de tag (`HEAD -> main, tag: v1.0`). Il accepte aussi `--oneline`, `-n`, `--all`, `--author`, `--grep`, `--stat`, `-p`, les plages comme `main..feature` et `--graph` quand l'historique est linéaire. `git show` affiche un commit avec ses changements, ou un fichier tel qu'il était dans un commit (`git show HEAD~1:README.md`). +- **Comparer des branches.** `git diff main feature` montre ce qui sépare deux branches, `git diff main...feature` ce que la branche a ajouté depuis qu'elle est partie de `main`, et `--stat` en donne le résumé. `git tag -n` affiche le message de chaque tag, et `--contains` ou `--merged` filtrent la liste. +- **Les erreurs aussi sont celles de Git.** Un nom de branche ou de tag invalide, une option inconnue, deux noms là où Git n'en attend qu'un : le message est celui de Git, mot pour mot. Les textes d'aide (`usage: git branch …`) sont copiés automatiquement depuis Git 2.56, pas retapés à la main. +- **Les mêmes identifiants que Git.** Mêmes fichiers, même auteur, même date et même message : le commit reçoit le même identifiant que dans un vrai dépôt. Un test le vérifie sur des commits, une fusion et un tag. +- **`git tag`, `git reset`, `git cherry-pick` et `git rebase` travaillent sur ce vrai historique.** Certaines situations ne sont pas encore simulées : le rebase interactif, un conflit pendant un cherry-pick, la « tête détachée », le dessin de `--graph` quand les branches se séparent. Le terminal le dit clairement au lieu d'inventer une réponse. + +--- + +## 🌿 29 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* diff --git a/STORY.md b/STORY.md index 43e1f25..7d0c3a5 100644 --- a/STORY.md +++ b/STORY.md @@ -21,7 +21,19 @@ 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) +## Des branches qui existent vraiment (29 septembre 2026) + +Thierry avait posé la question simplement : « Pour Git, c'est très basique, je présume que c'est l'équivalent d'un premier niveau ? » C'était vrai, et pour une raison précise. On ne peut pas enseigner les branches avec un simulateur où une branche n'est qu'un nom. Il l'a dit mieux que moi : on n'apprend rien correctement sans une théorie et une pratique qui fonctionnent. Avant d'écrire la moindre leçon avancée, il fallait donc que le moteur fusionne pour de bon. + +Chaque commit connaît maintenant ses parents, et chaque branche pointe sur l'un d'eux. J'ai pris une décision de plus : calculer l'identifiant d'un commit exactement comme Git le calcule, à partir des fichiers, de l'auteur, de la date et du message. Pour le vérifier, j'ai créé les mêmes commits dans un vrai Git, à des dates fixées, et comparé : le simulateur donne les mêmes identifiants, fusion et tag compris. + +Le vrai Git m'a encore appris quelque chose. Pour relever ses messages, je redirigeais sa sortie vers un fichier, et j'y lisais une fusion refusée avant la ligne `Updating…`. Dans un vrai terminal, c'est l'inverse : Git écrit ses messages normaux et ses erreurs par deux canaux différents, et une redirection change leur ordre d'arrivée. J'ai donc relancé Git dans un pseudo-terminal, qui se comporte comme un vrai écran. C'est l'ordre de l'écran que l'élève voit, et c'est lui que les tests vérifient maintenant. + +Tout n'est pas encore là, et le terminal ne fait pas semblant. Le rebase interactif ouvre un éditeur, le dessin des branches par `git log --graph` demande un algorithme à part entière, et un conflit pendant un cherry-pick crée un état intermédiaire que nous ne simulons pas encore. Dans ces cas-là, le terminal le dit. Prochaine étape : des leçons en plusieurs étapes, vérifiées sur l'état réel du terminal, où l'élève provoque et résout lui-même ses conflits. + +--- + +## Un Git qui ne ment plus (29 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. diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 7b8fe30..d7a5463 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -1,4 +1,21 @@ import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, GitCommit, GitState, DirectoryNode, FSNode } from './types'; +import { + ancestors, + commitId, + commitTime, + formatGitDate, + ident, + isAncestor, + logOrder, + mergeBase, + mergeText, + newHash, + short, + statGraph, + tagId, + treeId, +} from './gitHistory'; +import { BRANCH_USAGE, CHECKOUT_USAGE, MERGE_USAGE, SWITCH_USAGE, TAG_USAGE } from './gitUsage'; import { blobId, countChanges, @@ -8,6 +25,7 @@ import { nodeAt, parseIgnore, readTree, + splitLines, unifiedHunks, writeFile, type IgnoreRule, @@ -26,10 +44,6 @@ function gitPath(cwd: string[], env: TerminalEnv): string { return ['C:', ...(cwd[0] === 'home' ? ['Users', ...cwd.slice(1)] : cwd)].join('/'); } -function makeHash(length = 7): string { - return Array.from({ length }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join(''); -} - const today = () => new Date().toISOString().slice(0, 10); /** @@ -38,16 +52,46 @@ const today = () => new Date().toISOString().slice(0, 10); * Null when no message option is given (git would open an editor). */ function commitMessage(args: string[]): string | null { + // Each -m is a paragraph: git joins them with a blank line. + const given: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; - if (a === '--message') return args[i + 1] ?? ''; - if (a.startsWith('--message=')) return a.slice('--message='.length); - if (/^-[a-zA-Z]*m/.test(a)) { + if (a === '--') break; + if (a === '--message') given.push(args[++i] ?? ''); + else if (a.startsWith('--message=')) given.push(a.slice('--message='.length)); + else if (/^-[a-zA-Z]*m/.test(a)) { const attached = a.slice(a.indexOf('m') + 1); - return attached || (args[i + 1] ?? ''); + given.push(attached || (args[++i] ?? '')); } } - return null; + return given.length ? given.join('\n\n') : null; +} + +/** + * Git's default regex for --grep and --author is POSIX basic: `( ) { } | + ?` are plain + * characters, and `\( \) \{ \} \| \+ \?` the operators. JavaScript reads them the other way. + */ +function basicRegex(pattern: string): string { + let out = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '\\' && i + 1 < pattern.length) { + const next = pattern[++i]; + out += '(){}|+?'.includes(next) ? next : `\\${next}`; + } else out += '(){}|+?'.includes(c) ? `\\${c}` : c; + } + return out; +} + +/** Every value of a long option given several times (`--grep=a --grep b`). */ +function allValues(args: string[], name: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--') break; + if (args[i] === `--${name}`) values.push(args[++i] ?? ''); + else if (args[i].startsWith(`--${name}=`)) values.push(args[i].slice(name.length + 3)); + } + return values; } // ─── Repository model ──────────────────────────────────────────────────────── @@ -80,9 +124,48 @@ function changes(from: Tree, to: Tree): Array<{ path: string; kind: 'new file' | .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. */ +/** + * Derives what the commit graph decides: HEAD's files and history, the branch + * list, and `stagedFiles` (the paths a commit would record). + */ function synced(g: GitState): GitState { - return { ...g, stagedFiles: changes(g.head ?? {}, g.index ?? {}).map((c) => c.path) }; + const objects = g.objects ?? {}; + const refs = g.refs ?? {}; + const tip = refs[g.branch]; + const head = tip ? objects[tip]?.tree ?? {} : {}; + return { + ...g, + head, + commits: tip ? logOrder(objects, [tip]) : [], + branches: [...new Set([...Object.keys(refs), g.branch])].sort(), + stagedFiles: changes(head, g.index ?? {}).map((c) => c.path), + }; +} + +/** For prepared states (lesson setups): derive the fields git computes from the graph. */ +export const syncGit = synced; + +/** + * A commit graph for a state that lists its commits by hand (tests, older + * saved states): a straight line, newest first, every branch at the newest + * commit. Short hashes are completed to 40 digits. + */ +function graphFrom(g: GitState, head: Tree): Pick { + const full = (h: string) => (/^[0-9a-f]{40}$/.test(h) ? h : (h + blobId(h)).slice(0, 40)); + const objects: Record = {}; + let later = Infinity; + g.commits.forEach((c, i) => { + const parsed = Date.parse(`${c.date}T09:00:00Z`) / 1000; + const time = c.time ?? Math.min(Number.isFinite(parsed) ? parsed : 1768467600, later - 60); + later = time; + const hash = full(c.hash); + const parents = c.parents ?? (g.commits[i + 1] ? [g.commits[i + 1].hash] : []); + objects[hash] = { ...c, hash, tree: c.tree ?? head, parents: parents.map(full), time, tz: c.tz ?? 60 }; + }); + const tip = g.commits[0] ? full(g.commits[0].hash) : undefined; + const refs = tip ? Object.fromEntries([...new Set([...g.branches, g.branch])].map((b) => [b, tip])) : {}; + const remoteRefs = g.remoteRefs ?? (tip ? Object.fromEntries(Object.keys(g.remotes).map((r) => [`${r}/${g.branch}`, tip])) : {}); + return { objects, refs, remoteRefs }; } /** @@ -93,7 +176,7 @@ function synced(g: GitState): GitState { */ function withModel(state: TerminalState, env: TerminalEnv): TerminalState { const g = state.git; - if (!g?.initialized || (g.repoPath && g.head && g.index && g.modes)) return state; + if (!g?.initialized || (g.repoPath && g.head && g.index && g.modes && g.objects && g.refs)) return state; const repoPath = g.repoPath ?? state.cwd; const files = readTree(state.root, repoPath); const head = g.head ?? (g.commits.length ? { ...files } : {}); @@ -101,8 +184,9 @@ function withModel(state: TerminalState, env: TerminalEnv): TerminalState { 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 graph = g.objects && g.refs ? {} : graphFrom(g, head); 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 }) }; + return { ...state, root, git: synced({ ...g, repoPath, head, index, modes, ...graph }) }; } /** A copy of `root` with `dir` placed at `path` (its parent must exist). */ @@ -154,7 +238,7 @@ function openRepo(state: TerminalState): Repo { } /** Files of `tree` at `rel` or below it (`''` is the whole repository). */ -function under(tree: Tree, rel: string): string[] { +function under(tree: Record, rel: string): string[] { return Object.keys(tree).filter((p) => !rel || p === rel || p.startsWith(`${rel}/`)); } @@ -164,7 +248,9 @@ function under(tree: Tree, rel: string): string[] { */ 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); + // An unmerged path is tracked even when the index has no version of it (deleted by us). + const tracked = (p: string) => p in repo.index || !!repo.g.merge?.conflicts[p]; + const visible = (p: string) => !tracked(p) && !ignoredBy(repo.rules, p, false); const walk = (node: DirectoryNode, rel: string) => { for (const [name, child] of Object.entries(node.children)) { if (name === '.git') continue; @@ -176,7 +262,7 @@ function untracked(state: TerminalState, repo: Repo, collapse = true): Array<{ p 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 }); + if (collapse && files.every((f) => !tracked(f))) out.push({ path: p, dir: true }); else walk(child, p); } }; @@ -205,15 +291,37 @@ function statusLines(state: TerminalState, repo: Repo, forCommit = false, untrac 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 conflicts = g.merge?.conflicts ?? {}; + const unmerged = Object.keys(conflicts).sort(); + if (g.merge) { + if (unmerged.length) { + say('You have unmerged paths.'); + say(' (fix conflicts and run "git commit")'); + say(' (use "git merge --abort" to abort the merge)'); + } else { + say('All conflicts fixed but you are still merging.'); + say(' (use "git commit" to conclude merge)'); + } + say(''); + } + const staged = changes(repo.head, repo.index).filter((c) => !(c.path in conflicts)); + const notStaged = unstaged(repo).filter((c) => !(c.path in conflicts)); 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)'); + // During a merge git gives no hint here. + if (!g.merge) 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 (unmerged.length) { + say('Unmerged paths:'); + say(unmerged.some((p) => conflicts[p].ours === null || conflicts[p].theirs === null) + ? ' (use "git add/rm ..." as appropriate to mark resolution)' + : ' (use "git add ..." to mark resolution)'); + unmerged.forEach((p) => say(`\t${conflictKind(conflicts[p]).label.padEnd(17)}${shown(p, repo.cwdInRepo)}`, 'removed')); + say(''); + } if (notStaged.length) { say('Changes not staged for commit:'); say(notStaged.some((c) => c.kind === 'deleted') @@ -229,8 +337,10 @@ function statusLines(state: TerminalState, repo: Repo, forCommit = false, untrac 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")'); + // A merge whose conflicts are all fixed can be committed even when the index equals HEAD: no closing line. + const committable = staged.length > 0 || (g.merge !== undefined && !unmerged.length); + if (!committable) { + if (notStaged.length || unmerged.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'); @@ -279,6 +389,248 @@ function statLine(files: number, insertions: number, deletions: number): string return line; } +// ─── History ───────────────────────────────────────────────────────────────── + +/** + * The commit a revision names, as git reads it: HEAD (or @), a branch, a tag, + * a remote-tracking branch (`origin/main`) or a unique hash prefix, followed + * by any number of `~N` (Nth first-parent ancestor) and `^N` (Nth parent). + */ +function resolveRev(g: GitState, spec: string): string | null { + const m = spec.match(/^(.+?)((?:[~^]\d*)*)$/); + if (!m) return null; + const [, name, ops] = m; + const objects = g.objects ?? {}; + const refs = g.refs ?? {}; + const bare = name.replace(/^refs\/(heads|tags|remotes)\//, ''); + let h: string | undefined; + if (name === 'HEAD' || name === '@') h = refs[g.branch]; + else if (name === '@{-1}') h = g.previousBranch ? refs[g.previousBranch] : undefined; + else if (g.tags?.[bare]) h = g.tags[bare]; + else if (refs[bare]) h = refs[bare]; + else if (g.remoteRefs?.[bare.replace(/^remotes\//, '')]) h = g.remoteRefs[bare.replace(/^remotes\//, '')]; + else if (/^[0-9a-f]{4,40}$/.test(name)) { + const found = Object.keys(objects).filter((k) => k.startsWith(name)); + if (found.length === 1) h = found[0]; + } + for (const op of ops.match(/[~^]\d*/g) ?? []) { + if (!h) return null; + const n = op.length > 1 ? Number(op.slice(1)) : 1; + if (op[0] === '~') for (let i = 0; i < n && h; i++) h = objects[h]?.parents?.[0]; + else if (n > 0) h = objects[h]?.parents?.[n - 1]; + } + return h && objects[h] ? h : null; +} + +const subject = (c: GitCommit) => c.message.split('\n')[0]; + +/** ` (HEAD -> main, tag: v1.0, origin/main, feature)`: what a terminal shows next to a commit. */ +function decorations(g: GitState, hash: string): string { + const refs = g.refs ?? {}; + const headHere = refs[g.branch] === hash; + // Git lists the other names in reverse order of their full ref name. + const names = [ + ...Object.entries(refs).filter(([b, h]) => h === hash && !(headHere && b === g.branch)).map(([b]) => `refs/heads/${b}`), + ...Object.entries(g.remoteRefs ?? {}).filter(([, h]) => h === hash).map(([r]) => `refs/remotes/${r}`), + ...Object.entries(g.tags ?? {}).filter(([, h]) => h === hash).map(([t]) => `refs/tags/${t}`), + ].sort().reverse().map((n) => (n.startsWith('refs/tags/') ? `tag: ${n.slice(10)}` : n.replace(/^refs\/(heads|remotes)\//, ''))); + const all = [...(headHere ? [`HEAD -> ${g.branch}`] : []), ...names]; + return all.length ? ` (${all.join(', ')})` : ''; +} + +/** The header `git log` and `git show` print for a commit. */ +function commitHeader(g: GitState, c: GitCommit, decorate = true, abbrev = false): OutputLine[] { + return [ + { text: `commit ${abbrev ? short(c.hash) : c.hash}${decorate ? decorations(g, c.hash) : ''}`, type: 'success' }, + ...((c.parents?.length ?? 0) > 1 ? [{ text: `Merge: ${c.parents!.map(short).join(' ')}`, type: 'output' as const }] : []), + { text: `Author: ${ident(c.author)}`, type: 'output' }, + { text: `Date: ${formatGitDate(c.time ?? 0, c.tz ?? 0)}`, type: 'output' }, + { text: '', type: 'output' }, + ...c.message.split('\n').map((l) => ({ text: ` ${l}`, type: 'output' as const })), + ]; +} + +/** The `--stat` block from one tree to another: a line per file, the totals, created and deleted files. */ +function statBlock(from: Tree, to: Tree, modes: Record, withModes = true, withGraph = true): OutputLine[] { + const changed = changes(from, to); + if (!changed.length) return []; + const counts = changed.map((c) => ({ path: c.path, ...countChanges(from[c.path] ?? '', to[c.path] ?? '') })); + const total = counts.reduce((t, n) => ({ insertions: t.insertions + n.insertions, deletions: t.deletions + n.deletions }), { insertions: 0, deletions: 0 }); + return [ + ...(withGraph ? statGraph(counts).map((text) => ({ text, type: 'output' as const })) : []), + { text: statLine(changed.length, total.insertions, total.deletions), type: 'output' }, + ...(withModes ? changed.filter((c) => c.kind !== 'modified').map((c) => ({ + text: ` ${c.kind === 'new file' ? 'create' : 'delete'} mode ${modes[c.path] ?? '100644'} ${c.path}`, + type: 'output' as const, + })) : []), + ]; +} + +/** A new commit on top of `parents`, with an id computed as git computes it. */ +function makeCommit(g: GitState, user: string, tree: Tree, parents: string[], message: string, author?: GitCommit): GitCommit { + const now = commitTime(g.objects ?? {}); + const time = author?.time ?? now.time; + const tz = author?.tz ?? now.tz; + const committed = author ? now.time : undefined; + const hash = commitId({ tree: treeId(tree, g.modes), parents, author: author?.author ?? user, time, tz, committed, message }); + return { hash, message, author: author?.author ?? user, date: today(), tree, parents, time, tz, ...(committed === undefined ? {} : { committed }) }; +} + +/** `g` with `commit` stored and the current branch moved to it. */ +function advance(g: GitState, commit: GitCommit): GitState { + return { ...g, objects: { ...g.objects, [commit.hash]: commit }, refs: { ...g.refs, [g.branch]: commit.hash } }; +} + +type Conflict = { base: string | null; ours: string | null; theirs: string | null }; + +interface ThreeWay { + /** What the index holds after the merge: the merged files, and our side of each conflict. */ + tree: Tree; + /** What the working tree holds where it differs from `tree`: files with conflict markers. */ + work: Record; + conflicts: Record; + lines: OutputLine[]; +} + +/** + * Git's default merge of two commits from their common ancestor: a change made + * on one side only is taken; a file changed on both sides is merged line by + * line (`Auto-merging`), and what cannot be merged is a conflict. + */ +function threeWay(base: Tree, ours: Tree, theirs: Tree, theirsLabel: string): ThreeWay { + const out: ThreeWay = { tree: {}, work: {}, conflicts: {}, lines: [] }; + const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort(); + for (const p of paths) { + const b = base[p]; + const o = ours[p]; + const t = theirs[p]; + const take = (v: string | undefined) => { if (v !== undefined) out.tree[p] = v; }; + if (o === t || b === t) { take(o); continue; } + if (b === o) { take(t); continue; } + if (o !== undefined && t !== undefined) { + const merged = mergeText(b ?? '', o, t, 'HEAD', theirsLabel); + out.lines.push({ text: `Auto-merging ${p}`, type: 'output' }); + if (!merged.conflict) { take(merged.content); continue; } + out.lines.push({ text: `CONFLICT (${b === undefined ? 'add/add' : 'content'}): Merge conflict in ${p}`, type: 'removed' }); + out.work[p] = merged.content; + } else { + const [gone, kept] = o === undefined ? ['HEAD', theirsLabel] : [theirsLabel, 'HEAD']; + out.lines.push({ text: `CONFLICT (modify/delete): ${p} deleted in ${gone} and modified in ${kept}. Version ${kept} of ${p} left in tree.`, type: 'removed' }); + out.work[p] = o ?? t ?? null; + } + take(o); + out.conflicts[p] = { base: b ?? null, ours: o ?? null, theirs: t ?? null }; + } + return out; +} + +/** Whether git accepts `name` for a branch or a tag (`git check-ref-format`). */ +function validRefName(name: string): boolean { + if (!name || name === 'HEAD' || name === '@' || name.startsWith('-') || name.startsWith('/') || name.endsWith('/') || name.endsWith('.')) return false; + // Spaces, control characters and the characters revisions use (~ ^ : ? * [ \) are refused. + if (/[\s~^:?*[\\\x00-\x1f\x7f]|\.\.|@\{|\/\//.test(name)) return false; + return name.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock')); +} + +/** Git refuses `feature` next to `feature/login` (and the reverse): one would be a file, the other a folder. */ +function refClash(name: string, existing: string[], kind: 'heads' | 'tags'): string | null { + const other = existing.find((e) => e !== name && (e.startsWith(`${name}/`) || name.startsWith(`${e}/`))); + return other ? `fatal: cannot lock ref 'refs/${kind}/${name}': 'refs/${kind}/${other}' exists; cannot create 'refs/${kind}/${name}'` : null; +} + +/** A message as git's default clean-up leaves it: no `#` lines, no blank lines at the end. */ +function stripComments(message: string): string { + return message.split('\n').filter((l) => !l.startsWith('#')).join('\n').replace(/\s+$/, ''); +} + +/** + * An option the simulator does not handle. When git's usage lists it, git knows it: + * it is only not simulated (the info line, exit 1). Otherwise git itself refuses it with + * `error: unknown option ...` and the command's usage (exit 129). + */ +function unknownOption(o: Options, shortOk: string, longOk: string[], usage: string[]): { lines: OutputLine[]; status: number } | null { + const bad = [...o.short, ...o.values.keys()].find((c) => !shortOk.includes(c)); + const badLong = [...o.long.keys()].find((k) => !longOk.includes(k)); + if (bad === undefined && badLong === undefined) return null; + const text = usage.join('\n'); + const listed = bad !== undefined + ? new RegExp(`(^|[\\s[(|])-${bad.replace(/[^\w]/g, '\\$&')}[\\s,[\\]<|)]`, 'm').test(text) + : [badLong!, badLong!.replace(/^no-/, '')].some((name) => { + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`--(\\[no-\\])?${n}([\\s=[\\]<|)]|$)`, 'm').test(text); + }); + if (listed) { + const command = usage[0].split(' ')[2]; + const option = bad !== undefined ? `-${bad}` : `--${badLong}`; + return { lines: [{ text: `L'option ${option} de git ${command} n'est pas simulée dans ce terminal d'entraînement.`, type: 'info' }], status: 1 }; + } + const first = bad !== undefined ? `error: unknown switch \`${bad}'` : `error: unknown option \`${badLong}'`; + return { lines: [first, ...usage].map((line) => ({ text: line, type: 'error' as const })), status: 129 }; +} + +/** The merge state once `paths` are staged (`git add`, `git rm`): their conflicts are resolved. */ +function resolving(g: GitState, paths: Set): GitState['merge'] { + if (!g.merge) return undefined; + return { ...g.merge, conflicts: Object.fromEntries(Object.entries(g.merge.conflicts).filter(([p]) => !paths.has(p))) }; +} + +/** How `git status` names a conflict, and its two letters in `git status -s`. */ +function conflictKind(c: Conflict): { label: string; code: string } { + if (c.ours === null) return { label: 'deleted by us:', code: 'DU' }; + if (c.theirs === null) return { label: 'deleted by them:', code: 'UD' }; + return c.base === null ? { label: 'both added:', code: 'AA' } : { label: 'both modified:', code: 'UU' }; +} + +/** POSIX classes git's wildmatch accepts inside brackets (`[[:digit:]]`). */ +const POSIX_CLASSES: Record = { + alpha: 'a-zA-Z', digit: '0-9', alnum: 'a-zA-Z0-9', upper: 'A-Z', lower: 'a-z', space: '\\s', xdigit: '0-9a-fA-F', punct: '!-\\/:-@\\[-`{-~', +}; + +/** + * The glob of `git tag -l` and `git branch --list` (wildmatch): `*`, `?`, `[a-z]`, `[!x]` or `[^x]`, + * `[[:digit:]]`, `\` escapes. A class git cannot use (a reversed range) matches nothing. + */ +function globMatch(pattern: string, name: string): boolean { + const quote = (c: string) => c.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&'); + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') re += '.*'; + else if (c === '?') re += '.'; + else if (c === '\\' && i + 1 < pattern.length) re += quote(pattern[++i]); + else if (c === '[') { + // The class ends at the first `]` that is not its first character nor part of `[:name:]`. + let j = i + 1; + if (pattern[j] === '!' || pattern[j] === '^') j++; + if (pattern[j] === ']') j++; + while (j < pattern.length && pattern[j] !== ']') { + const posix = pattern.slice(j).match(/^\[:([a-z]+):\]/); + j += posix ? posix[0].length : 1; + } + if (j >= pattern.length) { re += quote(c); continue; } + let body = pattern.slice(i + 1, j); + const negate = body[0] === '!' || body[0] === '^'; + if (negate) body = body.slice(1); + let cls = ''; + for (let k = 0; k < body.length; k++) { + const posix = body.slice(k).match(/^\[:([a-z]+):\]/); + if (posix) { + if (!(posix[1] in POSIX_CLASSES)) return false; + cls += POSIX_CLASSES[posix[1]]; + k += posix[0].length - 1; + } else if (body[k + 1] === '-' && k + 2 < body.length) { + if (body[k] > body[k + 2]) return false; + cls += `${quote(body[k])}-${quote(body[k + 2])}`; + k += 2; + } else cls += quote(body[k]); + } + re += `[${negate ? '^' : ''}${cls}]`; + i = j; + } else re += quote(c); + } + return new RegExp(`^${re}$`).test(name); +} + interface Options { short: Set; long: Map; @@ -369,6 +721,75 @@ const RESET_USAGE = [ const NOT_A_REPO = 'fatal: not a git repository (or any of the parent directories): .git'; +/** The `git log` options the simulator knows; any other is reported as not simulated. */ +const LOG_OPTIONS = ['oneline', 'all', 'graph', 'decorate', 'no-decorate', 'stat', 'patch', 'author', 'grep', 'max-count', 'pretty', 'no-merges', 'reverse', 'abbrev-commit', 'no-abbrev-commit']; + +/** Git 2.56's usage of `git cherry-pick`, printed when no commit is given. */ +const CHERRY_PICK_USAGE = [ + 'usage: git cherry-pick [--edit] [-n] [-m ] [-s] [-x] [--ff]', + ' [-S[]] ...', + ' or: git cherry-pick (--continue | --skip | --abort | --quit)', + '', + ' --quit end revert or cherry-pick sequence', + ' --continue resume revert or cherry-pick sequence', + ' --abort cancel revert or cherry-pick sequence', + ' --skip skip current commit and continue', + ' --[no-]cleanup how to strip spaces and #comments from message', + " -n, --no-commit don't automatically commit", + ' --commit opposite of --no-commit', + ' -e, --[no-]edit edit the commit message', + ' -s, --[no-]signoff add a Signed-off-by trailer', + ' -m, --[no-]mainline ', + ' select mainline parent', + ' --[no-]rerere-autoupdate', + ' update the index with reused conflict resolution if possible', + ' --[no-]strategy ', + ' merge strategy', + ' -X, --[no-]strategy-option