
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.
git cat-filecat-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.
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 :
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 :
export type GitObject = {
type: GitObjectType;
content: Buffer;
};
const HASH_PATTERN = /^[0-9a-f]{40}$/;
Puis la fonction elle-même :
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 :
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.content.length !== size) attrape les objets corrompus avant qu'ils n'empoisonnent la suite.-p, -t, -s sont mutuellement exclusifs. On les modélise chacun comme un booléen et on vérifie qu'exactement un est positionné :
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 :
-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.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.Comme pour hash-object, on ajoute l'import et l'enregistrement dans 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à CatFileCommand → cat-file.
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
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.