fr2026-05-08

Implémenter Git en Typescript: la commande init

Git.ts init illustration

"What I cannot create, I do not understand."

Richard Feynman

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.

Introduction

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 :

  1. Observer le comportement de git init.
  2. Reproduire la structure minimale en TypeScript.
  3. Installer notre commande avec Bun, puis vérifier le résultat avec Git.

Observer le comportement de git init

Avant 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émentRôle
.git/objectsContient les objets Git.
.git/objects/infoContient des informations supplémentaires sur les objets.
.git/objects/packContient les objets compressés en packs.
.git/refsContient les références du dépôt.
.git/refs/headsContient les branches locales.
.git/refs/tagsContient les tags.
.git/HEADIndique 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 :

  1. Créer .git/objects/info.
  2. Créer .git/objects/pack.
  3. Créer .git/refs/heads et .git/refs/tags.
  4. Remplir .git/HEAD avec ref: refs/heads/main.

Préparer le projet

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

Définir une commande

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.

Créer une erreur d'usage

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.

src/errors.ts
export class UsageError extends Error {}

Cette classe permettra au point d'entrée de reconnaître une erreur prévue.

Enregistrer les commandes

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.

src/main.ts
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);

Ajouter notre classe Application

Notre 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.

Implémenter git init

La commande init s'occupe de créer la structure minimale du dépôt.

src/commands/init.ts
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émentRôle
argsLes arguments de la commande, sans le nom de celle-ci.
optionsDescription des options valides. Ici, seule l'option --bare existe.
type: "boolean"Indique que l'option --bare n'attend pas de valeur.
default: falseDonne 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.

Gérer les erreurs de parsing

src/utils/parse.ts
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";

Brancher le point d'entrée

Le registre est assemblé dans src/main.ts:

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");

Rendre la commande exécutable

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"

Conclusion

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.