В продължение на много години Git е предпочитаният SCM (управление на контрола на източника, известен още като контрол на версиите). Той предлагаше много функции, които алтернативи като CVS не предлагаха, и комбиниран с уебсайта на GitHub създаде цял CI тръбопровод, около който може да се изгради всеки екип, практикуващ Dev.

Когато започнах да чета за механиката на Git, беше очевидно, че това е комбинация от много различни техники, всички от които произвеждат „репликирана файлова система с версии“, известна като Git, например:

  • Свързани списъци,
  • База данни с обекти на файловата система
  • Хеширане (статистика SHA-1 срещу съдържание SHA-1 срещу намаляване на съдържанието)
  • Диференциално кодиране

Затова реших да създам мини-работеща версия с някои от основните функции за контрол на версиите. За щастие има много полезни книги, които разбиват как работят нещата, така че се опитах да премахна вътрешностите до техния минимум.

Тази публикация ще се фокусира върху:

  • хранилища,
  • работни директории,
  • постановка,
  • ангажиране
  • проверки на състоянието.

Пропуснал съм packfiles, deltas, клонове, тагове, сливане и сравняване на поетапни парчета (diffing). Може да направя последваща публикация/хранилище за тях.

Това е част от моята серия «„под капака““:

Днешната статия ще бъде разделена на:

  1. Преглед
  • Работния процес
  • Обектен модел
  • Компоненти
  • Допълнителна литература

2. Изграждане на наш собствен Git

  • Нашият git код
  • Тестването работи

3. Какво сме пропуснали?

1: Преглед

Git се описва като разпределена система за контрол на версиите, която проследява промените във всеки набор от файлове. Първоначално беше пуснат преди 15 години (през 2005 г.) и оттогава е нараснал значително във функционалност и популярност. Както знае всеки разработчик, който използва Github (или алтернатива, напр. BitBucket/GitLab), той се е превърнал в основен елемент в света на софтуера като най-добра практика.

Работен процес

Няма да преглеждам как се използва, но основният работен процес може да бъде обобщен от:

  1. инициализирайте ново git хранилище
  2. Промяната на файл/и се прави локално и се записва
  3. Файлът/ите се добавят към етапа
  4. Файлът(овете) в зоната за етап са ангажирани
  5. Ангажиментът се насочва към отдалечено хранилище (изтегля най-новото преди да го направи).

Ще разбием всяка стъпка, но преди да го направим, трябва да прегледаме механизма в основата на Git, „Обектния модел“.

Обектен модел

Обектният модел по същество е невероятно ефективна файлова система с версии (с репликация).

Всеки файл в хранилището съществува във файловата система и обектната база данни. Обектната база данни е хеш на съдържанието. Хешът е обект, има общо 4 вида, но днес ще разгледаме (с изключение на „тагове“):

  1. Blob -› последователност от байтове. Петно в Git ще съдържа същите точни данни като файл, просто петното се съхранява в базата данни на Git обект. Основно съдържанието на файла.
  2. Дърво -› съответства на записи в директория на UNIX. Може да съдържа петна или поддървета (поддиректория). Дървото на ангажиментите съдържа целия проект в blob и дървета по време на ангажимента. Може да пресъздаде целия проект от това дърво. Винаги от основната директория, дори ако файлът на поддиректория се актуализира в ангажимента.
  3. Ангажимент -› идентификатор на едно дърво и ангажименти пред него

Всеки дървовиден възел, ангажимент и файл имат собствено уникално SHA-1 представяне с дължина 40 знака. Името на файла е хеш на съдържанието. Ако съдържанието се промени, хешът също. Всеки път, когато се променя, се добавя нов запис/хеш, но запазва старите.

Вътре в git хранилище те се намират в папката .git/objects.

Това е любимото ми изображение за описание на структурата.

Хеш

В рамките на обектния модел името на файла е двупосочно SHA-1 кодиране на съдържанието.

Git добавя към всички Blob обекти blob , последвано от дължината (като цяло число, което може да се чете от човека), последвано от NUL знак Пример:

> s='abc'
> printf "$s" | git hash-object --stdin

Еквивалентно на

> printf "blob $(printf "$s" | wc -c)\0$s" | sha1sum

Съдържанието на обектния файл се компресира чрез алгоритъм DEFLATE zlib, той е по-малко четим от човека или по-удобен за име на файл, но е по-ефективно кодиране.

Компоненти

Ще покрия компонентите, които ще изградим в нашата мини-работна версия.

Работна директория

Текущата системна папка с git хранилище, известна също като работно дърво.

ГЛАВА

Файл, съдържащ препратка към текущия работен клон. По принцип последното проверено работно пространство. Той съдържа препратка към родителския ангажимент, обикновено последния изваден клон.

Намерен във файл .git/HEAD. Пример

> ls .git/HEAD ref: refs/heads/master > ls .git/refs/heads/master 2e1803ee08fa9aa36e4c5918220e283380a4c385

Клон

Разклонението всъщност е само наименуван указател към конкретна моментна снимка. Когато се извади

  1. премества показалеца HEAD, за да сочи към функцията ref (клон)
  2. премества цялото съдържание от текущото репо на клон в индексния файл, така че е лесно да се проследяват промените.
  3. Направете работеща директория да съответства на съдържанието на ангажимента, сочещ към (използване на дървовидни и blob обекти за актуализиране на съдържанието на работната директория)

Етикети

Псевдоним за идентификатор на ангажимент. HEAD ще сочи към най-новите или предварително дефинирани, напр. .git/refs/heads/tags/<tag_name>

Хранилище

Git проект, съхранен на диск, т.е. не в паметта. По същество колекция от обекти.

Постановка

Област между работната директория и хранилището. Всички промени в етапа ще бъдат в следващия комит.

Индекс файл

Индексът е двоичен файл, не съдържа обекти (петна/дървета), съхранява информация за файлове в хранилище. Това е състояние на виртуално работещо дърво.

Индексният файл се намира на адрес .git/index. Можете да видите състоянието на индексния файл чрез > git ls-files --stage

Съхранена информация

За всеки файл, който съхранява

  • време на последно обновяване, име на файл,
  • версия на файла в работната директория,
  • версия на файла в индекса,
  • версия на файла в хранилището

Версиите на файла са маркирани с контролни суми, SHA-1 хеш на stat(), а не хеш на съдържанието. Това е по-ефективно.

Опресняване

Той се актуализира, когато проверявате клон или работната директория се актуализира. Работи автоматично във фонов режим.

Хеширане

Той използва използва файлова система stat(), за да получи информация за файловете, за да провери бързо дали съдържанието на работния дървовиден файл се е променило от записващото устройство на версията в индексния файл. Проверява времето за модификация на файла под st_mtime.

Опресняването буквално извиква stat() за всички файлове.

Допълнителна литература

Основната цел на тази публикация е мини-работещата версия по-долу, така че току-що засегнахме накратко как работи git. Ето уебсайтове, които навлизат в много повече подробности

2: Изграждане на наш собствен Git

Кодът се състои от 4 файла, по един за всяка команда, плюс помощна програма.

  • init.mjs
  • status.mjs
  • add.mjs
  • commit.mjs
  • util.mjs

init.mjs

// imports excluded, see linked repo for details
const init = () => {
  const workingDirectory = workingDir();
  const files = glob.sync("**/*.txt", { cwd: workingDirectory }); // (1)

  const indexData = files.reduce((acc, curr) => { // (2)
    const hash = hashFileStats(curr);
    acc[curr] = {
      cwd: hash,
      staging: "",
      repository: "",
    };
    return acc;
  }, {});

  fs.mkdirSync(`${workingDirectory}/.repo`); // (3)
  updateIndex(indexData);
  fs.writeFileSync(`${workingDirectory}/.repo/HEAD`); // (4)
  fs.mkdirSync(`${workingDirectory}/.repo/objects`); // (4)
}

(1) вземете всички файлове от текущата работна директория
(2) изградете индексния файл с помощта на файлове stat() SHA-1 хеш за всеки файл
(3) запишете папка на хранилище под.repo
(4) Вътре в хранилището запишете HEAD файл и objects папка

status.mjs

// imports excluded, see linked repo for details
const status = () => {
  const indexData = getIndexData(); // (1)

  const notStaged = [];
  const notComitted = [];
  const updatedIndexData = Object.keys(indexData).reduce((acc, curr) => { // (2)
    const hash = hashFileStats(curr); // (2a)
    if (hash !== indexData[curr].cwd) { // (2b)
      acc[curr] = {
        cwd: hash,
        staging: indexData[curr].staging,
        repository: indexData[curr].repository,
      };
      notStaged.push(curr);
    } else {
      if (indexData[curr].cwd !== indexData[curr].staging) {
        notStaged.push(curr); // (2c)
      } else if (indexData[curr].staging !== indexData[curr].repository) {
        notComitted.push(curr); // (2d)
      }
      acc[curr] = indexData[curr];
    }

    return acc;
  }, {});

  updateIndex(updatedIndexData); // (3)

  console.log("\nChanged locally but not staged:");
  notStaged.map((message) => console.log(`- ${message}`)); // (4)
  console.log("\nStaged but not comitted:");
  notComitted.map((message) => console.log(`- ${message}`)); // (5)
}

(1) вземете данните за индекса
(2) за всеки елемент в данните за индекса
(2a) вземете файлове stat() SHA-1 хеш
(2b) ако не съвпада с текущата работна директория съхранен хеш на файл, маркирайте като променен, но не на етап
(2c) ако съвпада по-горе, но не съвпада на етап, флаг като не на етап
(2d) ако съответства на етап, но не и хранилище, флаг като некомитиран< br /> (3) актуализирайте индексния файл
(4) изведете локални промени, които не са поетапни
(5) изведете поетапни промени, които не са извършени

add.mjs

// imports excluded, see linked repo for details
const add = () => {
  const workingDirectory = workingDir();

  const files = process.argv.slice(2); // (1)

  const indexData = getIndexData();

  console.log("[add] - write blob objects");
  const updatedFiles = files.map((file) => {
    const blobHash = hashBlobContentsInFile(file); // (2)
    const blobDir = blobHash.substring(0, 2);
    const blobObject = blobHash.substring(2);

    // TODO - check exists first - for re-adding file with earlier contents
    fs.mkdirSync(`${workingDirectory}/.repo/objects/${blobDir}`);

    const blobCompressed = compressBlobContentsInFile(file); // (3)
    fs.writeFileSync(
      `${workingDirectory}/.repo/objects/${blobDir}/${blobObject}`,
      blobCompressed
    );

    const hash = hashFileStats(file); // (4)

    return {
      file,
      hash,
    };
  });

  const updatedIndexData = Object.keys(indexData).reduce((acc, curr) => { // (5)
    if (!updatedFiles.find((item) => item.file === curr)) { // (5a)
      acc[curr] = {
        cwd: indexData[curr].cwd,
        staging: indexData[curr].staging,
        repository: indexData[curr].repository,
      };
      return acc;
    }
    acc[curr] = {
      cwd: indexData[curr].cwd,
      staging: updatedFiles.find((item) => item.file === curr).hash, // (5b)
      repository: indexData[curr].repository,
    };
    return acc;
  }, {});

  updateIndex(updatedIndexData); // (6)
}

(1) изрично дайте файлове, напр. one.txt и two/three.txt
(2) за всеки файл, вземете съдържанието в SHA-1 и използвайте за име на директория и име на файл
(3) вземете DEFLATED стойност и използвайте за съдържание
(4) вземете SHA -1 стойност за файлове stat()
(5) Актуализиране на индекса
(5a) Ако файлът не е бил докоснат, само прокси стойности
(5b) Ако файлът е бил докоснат, актуализирайте етапа за файла< br /> (6) Замяна на стари индексни данни с нови индексни данни

commit.mjs

// imports excluded, see linked repo for details

// array of dir (name) and files (children), ordered by bottom-up
const _buildTree = (paths) => {
  return paths.reduce(
    (parent, path, key) => {
      path.split("/").reduce((r, name, i, { length }) => {
        if (!r.children) {
          r.children = [];
        }
        let temp = r.children.find((q) => q.name === name);
        if (!temp) {
          temp = { name };
          if (i + 1 === length) {
            temp.type = "blob";
            temp.hash = hashBlobContentsInFile(path);
          } else {
            temp.type = "tree";
          }
          r.children.push(temp);
        }
        return temp;
      }, parent);

      return parent;
    },
    { children: [] }
  ).children;
};

const commit = () => {
  const workingDirectory = workingDir();
  const indexData = getIndexData();
  // TODO - if comitted already then dont recreate tree?? PROB chek first
  const paths = Object.keys(indexData).filter( // (1)
    (item) => indexData[item].staging || indexData[item].repository
  );

  const rootTrees = _buildTree(paths); // (2)

  const flattenedTrees = rootTrees.reverse().reduce((acc, curr, key) => { // (3)
    if (curr.children) {
      const hash = createTreeObject(curr.children); // (3a)
      const clone = Object.assign({}, curr);
      delete clone.children;
      clone.hash = hash;
      acc.push(curr.children); // (3b)
      acc.push([clone]);
    } else {
      acc[key].push(curr); (3c)
    }
    return acc;
  }, []);

  const rootTree = flattenedTrees.reverse()[0];
  const treeForCommit = createTreeObject(rootTree); // (4)

  const parent = getParentCommit();

  const commit = { // (5)
    tree: treeForCommit,
    parent: parent === "undefined" ? null : parent,
    author: "CRAIG", // hardcoded for now
    committor: "CRAIG",
    message: "Initial commit",
  };

  const commitHash = createCommitObject(commit); // (6)

  const updatedIndexData = Object.keys(indexData).reduce((acc, curr) => { // (7)
    const { cwd, staging, repository } = indexData[curr];
    let updatedRepo = repository;
    if (staging !== repository) { (7a)
      updatedRepo = staging;
    }
    acc[curr] = {
      cwd: indexData[curr].cwd,
      staging: indexData[curr].staging,
      repository: updatedRepo,
    };
    return acc;
  }, {});
  updateIndex(updatedIndexData);

  fs.writeFileSync(`${workingDirectory}/.repo/HEAD`, commitHash); // (8)
}

(1) Грабване на файлове от файлове за ангажиране
(2) Изграждане на дърво за файлове в етапна или ангажирана, изключена работна директория само
(3) Повторение на корена на елементите „дърво“ в сплескан масив от дървета
(3a) Ако дърво, създайте дърво за деца
(3b) След това добавете деца към сплескано дърво
(3c) Ако не е дърво, натиснете с предишното дърво
(4) Създайте дърво обект за корен
(5) Създаване на обект за ангажиране, като се използва родителски ангажимент, ако съществува, и дървовиден хеш
(6) От обект на ангажимент вземете хеш на ангажимент
(7) Актуализиране на индексен файл
(7a) Ако етапният хеш не съответства на хеша на хранилището, актуализирайте. Съществуващ файл е актуализиран.
(8) Актуализирайте HEAD с последния комит

utils.mjs

Включих помощния файл, но се надявам, че имената са доста разбираеми.

Най-големият е createTreeObject и createCommitObject. И двете от които:

  1. Обработка на дадено съдържание в хеш
  2. Компресиране на дадено съдържание
  3. Записва компресирано съдържание в съответната директория и файл — Първите 2 знака от хеша стават директория, а останалите име на файл.
import fs from "fs";
import crypto from "crypto";
import zlib from "zlib";

export const workingDir = () => {
  const cwd = process.cwd();
  return cwd + "/src";
};

export const sha1 = (object) => {
  const string = JSON.stringify(object);
  return crypto.createHash("sha1").update(string).digest("hex");
};

const getFilePath = (file) => {
  const workingDirectory = workingDir();
  return `${workingDirectory}/${file}`;
};
const getContentsInFile = (file) => {
  const path = getFilePath(file);
  return fs.readFileSync(path, { encoding: "utf-8" });
};

export const compressBlobContentsInFile = (file) => {
  const contents = getContentsInFile(file);
  return zlib.deflateSync(contents);
};

// always same based on contents
export const hashBlobContentsInFile = (file) => {
  const contents = getContentsInFile(file);
  return sha1({ type: "blob", contents });
};

// different based on midified time
// remove atime + atimeMs which are different each stat() call
export const hashFileStats = (file) => {
  const path = getFilePath(file);
  const contents = fs.statSync(path);
  delete contents["atime"];
  delete contents["atimeMs"];
  return sha1(contents);
};

export const getIndexData = () => {
  const workingDirectory = workingDir();
  return JSON.parse(
    fs.readFileSync(`${workingDirectory}/.repo/index`, { encoding: "utf-8" })
  );
};

export const updateIndex = (indexData) => {
  const workingDirectory = workingDir();
  fs.writeFileSync(
    `${workingDirectory}/.repo/index`,
    JSON.stringify(indexData)
  );
};

// hash contents, create tree, return hash
export const createTreeObject = (contents) => {
  const contentsClone = Object.assign([], contents);
  const flatContents = contentsClone.map((item) => {
    delete item.children; // dont need full children depth
    return item;
  });
  const workingDirectory = workingDir();
  const stringContents = JSON.stringify(flatContents);
  const treeHash = sha1(stringContents);
  const treeDir = treeHash.substring(0, 2);
  const treeObject = treeHash.substring(2);
  const treeCompressed = zlib.deflateSync(stringContents);
  // create tree object
  fs.mkdirSync(`${workingDirectory}/.repo/objects/${treeDir}`);
  fs.writeFileSync(
    `${workingDirectory}/.repo/objects/${treeDir}/${treeObject}`,
    treeCompressed
  );
  return treeHash;
};

export const createCommitObject = (contents) => {
  const workingDirectory = workingDir();
  const stringContents = JSON.stringify(contents);
  const commitHash = sha1(stringContents);
  const commitDir = commitHash.substring(0, 2);
  const commitObject = commitHash.substring(2);
  const commitCompressed = zlib.deflateSync(stringContents);
  // create commit object
  fs.mkdirSync(`${workingDirectory}/.repo/objects/${commitDir}`);
  fs.writeFileSync(
    `${workingDirectory}/.repo/objects/${commitDir}/${commitObject}`,
    commitCompressed
  );
  return commitHash;
};

export const getParentCommit = () => {
  const workingDirectory = workingDir();
  return fs.readFileSync(`${workingDirectory}/.repo/HEAD`, {
    encoding: "utf-8",
  });
};

Тестването работи

Написах малък проект за тестване на контрола на версиите. 3 файла всеки с ред текст, 2 от които в папка.

Горните скриптове се намират в bin/

Работна директория/приложение е намерено в src/

  • one.txt
  • two/three.txt
  • two/four.txt

След това написах някои тестове за интеграция ( test/index.integration.spec.js), за да проследя какво се случва с нашето хранилище за дадена команда, стъпките (и резултатите) са:

  1. repo:init =› създаден ИНДЕКС с текущите файлове на работната директория stat() хеш
  2. repo:status =› флаг 3 нови локални промени, които не са поставени (тези по-горе)
  3. repo:add one.txt two/three.txt =>
  • трябва да създава blob обекти в директории с дължина 2 символа, с компресирано съдържание
  • трябва да актуализира ИНДЕКС, да премести елементи в поетапно

4. repo:status =› маркирайте 1 нови локални промени, които не са организирани, и 2 промени, които не са ангажирани

5. Ръчно актуализирайте one.txt

6. repo:status =› подобно на предишното, но сега флагове one.txt като локално променени

7. repo:add one.txt =› повторно добавяне на актуализиран файл one.txt трябва да актуализира blob обект

8. repo:status =› повторно добавеният файл трябва да се показва със стария добавен файл

9. repo:add two/four.txt =› добавете two/four.txt така че 2 елемента в дървовиден обект

10. repo:commit =› трябва да създаде дърво и да извърши обект и да актуализира HEAD и INDEX

Какво сме пропуснали?

Както споменахме, има много допълнителни части към реалния контрол на версиите на Git, които сме пропуснали от нашата библиотека. Някои от тях са:

  • Сравняване на части от промяната (различие)
  • Пакет файлове
  • Делти
  • Клонове
  • Етикети
  • Сливане

Благодаря много за четенето, научих много за Git от това изследване и се надявам, че е било полезно за вас. Можете да намерите хранилището за целия този код тук.

Благодаря, Крейг 😃