fr2026-05-12

Implémenter Git en Typescript: la commande cat-file

Git.ts cat-file illustration

Dans le précédent article, on a écrit la première moitié de la base d'objets : hash-object permet de calculer un identifiant et d'écrire un blob dans .git/objects.

Reste à pouvoir le relire. C'est le rôle de cat-file.

Une fois cette commande en place, la boucle écriture-lecture est complète : nos objets sont lisibles par Git, et les objets de Git sont lisibles par nous.

Observer le comportement de git cat-file

cat-file ne fait pas qu'une seule chose. Elle expose plusieurs facettes du même objet via des drapeaux exclusifs.

cd /tmp/git-hash-test
git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world

-p affiche le contenu (« pretty-print »). Pour un blob, c'est simplement le contenu brut.

-t affiche le type :

git cat-file -t 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
blob

-s affiche la taille en octets :

git cat-file -s 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
12

Ces trois drapeaux sont mutuellement exclusifs. Un seul peut être donné à la fois.

Lire un objet

L'écriture d'un objet, vue dans le précédent article, suivait trois étapes : sérialiser (<type> <taille>\0<contenu>), compresser, écrire. La lecture est l'inverse : lire le fichier, décompresser, scinder à l'octet nul, vérifier la taille annoncée.

On ajoute readObject à src/objects.ts, à côté de hashObject et writeObject. Il faut d'abord élargir les imports :

src/objects.ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { deflateSync, inflateSync } from "node:zlib";
import { UsageError } from "./errors";

On introduit aussi un type pour le retour, et un motif pour valider la forme du hash :

src/objects.ts
export type GitObject = {
  type: GitObjectType;
  content: Buffer;
};

const HASH_PATTERN = /^[0-9a-f]{40}$/;

Puis la fonction elle-même :

src/objects.ts
export async function readObject(gitDir: string, hash: string): Promise<GitObject> {
  if (!HASH_PATTERN.test(hash)) {
    throw new UsageError(`not a valid object name: ${hash}`);
  }

  const path = join(gitDir, "objects", hash.slice(0, 2), hash.slice(2));

  let compressed: Buffer;
  try {
    compressed = await readFile(path);
  } catch (error: unknown) {
    if (error instanceof Error && "code" in error && error.code === "ENOENT") {
      throw new UsageError(`object not found: ${hash}`);
    }
    throw error;
  }

  const raw = inflateSync(compressed);
  const headerEnd = raw.indexOf(0);
  if (headerEnd === -1) {
    throw new Error(`malformed object: ${hash}`);
  }

  const header = raw.subarray(0, headerEnd).toString("utf8");
  const match = header.match(/^(blob|tree|commit|tag) (\d+)$/);
  if (!match) {
    throw new Error(`malformed object header for ${hash}: ${header}`);
  }

  const type = match[1] as GitObjectType;
  const size = Number(match[2]);
  const content = raw.subarray(headerEnd + 1);

  if (content.length !== size) {
    throw new Error(
      `size mismatch for ${hash}: header ${size}, actual ${content.length}`,
    );
  }

  return { type, content };
}

Quelques points :

  • On valide d'abord le format du hash (40 caractères hexadécimaux). Sans ce contrôle, on enverrait n'importe quoi à join, avec un message d'erreur peu clair en bout de course.
  • ENOENT est traduit en UsageError. L'utilisateur a tapé un hash inconnu ; ce n'est pas un bug, c'est une erreur d'usage.
  • La vérification de taille (content.length !== size) attrape les objets corrompus avant qu'ils n'empoisonnent la suite.

La commande CLI

-p, -t, -s sont mutuellement exclusifs. On les modélise chacun comme un booléen et on vérifie qu'exactement un est positionné :

src/commands/cat-file.ts
import { parseArgs } from "node:util";
import type { Command, CommandContext } from "../main";
import { UsageError } from "../errors";
import { parseCommandArgs } from "../utils/parse";
import { findGitDir } from "../repository";
import { readObject } from "../objects";

export class CatFileCommand implements Command {
  readonly description = "Provide content or type/size of a repository object";

  async run({ args }: CommandContext): Promise<void> {
    const { values, positionals } = parseCommandArgs(() =>
      parseArgs({
        args,
        options: {
          pretty: { type: "boolean", short: "p", default: false },
          type: { type: "boolean", short: "t", default: false },
          size: { type: "boolean", short: "s", default: false },
        },
        strict: true,
        allowPositionals: true,
      }),
    );

    const modes = [values.pretty, values.type, values.size].filter(Boolean);
    if (modes.length !== 1) {
      throw new UsageError("cat-file requires exactly one of -p, -t, -s");
    }
    if (positionals.length !== 1) {
      throw new UsageError("cat-file expects one object hash");
    }

    const hash = positionals[0]!;
    const gitDir = await findGitDir();
    const object = await readObject(gitDir, hash);

    if (values.type) {
      console.log(object.type);
    } else if (values.size) {
      console.log(object.content.length);
    } else {
      process.stdout.write(object.content);
    }
  }
}

Deux détails :

  • Pour -p, on utilise process.stdout.write plutôt que console.log. Un blob peut contenir un saut de ligne final, ou en être dépourvu. console.log ajouterait son propre saut, ce qui modifierait l'octet de sortie.
  • Pour les blobs, « pretty-print » revient à écrire les octets bruts tels quels. Pour les arbres (tree), Git formate le contenu en plusieurs lignes lisibles. On n'a pas encore d'arbres ; on s'occupera de ce cas dans l'article sur write-tree.

Enregistrer la commande

Comme pour hash-object, on ajoute l'import et l'enregistrement dans src/main.ts :

src/main.ts
import { CatFileCommand } from "./commands/cat-file";

registry.register(InitCommand, HashObjectCommand, CatFileCommand, HelpCommand);

La conversion PascalCase vers kebab-case, ajoutée dans le précédent article, gère déjà CatFileCommandcat-file.

Vérifier avec Git

Le test critique est le double sens : on doit pouvoir lire les objets de Git, et Git doit pouvoir lire les nôtres.

cd /tmp/git-hash-test
echo "lu par nous" > note.txt
HASH=$(git hash-object -w note.txt)
git.ts cat-file -p $HASH
lu par nous

Et l'inverse :

echo "écrit par nous" > note2.txt
HASH=$(git.ts hash-object -w note2.txt)
git cat-file -p $HASH
écrit par nous

Les deux outils manipulent le même format. La compatibilité est totale, du moins pour les blobs.

On vérifie aussi le type et la taille :

git.ts cat-file -t $HASH
git.ts cat-file -s $HASH
blob
15

Conclusion

La base d'objets a une boucle écriture-lecture fonctionnelle. C'est sur cette boucle qu'on va construire la suite : les arbres pour décrire un répertoire, les commits pour décrire un instantané, et git add pour relier tout ça au monde réel.

Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.