
"What I cannot create, I do not understand."
Quoi de tel pour comprendre un outil, que de le réimplémenter ?
Dans cette série d'articles, on va s'atteler à réimplémenter Git en TypeScript, commande par commande.
Tout le code de cette série peut être retrouvé sur GitHub: github.com/alexisbchz/git.ts.
On commence par la commande init, parce qu'elle pose les bases d'un dépôt.
Cette commande crée un répertoire .git qui pourra être interprété par les commandes ultérieures. À la fin de cet article, la vraie commande git devra reconnaître le dépôt créé par notre programme.
Nous allons procéder en trois temps :
git init.git initAvant d'écrire du code, créons un dépôt de test pour inspecter les fichiers produits par git init :
mkdir /tmp/git-init-test
cd /tmp/git-init-test
Puis, lançons la commande officielle :
git init
La commande affiche un message d'initialisation. Le contenu intéressant se trouve dans le nouveau dossier .git :
.git
├── HEAD
├── objects
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
Chaque élément a un rôle.
| Élément | Rôle |
|---|---|
.git/objects | Contient les objets Git. |
.git/objects/info | Contient des informations supplémentaires sur les objets. |
.git/objects/pack | Contient les objets compressés en packs. |
.git/refs | Contient les références du dépôt. |
.git/refs/heads | Contient les branches locales. |
.git/refs/tags | Contient les tags. |
.git/HEAD | Indique la référence active. |
Lisons maintenant le contenu de HEAD:
cat .git/HEAD
Le contenu habituel ici est le suivant :
ref: refs/heads/main
Cette ligne signifie que le dépôt pointe vers la branche main. La branche peut ne pas encore exister. Git accepte cet état après un init.
Notre première version de git.ts init doit donc faire quatre choses :
.git/objects/info..git/objects/pack..git/refs/heads et .git/refs/tags..git/HEAD avec ref: refs/heads/main.On va commencer par se créer un nouveau projet avec Bun :
mkdir git.ts
cd git.ts
bun init
Je préfère placer le code source dans un dossier src. Ce n'est pas obligatoire avec Bun, mais cela garde la racine du projet plus lisible quand le projet va grossir.
mkdir -p src
mv index.ts src/main.ts
Nous allons représenter chaque commande par une interface simple. Une commande reçoit des arguments. Elle peut être synchrone ou asynchrone.
export type CommandContext = {
args: string[];
};
export interface Command {
readonly description: string;
run(context: CommandContext): Promise<void> | void;
}
Cette interface nous permettra d'ajouter d'autres commandes plus tard. Chaque commande expose une description et une méthode run.
Nous devons distinguer les erreurs d'usage des erreurs inattendues. Une commande inconnue ou une option invalide ne doit pas afficher une stack trace.
Nous créons src/errors.ts.
export class UsageError extends Error {}
Cette classe permettra au point d'entrée de reconnaître une erreur prévue.
On va se créer un registre de commandes. Il gardera la liste des commandes disponibles et permet de retrouver une commande par son nom.
type CommandConstructor = {
new (commands: CommandRegistry): Command;
readonly name: string;
};
class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(...commandTypes: CommandConstructor[]): this {
for (const commandType of commandTypes) {
this.commands.set(commandName(commandType), new commandType(this));
}
return this;
}
find(name: string): Command | undefined {
return this.commands.get(name);
}
get(name: string): Command {
const command = this.find(name);
if (!command) {
throw new Error(`missing command: ${name}`);
}
return command;
}
entries(): IterableIterator<[string, Command]> {
return this.commands.entries();
}
}
function commandName(commandType: CommandConstructor): string {
return commandType.name.replace(/Command$/, "").toLowerCase();
}
Le nom de la commande vient du nom de la classe. InitCommand devient init. HelpCommand devient help.
Le registre stocke des instances, pas seulement des classes. Cette décision a une conséquence importante. Une commande est construite une seule fois au démarrage. Elle peut recevoir des dépendances dans son constructeur.
Dans ce cas, chaque commande reçoit le registre:
new commandType(this)
Cela permet à HelpCommand de lire la liste des commandes. Une autre commande pourrait recevoir plus tard un objet de configuration ou une abstraction de système de fichiers.
La méthode register accepte plusieurs classes:
registry.register(InitCommand, HelpCommand);
ApplicationNotre classe Application se contente de faire le lien entre Bun.argv et nos commandes.
Pour une commande comme celle-ci:
git.ts init my-repo
Bun fournit un tableau proche de celui-ci:
[
"/path/to/bun",
"/path/to/src/main.ts",
"init",
"my-repo"
]
On commence par ignorer les deux premières entrées:
const [, , ...args] = argv;
args contient maintenant les arguments utiles:
["init", "my-repo"]
On sépare ensuite le nom de la commande du reste des arguments:
const [commandName, ...commandArgs] = args;
Puis on affiche l'aide si aucune commande n'est fournie:
if (!commandName || commandName === "-h" || commandName === "--help") {
this.registry.get("help").run({ args: [] });
}
Si le nom ne correspond à rien, on lève une UsageError.
git initLa commande init s'occupe de créer la structure minimale du dépôt.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseArgs } from "node:util";
import type { Command, CommandContext } from "../main";
import { UsageError } from "../errors";
import { parseCommandArgs } from "../utils/parse";
export class InitCommand implements Command {
readonly description = "Create an empty Git repository";
async run({ args }: CommandContext): Promise<void> {
const { values, positionals } = parseCommandArgs(() =>
parseArgs({
args,
options: {
bare: {
type: "boolean",
default: false,
},
},
strict: true,
allowPositionals: true,
}),
);
if (positionals.length > 1) {
throw new UsageError("init accepts at most one directory");
}
const target = positionals[0] ?? ".";
const repositoryPath = values.bare ? target : join(target, ".git");
await mkdir(join(repositoryPath, "objects", "info"), { recursive: true });
await mkdir(join(repositoryPath, "objects", "pack"), { recursive: true });
await mkdir(join(repositoryPath, "refs", "heads"), { recursive: true });
await mkdir(join(repositoryPath, "refs", "tags"), { recursive: true });
try {
await writeFile(join(repositoryPath, "HEAD"), "ref: refs/heads/main\n", {
flag: "wx",
});
} catch (error: unknown) {
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
return;
}
throw error;
}
}
}
parseArgs isole l'option --bare du chemin cible.
["--bare", "my-repo.git"]
On utilise parseArgs pour séparer l'option --bare du chemin cible.
| Élément | Rôle |
|---|---|
args | Les arguments de la commande, sans le nom de celle-ci. |
options | Description des options valides. Ici, seule l'option --bare existe. |
type: "boolean" | Indique que l'option --bare n'attend pas de valeur. |
default: false | Donne une valeur stable quand l'option est absente. |
On refuse plus d'un argument positionnel:
if (positionals.length > 1) {
throw new UsageError("init accepts at most one directory");
}
Puis on calcule le dossier cible:
const target = positionals[0] ?? ".";
const repositoryPath = values.bare ? target : join(target, ".git");
Ensuite, on crée l'arborescence attendue:
await mkdir(join(repositoryPath, "objects", "info"), { recursive: true });
await mkdir(join(repositoryPath, "objects", "pack"), { recursive: true });
await mkdir(join(repositoryPath, "refs", "heads"), { recursive: true });
await mkdir(join(repositoryPath, "refs", "tags"), { recursive: true });
Puis on écrit HEAD:
await writeFile(join(repositoryPath, "HEAD"), "ref: refs/heads/main\n", {
flag: "wx",
});
Le fichier HEAD est écrit avec le flag wx. Ce flag crée le fichier seulement s'il n'existe pas. Si HEAD existe déjà, l'erreur EEXIST est ignorée.
import { UsageError } from "../errors";
export function parseCommandArgs<T>(parse: () => T): T {
try {
return parse();
} catch (error: unknown) {
if (
error instanceof Error &&
"code" in error &&
String(error.code).startsWith("ERR_PARSE_ARGS")
) {
throw new UsageError(error.message);
}
throw error;
}
}
Cette fonction convertit les erreurs de parseArgs en UsageError. L'affichage reste donc propre pour l'utilisateur.
La commande init peut ensuite l'importer sans porter elle-même cette logique:
import { parseCommandArgs } from "../utils/parse";
Le registre est assemblé dans src/main.ts:
import { InitCommand } from "./commands/init";
import { UsageError } from "./errors";
Puis enregistrer les commandes qu'on a définies :
registry.register(InitCommand, HelpCommand);
Notre ./src/main.ts exécute l'application dans un bloc try/catch pour gérer les erreurs :
console.error("run `git.ts help` for usage");
On ajoute un shebang en tête du fichier principal:
#!/usr/bin/env bun
Ce shebang indique au système d'utiliser Bun pour exécuter le fichier.
Nous ajoutons une entrée bin dans package.json, et modifions le champ "module" :
{
"name": "git.ts",
"module": "src/main.ts",
"type": "module",
"private": true,
"bin": {
"git.ts": "./src/main.ts"
}
}
Si tout s'est bien passé, le fichier HEAD devrait contenir :
ref: refs/heads/main
La commande git.ts est désormais disponible depuis le shell, ce qui va être pratique pour l'essayer :
git.ts init my-repo
cd my-repo
Le dépôt créé doit ensuite être reconnu par Git:
echo "hello" > README.md
git add README.md
git commit -m "Initial commit"
Voilà, voilà, on a implémenté notre 1ère commande. Facile jusqu'ici, n'est-ce pas ?
C'est par la suite que ça va se corser. 😅
Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.