Привет, Хабр! Я Паша, разработчик Gramax — Open Source платформы для управления документацией в подходе Docs as Code.

В прошлой статье я рассказывал о том, как мы переводили наше приложение с Isomorphic Git на libgit2, как засунули его в веб-версию приложения при помощи Emscripten и с какими трудностями столкнулись в процессе.

На этот раз — как мы оптимизировали хранение файлов ресурсов каталога (в большинстве случаев, изображений) при помощи Git LFS, тем самым ускорив его клонирование и синхронизацию.

В чём проблема?

Документация — это не только текст, но еще и разные ресурсы: изображения, диаграммы, вложенные файлы. Иногда это набор из нескольких лёгких картинок, а иногда — тонны pdf-файлов. Хранить их в истории git — идея плохая, ведь из-за них репозиторий быстро начинает набирать вес.

Это негативно влияет на производительность приложения и, соответсвенно, на удобство работы с ним: начиная с клонирования, которое занимает значительное время из-за множества больших бинарный файлов в истории и заканчивая замедлением практически всех git-операций над репозиторием.

Конечно, можно попросить пользователей не добавлять большие файлы, но кто послушает? Лучше решить проблему более «фундаментально».

Что такое Git LFS?

Git Large File Storage (LFS) replaces large files such as audio samples, videos, datasets, and graphics with text pointers inside Git, while storing the file contents on a remote server like GitHub.com or GitHub Enterprise.

Вероятно, ни для кого не секрет, что Git LFS — это расширение для git, которое позволяет хранить блобы в отдельном S3 хранилище. Согласно спецификации, в git object database (ODB) хранится указатель, состоящий из размера файла и sha256-хеша от его содержания содержания, по которому этот самый файл можно скачать.

В .gitattributes можно указать, какие именно файлы следует обрабатывать как LFS-объекты. При добавлении в ODB, они будут превращены в указатель, а их контент записан в .git/lfs/objects/.

*.png filter=lfs

Это работает благодаря механизму фильтров, которые git вызывает при выполнении тех или иных операций.

При выполнении git push или git fetch, git-lfs автоматически загружает необходимые файлы, а при чекауте — создаёт хардлинки на объекты из .git/lfs/objects/.

Наш подход

Использовать оригиналное расширение в нашем случае не получилилось по нескольким причинам. Одна из которых — веб-приложение, которое должно уметь выполнять все те же функции, что и десктопное.

Библиотека libgit2, которую мы используем, имеет поддержку фильтров, хотя и реализованную несколько иначе. И если бы не веб-приложение, где libgit2 скомпилирован в wasm-модуль и работает в браузере, можно было бы попытаться скрестить git-lfs и libgit2. Не хочется возиться с компиляцией cli-тулзы на Go в WebAssembly — вероятнее всего (поскольку не пробовали), на этом этапе уже возникла бы гора проблем и ничего бы не получилось.

Поэтому было решено взять спецификацию Git LFS и просто реализовать минимально необходимый функционал:

  1. Создание и парсинг указателей на LFS-объекты

  2. Smudge и Clean фильтры: замена блоба на указатель при добавлении в индекс и наоборот при чекауте

  3. Загрузка и выгрузка LFS-объектов согласно API-спецификации

А где биндинги?

Очевидно, что в приложении мы используем не только лишь голый libgit2. Поверх него мы используем крейт git2-rs. Поэтому, большая часть кода, связанная с Git, написана на Rust — так было удобнее как с точки зрения компиляции в WebAssembly, так и с точки зрения уже существующего кода десктопного приложения на Tauri.

Как уже было сказано выше, Git LFS базируется на двух фильтрах — Clean и Smudge. Они позволяют модифицировать контент объекта при добавлении в ODB и при чекауте. То есть, превращать указатель в файл, а файл в указатель.

В целом, до этого с git2-rs особых проблем не наблюдалось, но тут бац! На этот раз оказалось, что биндингов для фильтров libgit2 нет, за всё это время их никто не реализовал. В разделе Issues на тот момент уже было несколько достаточно старых фича-реквестов:

Их статус оказался недостаточно фиолетовым для радости (и на момент написания статьи они всё ещё в Open). Поэтому пришлось писать самим.

У libgit2 хоть и не всегда подробная, но достаточно прозрачная документация, поэтому сложностей с реализацией не ожидалось. Однако можно выделить несколько интересных моментов, о которых я повествую далее.

Инициализация фильтра

Для создания фильтра нужно инициализировать структуру git_filter, заполнить её указателями на коллбеки и зарегистрировать при помощи git_filter_register.

Уже на этом этапе возникли первые проблемы. Изначально я наивно полагал, что аллоцировать git_filter в куче не обязательно и libgit2 сам скопирует структуру в нужное ему место при добавлении в список фильтров. Пришлось добавить Box после некоторого времени дебага сегфолтов и просмотра кода стандартного crlf-фильтра.

#[repr(C)]
pub struct FilterRaw<'f> {
    raw: raw::git_filter,
    initialize: Option<Box<dyn FilterInitialize<'f> + 'f>>,
    shutdown: Option<Box<dyn FilterShutdown<'f> + 'f>>,
    check: Option<Box<dyn FilterCheck<'f> + 'f>>,
    apply: Option<Box<dyn FilterApply<'f> + 'f>>,
    cleanup: Option<Box<dyn FilterCleanup<'f> + 'f>>,
}

pub struct Filter<'f, P> {
    inner: *mut FilterRaw<'f>,
    _phantom: std::marker::PhantomData<&'f P>,
}

impl<'f, P> Filter<'f, P> {
    pub fn new() -> Result<Self, Error> {
        crate::init();

        let inner = Box::new(FilterRaw { // <- Box
            raw: unsafe { mem::zeroed() }, // git_filter
            initialize: None,
            shutdown: None,
            check: None,
            apply: None,
            cleanup: None,
        });

        let filter = Self {
            inner: Box::into_raw(inner), // <- Box
            _phantom: std::marker::PhantomData,
        };

        unsafe {
            try_call!(raw::git_filter_init(
                filter.inner as *mut raw::git_filter,
                raw::GIT_FILTER_VERSION
            ));
        }

        Ok(filter)
    }


    pub fn register(mut self, name: &str, priority: i32) -> Result<(), Error> {
        unsafe {
            try_call!(raw::git_filter_register(
                name.into_c_string()?.into_raw(),
                self.inner as *mut raw::git_filter,
                priority
            ));
        }

        Ok(())
    }
}

Смотря на этот код спустя время, он всё больше походит на так называемую «проблему с навыком» :/

Коллбеки фильтра

Теперь git_filter нужно наполнить коллбека��и — указателями на функции, которые будут вызываться при том или ином этапе его работы.

Пихание в C’шную структуру ссылок на Rust-функции ни к чему хорошему не приведёт. Для борьбы с этим и нужна структура FilterRaw — её объявление можно увидеть в сниппете выше. Она обозначена как #[repr(C)] и в первом поле хранит структуру git_filter — два этих фактора обеспечивают правильный лейаут структуры и libgit2 сможет с ней работать точно так же, как если бы это был обычный git_filter.

В следующих полях находятся ссылки на Rust-функции. Алгоритм работы следующий: получаем коллбек в extern "C"-функции, кастим git_filter до FilterRaw и вызываем Rust-функцию, предварительно конвертировав параметры.

Однако, для того, чтобы сохранить типизацию Payload в коллбеках, пришлось обернуть их в структуру FilterCallback и реализовать для неё специальный трейт. Впрочем, позже выяснилось, что использовать Payload необходимости не было и было лишним усложнением. Ну да ладно.

struct FilterCallback<'a, P, F> {
    callback: F,
    _phantom: std::marker::PhantomData<&'a P>,
}

trait FilterApply<'a> {
    unsafe fn call(
        &self,
        filter: FilterInternal<'a>,
        payload: *mut *mut c_void,
        to: *mut raw::git_buf,
        from: *const raw::git_buf,
        src: *const raw::git_filter_source,
    ) -> Result<bool, Error>;

pub fn on_apply<F>(&mut self, callback: F) → &mut Self
where
    F: Fn(
            Filter<'f, P>,
            FilterPayload<P>,
            FilterBuf,
            FilterBuf,
            FilterSource,
        ) → Result<bool, Error>
        + 'f,
{
    if let Some(inner) = unsafe { self.inner.as_mut() } {
        inner.raw.stream = Some(on_stream);
        inner.apply = Some(Box::new(FilterCallback::<'f, P, F>::new(callback)));
    }
    self
}


impl<'a, P, F> FilterCheck<'a> for FilterCallback<'a, P, F>
where
    F: Fn(Filter<'a, P>, FilterPayload<P>, FilterSource, bool) -> Result<bool, Error> + 'a,
{
    unsafe fn call(
        &self,
        filter: FilterInternal<'a>,
        payload: *mut *mut c_void,
        src: *const raw::git_filter_source,
        attr_values: *const *const c_char,
    ) -> Result<bool, Error> {
        (self.callback)(
            filter.cast::<P>(),
            FilterPayload::<P>::from_raw(payload),
            FilterSource::from_raw(src as *mut _),
            if attr_values.is_null() { false } else { true },
        )
    }
}

extern "C" fn on_apply(
    filter: *mut raw::git_filter,
    payload: *mut *mut libc::c_void,
    to: *mut raw::git_buf,
    from: *const raw::git_buf,
    src: *const raw::git_filter_source,
) -> i32 {
    let ok = panic::wrap(|| unsafe {
        let filter = FilterInternal::from_raw(filter as *mut _);

        if let Some(ref apply) = (*filter.inner).apply {
            apply.call(filter, payload, to, from, src)
        } else {
            Ok(true)
        }
    });

    match ok {
        Some(Ok(true)) => 0,
        Some(Ok(false)) => raw::GIT_PASSTHROUGH,
        Some(Err(e)) => e.raw_code(),
        None => -1,
    }
}

С остальными коллбеками алгоритм действий аналогичный. Показывать не буду, слишком много неинтересного кода.

FilterBuf — обёртка над git_buf

В apply libgit2 передаёт два буфера — from с оригинальным контентом объекта и to, куда фильтр может записать изменённый контент. Причём изначально to — null и его аллокация под ответственностью фильтра. Но деаллоцирует его, понятное дело, сам libgit2.

Это создаёт некоторые трудности при написании биндингов. Хочется работать не с указателями, а с Vec, ведь так? Это породило такое решение — указатели на начало массива у git_buf и вектора data одинаковые. Вектор аллоцируется по запросу, а при завершении коллбека указатель на его данные записывается в git_buf при помощи метода sync.

pub struct FilterBuf {
    raw: *mut raw::git_buf,
    data: Option<ManuallyDrop<Vec<u8>>>,
}

impl Binding for FilterBuf {
    type Raw = *mut raw::git_buf;

    unsafe fn from_raw(raw: *mut raw::git_buf) -> FilterBuf {
        let data = if (*raw).ptr.is_null() {
            None
        } else {
            Some(ManuallyDrop::new(Vec::from_raw_parts(
                (*raw).ptr as *mut u8,
                (*raw).size,
                (*raw).size,
            )))
        };

        FilterBuf { raw, data }
    }

    fn raw(&self) -> Self::Raw {
        self.raw
    }
}

impl FilterBuf {
    pub fn as_bytes(&self) -> &[u8] {
        self.data
            .as_ref()
            .map(|data| data.as_slice())
            .unwrap_or_default()
    }

    pub fn as_allocated_vec(&mut self) -> &mut Vec<u8> {
        if self.data.is_none() {
            self.data = Some(ManuallyDrop::new(Vec::new()));
        }

        self.data.as_mut().unwrap()
    }

    pub fn sync(&mut self) {
        if let Some(data) = &self.data {
            unsafe {
                (*self.raw).ptr = data.as_ptr() as *mut i8;
                (*self.raw).size = data.len() as usize;
            }
        }
    }
} 

Храним LFS-объекты по спецификации

Теперь у нас есть все необходимые биндинги — осталось только написать саму логику.

Для создания фильтра используется LfsBuilder, в котором настраиваются разные параметры и атрибут, на который он будет реагировать. Его регистрация происходит в методе install. Проще говоря, типичный билдер.

Некоторый код опущен для краткости.

pub struct Lfs<'a> {
	config: &'a LfsBuilder,
	repo: FilterRepository,
}

#[derive(Default, Clone, Debug)]
pub struct LfsBuilder {
	//
}

impl LfsBuilder {
	pub fn install(self, attributes: &str) -> Result<(), Error> {
		let mut filter = Filter::<()>::new()?; // В качестве Payload в итоге всё равно используется ()

		let config = Arc::new(self);

		let on_check_config = Arc::clone(&config);
		let on_apply_config = Arc::clone(&config);

		filter
			.on_init(|_| Ok(()))
			.attributes(attributes)?
			.on_check(move |_, _, src, attrs_set| {
				let lfs = Lfs::new(src.repo(), &on_check_config);
				// do smth
			})
			.on_apply(move |_, _, mut to, from, src| {
				let lfs = Lfs::new(src.repo(), &on_apply_config);

				match src.mode() {
					FilterMode::Clean => match lfs.clean(from.as_bytes(), &mut to) {
						Ok(applied) => Ok(applied),
						Err(e) => {
							error!(path = %src.path().unwrap().display(), "error cleaning lfs: {}", crate::report_error(&e));
							Err(git2::Error::from_str(&crate::report_error(&e)))
						}
					},
					FilterMode::Smudge => match lfs.smudge(from.as_bytes(), &mut to) {
						Ok(applied) => Ok(applied),
						Err(e) => {
							error!(path = %src.path().unwrap().display(), "error smudging lfs: {}", crate::report_error(&e));
							Err(git2::Error::from_str(&crate::report_error(&e)))
						}
					},
				}
			});

		filter.register("lfs", 1)?;
		Ok(())
	}

	//
}

Важная деталь: libgit2 кладёт зарегистрированный фильтр в статический список где-то в своих внутренностях. И даже при открытии нескольких репозиториев, фильтр будет оставаться одним и тем же и продолжать работать. Регистрировать его необходимо лишь один раз за всё время жизни программы.

pub(crate) fn lfs_init_once() {
	static ONCE: std::sync::Once = std::sync::Once::new();
	ONCE.call_once(|| {
		git2_lfs::LfsBuilder::default().install("filter=lfs").unwrap();
	});
}

Сама логика фильтров оказалась достаточно простой для реализации. В целом, и алгоритм то не сложный — взять буфер объекта, пройтись по нему и взять хеш, сгенерировать указатель, а сам буфер записать в файл в специальной директории — .git/lfs/objects.

В коде это выглядит так. При добавлении объекта в ODB вызывается этот метод:

pub fn clean(self, input: &[u8], out: &mut FilterBuf) -> Result<bool, Error> {
	if input.len() == 0 {
		info!("clean: passing through zero-sized object");
		return Ok(false);
	}

	let pointer = Pointer::from_blob_bytes(input)?;
	self.store_object_if_not_exists(&pointer, input)?;
	pointer.write_pointer(&mut out.as_allocated_vec())?;

	Ok(true)
}

fn store_object_if_not_exists(self, pointer: &Pointer, bytes: &[u8]) -> Result<(), Error> {
	let path = self.object_dir().join(pointer.path());

	if path.exists() {
		debug!(path = %path.display(), "object already exists, skipping");
		return Ok(());
	}

	pointer.write_blob_bytes(&self.object_dir(), bytes)?;
	Ok(())
}

А при его чекауте во�� этот:

pub fn smudge(self, input: &[u8], out: &mut FilterBuf) -> Result<bool, Error> {
	if input.len() == 0 {
		info!("smudge: passing through zero-sized object");
		return Ok(false);
	}

	let Some(pointer) = Pointer::from_str_short(input) else {
		debug!("not a lfs pointer, passing through");
		return Ok(false);
	};

	self.load_object(&pointer, out)
}


fn load_object(self, pointer: &Pointer, out: &mut FilterBuf) -> Result<bool, Error> {
		let object_dir = self.object_dir();
		let path = self.object_dir().join(pointer.path());

		if !path.exists() {
			debug!(path = %path.strip_prefix(&object_dir).unwrap_or(&path).display(), "object not found, skipping");
			return Ok(false);
		}

		debug!(path = %path.strip_prefix(&object_dir).unwrap_or(&path).display(), "reading lfs object");

		let file = std::fs::File::open(&path)?;
		let mut reader = BufReader::new(file);
		std::io::copy(&mut reader, &mut out.as_allocated_vec())?;
		Ok(true)
	}

При выполнении Smudge-фильтра (т.е. при чекауте) имеет смысл проверять файл на его длину перед попыткой распарсить его как указатель, ведь в большинстве случаев это всё же текстовый файл.

pub const POINTER_ROUGH_LEN: std::ops::Range<usize> = 120..220;

impl Pointer {
	pub fn from_str_short(bytes: &[u8]) -> Option<Self> {
		// Берём слайс <= 220b, только затем парсим его как utf8
		match bytes.get(..(bytes.len().min(POINTER_ROUGH_LEN.end))).map(str::from_utf8) { 
			Some(Ok(text)) => {
				let pointer = Pointer::from_str(text); // Сам указатель парсится в std::str::FromStr
				if let Err(ref err) = pointer {
					trace!("Pointer::from_str_short: {:?}", err);
				}
				pointer.ok()
			}
			_ => None,
		}
	}
}

В среднем длина указателя ~140 байт, а самое важное поле — oid, которое умещается ровно в 120 байт. Всё что меньше — точно не является валидным указателем на файл. А любое разумное значение size уместиться в оставшуюся сотню.

В спецификации указано, что указатели должны быть размером <1024 байта. Но крайне сомневаюсь, что в реальном сценарии встретиться настолько огромный указатель.

version https://git-lfs.github.com/spec/v1
oid sha256:9da9af16cc31973942cf96ea0634b2f9f50c4c123154bab8a0e6523e14f74a7b
size 202261

Загрузка и выгрузка LFS-объектов

Перед тем как скачать или загрузить файл, клиент выполняет Batch запрос и узнаёт у сервера, по каким эндпоинтам и с какими заголовками он может получить к ним доступ.

The Batch API is used to request the ability to transfer LFS objects with the LFS server. The Batch URL is built by adding /objects/batch to the LFS server URL.

Логику взаимодействия с эндпоинтом просто необходимо сделать модульной. Всё по той же причине — веб-приложение. Если в десктопе руки развязаны и запросы можно отправлять каким угодно способом, то в WebAssembly так не получится.

На самом деле, эта «модульность» также относится к возможности параллелить запросы. Это нужно, важно и без этого никуда. Сделать это в десктопе — легко, в веб-приложении — совсем нет.

pub struct LfsClient<'a, C: Send + Sync> {
	repo: &'a git2::Repository,
	client: C,
	on_progress: Option<Box<OnProgress<'a>>>,
}

#[async_trait]
pub trait LfsRemote: Send + Sync {
	async fn batch(&self, req: BatchRequest) -> Result<BatchResponse, RemoteError>;
	async fn download(&self, action: &ObjectAction, to: &mut Write) -> Result<Pointer, RemoteError>;
	async fn upload(&self, action: &ObjectAction, blob: &[u8]) -> Result<(), RemoteError>;
	async fn verify(&self, action: &ObjectAction, pointer: &Pointer) -> Result<(), RemoteError>;
}

Большая часть логики LfsClient для скачивания или загрузки LFS-объектов реализована в LfsClient, но сами запросы выполняются через client (клиент в клиенте, лол). Для десктопного приложения он реализован при помощи крейта reqwest, а в WebAssembly поинтереснее — там пришлось отправлять запросы через JS.

WebAssembly: Б��ндинги для JS-функций и куча unsafe-кода

Emscripten, который мы используем для компиляции кода в WebAssembly, умеет создавать специальные функции для вызова JS при помощи макросов EM_JS и EM_JS_ASYNC. Только они для C-кода, не Rust.

Поэтому пришлось написать пару небольших C’шных функций. Ну и ладно. Кода там совсем немного, и большая его часть всё равно на JS. Прокладка из C используется скорее как клей между Rust-кодом и JS. В этом сниппете только пара функций, остальные выполнены в похожей манере.

EM_JS(int, _em_lfs_http_init, (const char *url, size_t buf_size, const char *method, const char *access_token), {
  const urlString = UTF8ToString(url);
  const methodString = UTF8ToString(method);
  const accessTokenString = UTF8ToString(access_token);
  return Module.em_lfs_http_init(urlString, buf_size, methodString, accessTokenString);
});

EM_ASYNC_JS(int, _em_lfs_http_send, (int connId, const char *body, size_t len), {
  if (len > 0) {
    const bytes = Module.HEAPU8.buffer.slice(body, body + len); // Module.HEAPU8 — "куча" wasm-модуля
    const sharedView = new Uint8Array(bytes);
    const regularBuffer = new ArrayBuffer(sharedView.length);
    const regularView = new Uint8Array(regularBuffer);
    regularView.set(sharedView);

    return await Module.em_lfs_http_send(connId, regularView);
  }
  
  const bodyString = body > 0 ? UTF8ToString(body) : null;
  return await Module.em_lfs_http_send(connId, bodyString);
});

int em_lfs_http_init(const char *url, size_t buf_size, const char *method, const char *access_token) {
  return _em_lfs_http_init(url, buf_size, method, access_token);
}

int em_lfs_http_send(int connId, const char *body, size_t len) {
  return _em_lfs_http_send(connId, body, len);
}

Этот кусок C-кода скомпилируется в билдскрипте при помощи cc-rs и слинкуется с WebAssembly-модулем, таким образом, их будет возможно вызывать из Rust.

extern "C" {
	fn em_lfs_http_init(url: *const c_char, buf_size: usize, method: *const c_char, access_token: *const c_char) -> i32;
	fn em_lfs_http_set_header(id: i32, header: *const c_char, value: *const c_char);
	fn em_lfs_http_send(id: i32, body: *const c_char, len: usize) -> i32;
	fn em_http_read(id: i32, ptr: *const u8, len: usize) -> i32;
	fn em_http_free(id: i32);
}

Поэтому просто реализовываем трейт LfsRemote и радуемся жизни.

#[async_trait::async_trait]
impl git2_lfs::remote::LfsRemote for WasmLfsClient {
	// ...другие методы

	async fn download(&self, action: &ObjectAction, to: &mut Write) -> Result<Pointer, RemoteError> {
		const BUF_SIZE: usize = 1024 * 1024;

		let url_cstr = std::ffi::CString::from_str(&action.href).unwrap();

		let access_token_cstr = std::ffi::CString::new(self.access_token.as_deref().unwrap_or("")).unwrap();
		let method_cstr = std::ffi::CString::new("GET").unwrap();

		let conn_id = unsafe { em_lfs_http_init(url_cstr.as_ptr(), BUF_SIZE, method_cstr.as_ptr(), access_token_cstr.as_ptr()) } as i32;

		if conn_id <= 0 {
			let err = std::io::Error::other("failed to initialize http connection");
			return Err(RemoteError::Custom(Box::new(err)));
		}

		for (header, value) in action.header.iter() {
			let header_cstr = std::ffi::CString::from_str(header).unwrap();
			let value_cstr = std::ffi::CString::from_str(value).unwrap();
			unsafe { em_lfs_http_set_header(conn_id, header_cstr.as_ptr(), value_cstr.as_ptr()) };
		}

		unsafe {
			em_lfs_http_set_header(
				conn_id,
				std::ffi::CString::new("User-Agent").unwrap().as_ptr(),
				std::ffi::CString::new(USER_AGENT).unwrap().as_ptr(),
			)
		};

		unsafe { em_lfs_http_send(conn_id, std::ptr::null(), 0) };
		handle_http_error(conn_id)?;

		let mut buf = [0u8; BUF_SIZE];
		let mut size = 0;
		let mut read = unsafe { em_http_read(conn_id, buf.as_mut_ptr(), buf.len()) };

		let mut checksum = Sha256::new();

		while read > 0 {
			size += read;
			checksum.update(&buf[..read as usize]);
			to.write(&buf[..read as usize]).map_err(|e| RemoteError::Io(std::io::Error::other(e)))?;
			read = unsafe { em_http_read(conn_id, buf.as_mut_ptr(), buf.len()) };
			handle_http_error(read)?;
		}

		let checksum = checksum.finalize();

		self.free_current_conn();

		Ok(Pointer::from_parts(&checksum, size as usize))
	}
}

Заставить эти запросы выполняться параллельно несколько сложнее, чем просто использовать tokio в десктопном приложении. Полноценный асинхронный рантайм в WebAssembly тащить не хочется. Не уверен, что вообще получится.

Поэтому пока что это было оставлено как есть, а async заглушен при помощи block_on:

futures::executor::block_on(lfs_remote.push(objects)).map_err(git2_lfs::Error::from)?;

Также считаю важным упомянуть про всё те же пресловутые проблемы с CORS. Ни один LFS-сервер не проставляет нужные заголовки по типу Access-Control-Allow-Origin, Access-Control-Allow-Headers и так далее, поэтому вновь приходится отправлять запросы через прокси, единственное предназначение которого — проставлять CORS заголовки.

Какие объекты скачивать нужно, а какие нет?

По умолчанию Git LFS не скачивает всё подряд, а проходится по дереву указанного референса, смотрит каких объектов недостаёт и докачивает их.

Нам нужно точно также — пройтись по дереву, взять блобы, понять какие из них являются LFS-указателями и посмотреть, существует ли соответствующий файл в .git/lfs/objects. Всё чего нет — докачать.

fn find_tree_missing_lfs_objects(&self, tree: &git2::Tree<'_>) → Result<Vec<Pointer>, Error> {
	let mut missing = HashSet::<Pointer>::new();

	tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
		let Some(ObjectType::Blob) = entry.kind() else {
			return TreeWalkResult::Ok;
		};

		let oid = entry.id();
		let Ok(blob) = self.find_blob(oid) else {
			warn!("blob '{}' ({}{}) not found during traversing tree {}", oid, dir, entry.name().unwrap_or_default(), tree.id());
			return TreeWalkResult::Ok;
		};

		match Pointer::from_str_short(blob.content()) {
			Some(pointer) if !self.path().join("lfs/objects").join(pointer.path()).exists() => {
				debug!("blob '{}' ({}{}) is lfs pointer but object is missing", oid, dir, entry.name().unwrap_or_default());
				missing.insert(pointer);
			}
			_ => (),
		}

		TreeWalkResult::Ok
	})?;

	Ok(missing.into_iter().collect())
}

Этот метод будет вызываться при клонировании и чекауте, который срабатывает при синхронизации каталога в приложении. Этого достаточно, что бы у пользователя всегда были скачаны все нужные файлы.

Промежуточные итоги

Всё, что было описано выше — относится к базовой логике реализации, которую мы решили вынести в отдельный репозиторий gram-ax/git2-lfs. Возможно, дело когда-нибудь дойдёт до PR с биндингами в git2-rs и тогда от git-зависимости получится избавиться.

Пропуская рассказ про написание скучного JS-кода для интеграции нового функционала в Gramax, в который, в большинстве своём пришлось лезть ради прокидывания пары параметров, на текущий момент времени мы имеем следующее:

  1. Приложение умеет парсить LFS-указатели, читать и записывать указанные в .gitattributes блобы в .git/lfs/objects

  2. После клонирования репозитория и перед чекаутом, HEAD-дерево проверяется на наличие LFS-объектов. Всё что нашли — скачиваем

  3. При чекауте (синхронизация или переключение ветки), недостающие LFS-объекты на целевом референсе скачиваются, затем происходит сам чекаут

У нас есть достаточно большой репозиторий, которым мы пользуемся каждый день — это каталог, в котором мы ведём задачи по разработке приложения. Его размер около 460mb, ~3k файлов, из которых ~1.5k — картинки. Он и стал мерилом успеха.

Мы перетащили все изображения в LFS, оставив в репозитории только текстовые Markdown файлы. Историю затёрли — это было проще, чем вычищать из неё все бинарники.

Настроить какие файлы будут считаться LFS-объектами можно не только напрямую в .gitattributes, но и через интерфейс приложения — в настройках каталога.

По итогу размер репозитория уменьшился с ~460mb до ~13mb. При скорости интернета ~11mb/s получились следующие данные:

Клонирование без LFS

Клонирование с LFS

(1 воркер)

Клонирование с LFS

(16 воркеров)

50.5s

3.2s repo + 200.7s lfs = 203.9s

8s repo + 36.9s lfs = 44.9s

К пренеприятнейшему сожалению оказалось, что наивное скачивание LFS-объектов при клонировании не даёт никакого буста. Что, впрочем, логично, ведь качаем тот же объём данных. Да ещё и каждый файл по отдельности, а не единым архивом как раньше.

Ленивая подгрузка LFS-объектов

Зачем скачивать все LFS-объекты текущего дерева, если можно скачивать их только при необходимости? Практика показывает, что во многие старые статьи в редакторе практически никто никогда и не заходит. В реалиях нашего «примера» это ещё более актуально. Ну кто смотрит задачи минувших релизов?

В следующей итерации так и было сделано. По умолчанию LFS-объекты теперь скачиваются только при открытии статьи. Но это поведение можно поменять в этом пункте меню. Первая синхронизация может быть долгой!

С точки зрения кода тут сложно подчеркнуть что-то красочное. Если в общем, алгоритм работы изменился с «ищем недостающие объекты при клонировании» на «при открытии статьи проверяем, все ли ресурсы скачаны».

Таким образом, удалось уменьшить время загрузки такого, казалось бы, большого репозитория с ~50s до ~3s при скорости интернета ~11mb/s.

В заключение

Изначально казалось, что Git LFS — это большая, огромная и сложная штука, и что «просто следуй спеке» не сработает. Оказалось наоборот. Спецификация очень маленькая и вмещается всего в несколько Markdown-файлов. Это позволило реализовать весь функционал, описанный в статье, достаточно быстро.

Если пересказать вкратце, то мы:

  1. Добавили поддержку Git LFS в Gramax, в процессе:

    1. Пропатчили git2-rs и добавили недостающие биндинги

    2. Написали свою реализацию git-lfs, которая работает в WebAssembly — и, соответственно, в веб-приложении

    3. Вынесли основную логику этой реализации в отдельный репозиторий

  2. Реализовали ленивую подгрузку ресурсов статьи, если они являются LFS-объектами, что сильно ускорило первоначальное клонирование репозитория

Открыто, бесплатно, и с сообществом