From ec157b766efd9eb781a8ca85fb9c28674e073da0 Mon Sep 17 00:00:00 2001 From: Rachel Powers <508861+Ryex@users.noreply.github.com> Date: Thu, 4 May 2023 23:42:42 -0700 Subject: feat(mod parsing): load extra mod details - (image, license, issuetracker) Signed-off-by: Rachel Powers <508861+Ryex@users.noreply.github.com> --- launcher/minecraft/mod/tasks/LocalModParseTask.cpp | 210 +++++++++++++++++++++ 1 file changed, 210 insertions(+) (limited to 'launcher/minecraft/mod/tasks/LocalModParseTask.cpp') diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp index 5342d693..084b0afb 100644 --- a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp @@ -52,6 +52,10 @@ ModDetails ReadMCModInfo(QByteArray contents) authors = firstObj.value("authors").toArray(); } + if (firstObj.contains("logoFile")) { + details.icon_file = firstObj.value("logoFile").toString(); + } + for (auto author : authors) { details.authors.append(author.toString()); } @@ -166,6 +170,30 @@ ModDetails ReadMCModTOML(QByteArray contents) } details.homeurl = homeurl; + QString issueTrackerURL = ""; + if (auto issueTrackerURLDatum = tomlData["issueTrackerURL"].as_string()) { + issueTrackerURL = QString::fromStdString(issueTrackerURLDatum->get()); + } else if (auto issueTrackerURLDatum = (*modsTable)["issueTrackerURL"].as_string()) { + issueTrackerURL = QString::fromStdString(issueTrackerURLDatum->get()); + } + details.issue_tracker = issueTrackerURL; + + QString license = ""; + if (auto licenseDatum = tomlData["license"].as_string()) { + license = QString::fromStdString(licenseDatum->get()); + } else if (auto licenseDatum =(*modsTable)["license"].as_string()) { + license = QString::fromStdString(licenseDatum->get()); + } + details.licenses.push_back(ModLicense(license)); + + QString logoFile = ""; + if (auto logoFileDatum = tomlData["logoFile"].as_string()) { + logoFile = QString::fromStdString(logoFileDatum->get()); + } else if (auto logoFileDatum =(*modsTable)["logoFile"].as_string()) { + logoFile = QString::fromStdString(logoFileDatum->get()); + } + details.icon_file = logoFile; + return details; } @@ -201,6 +229,57 @@ ModDetails ReadFabricModInfo(QByteArray contents) if (contact.contains("homepage")) { details.homeurl = contact.value("homepage").toString(); } + if (contact.contains("issues")) { + details.issue_tracker = contact.value("issues").toString(); + } + } + + if (object.contains("license")) { + auto license = object.value("license"); + if (license.isArray()) { + for (auto l : license.toArray()) { + if (l.isString()) { + details.licenses.append(ModLicense(l.toString())); + } else if (l.isObject()) { + auto obj = l.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), + obj.value("url").toString(), obj.value("description").toString())); + } + } + } else if (license.isString()) { + details.licenses.append(ModLicense(license.toString())); + } else if (license.isObject()) { + auto obj = license.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), + obj.value("description").toString())); + } + } + + if (object.contains("icon")) { + auto icon = object.value("icon"); + if (icon.isObject()) { + auto obj = icon.toObject(); + // take the largest icon + int largest = 0; + for (auto key : obj.keys()) { + auto size = key.split('x').first().toInt(); + if (size > largest) { + largest = size; + } + } + if (largest > 0) { + auto key = QString::number(largest) + "x" + largest; + details.icon_file = obj.value(key).toString(); + } else { // parsing the sizes failed + // take the first + for (auto icon : obj) { + details.icon_file = icon.toString(); + break; + } + } + } else if (icon.isString()) { + details.icon_file = icon.toString(); + } } } return details; @@ -238,6 +317,58 @@ ModDetails ReadQuiltModInfo(QByteArray contents) if (modContact.contains("homepage")) { details.homeurl = Json::requireString(modContact.value("homepage")); } + if (modContact.contains("issues")) { + details.issue_tracker = Json::requireString(modContact.value("issues")); + } + + if (modMetadata.contains("license")) { + auto license = modMetadata.value("license"); + if (license.isArray()) { + for (auto l : license.toArray()) { + if (l.isString()) { + details.licenses.append(ModLicense(l.toString())); + } else if (l.isObject()) { + auto obj = l.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), + obj.value("url").toString(), obj.value("description").toString())); + } + } + } else if (license.isString()) { + details.licenses.append(ModLicense(license.toString())); + } else if (license.isObject()) { + auto obj = license.toObject(); + details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), + obj.value("description").toString())); + } + } + + if (modMetadata.contains("icon")) { + auto icon = modMetadata.value("icon"); + if (icon.isObject()) { + auto obj = icon.toObject(); + // take the largest icon + int largest = 0; + for (auto key : obj.keys()) { + auto size = key.split('x').first().toInt(); + if (size > largest) { + largest = size; + } + } + if (largest > 0) { + auto key = QString::number(largest) + "x" + largest; + details.icon_file = obj.value(key).toString(); + } else { // parsing the sizes failed + // take the first + for (auto icon : obj) { + details.icon_file = icon.toString(); + break; + } + } + } else if (icon.isString()) { + details.icon_file = icon.toString(); + } + } + } return details; } @@ -515,6 +646,85 @@ bool validate(QFileInfo file) return ModUtils::process(mod, ProcessingLevel::BasicInfoOnly) && mod.valid(); } +bool processIconPNG(const Mod& mod, QByteArray&& raw_data) +{ + auto img = QImage::fromData(raw_data); + if (!img.isNull()) { + mod.setIcon(img); + } else { + qWarning() << "Failed to parse mod logo:" << mod.iconPath() << "from" << mod.name(); + return false; + } + return true; +} + +bool loadIconFile(const Mod& mod) { + if (mod.iconPath().isEmpty()) { + qWarning() << "No Iconfile set, be sure to parse the mod first"; + return false; + } + + auto png_invalid = [&mod]() { + qWarning() << "Mod at" << mod.fileinfo().filePath() << "does not have a valid icon"; + return false; + }; + + switch (mod.type()) { + case ResourceType::FOLDER: + { + QFileInfo icon_info(FS::PathCombine(mod.fileinfo().filePath(), mod.iconPath())); + if (icon_info.exists() && icon_info.isFile()) { + QFile icon(icon_info.filePath()); + if (!icon.open(QIODevice::ReadOnly)) + return false; + auto data = icon.readAll(); + + bool icon_result = ModUtils::processIconPNG(mod, std::move(data)); + + icon.close(); + + if (!icon_result) { + return png_invalid(); // icon invalid + } + } + } + case ResourceType::ZIPFILE: + { + QuaZip zip(mod.fileinfo().filePath()); + if (!zip.open(QuaZip::mdUnzip)) + return false; + + QuaZipFile file(&zip); + + if (zip.setCurrentFile(mod.iconPath())) { + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Failed to open file in zip."; + zip.close(); + return png_invalid(); + } + + auto data = file.readAll(); + + bool icon_result = ModUtils::processIconPNG(mod, std::move(data)); + + file.close(); + if (!icon_result) { + return png_invalid(); // icon png invalid + } + } else { + return png_invalid(); // could not set icon as current file. + } + } + case ResourceType::LITEMOD: + { + return false; // can lightmods even have icons? + } + default: + qWarning() << "Invalid type for mod, can not load icon."; + return false; + } +} + } // namespace ModUtils LocalModParseTask::LocalModParseTask(int token, ResourceType type, const QFileInfo& modFile) -- cgit From 2fe3dc5960a6cc231891a91dd68fcc8b159e9365 Mon Sep 17 00:00:00 2001 From: Rachel Powers <508861+Ryex@users.noreply.github.com> Date: Fri, 5 May 2023 11:13:36 -0700 Subject: fix: fix qchar conversion and codeql Signed-off-by: Rachel Powers <508861+Ryex@users.noreply.github.com> --- launcher/MTPixmapCache.h | 2 +- launcher/minecraft/mod/Mod.cpp | 4 ++-- launcher/minecraft/mod/ResourcePackFolderModel.cpp | 8 +++----- launcher/minecraft/mod/TexturePackFolderModel.cpp | 8 +++----- launcher/minecraft/mod/tasks/LocalModParseTask.cpp | 12 ++++++------ 5 files changed, 15 insertions(+), 19 deletions(-) (limited to 'launcher/minecraft/mod/tasks/LocalModParseTask.cpp') diff --git a/launcher/MTPixmapCache.h b/launcher/MTPixmapCache.h index 271788c0..65cbe032 100644 --- a/launcher/MTPixmapCache.h +++ b/launcher/MTPixmapCache.h @@ -95,7 +95,7 @@ class PixmapCache final : public QObject { } /** - * Mark that a cach miss occured because of a eviciton if too man of these occure to fast the cache size is increased + * Mark that a cache miss occurred because of a eviction if too many of these occur too fast the cache size is increased * @return if the cache size was increased */ bool _markCacheMissByEviciton() diff --git a/launcher/minecraft/mod/Mod.cpp b/launcher/minecraft/mod/Mod.cpp index 392f7f2e..aabc2db4 100644 --- a/launcher/minecraft/mod/Mod.cpp +++ b/launcher/minecraft/mod/Mod.cpp @@ -206,7 +206,7 @@ void Mod::finishResolvingWithDetails(ModDetails&& details) if (!iconPath().isEmpty()) { m_pack_image_cache_key.was_read_attempt = false; } -}; +} auto Mod::provider() const -> std::optional { @@ -250,7 +250,7 @@ QPixmap Mod::icon(QSize size, Qt::AspectRatioMode mode) const qDebug() << "Mod" << name() << "Had it's icon evicted form the cache. reloading..."; PixmapCache::markCacheMissByEviciton(); } - // Imaged got evicted from the cache or an attmept to load it has not been made. load it and retry. + // Image got evicted from the cache or an attempt to load it has not been made. load it and retry. m_pack_image_cache_key.was_read_attempt = true; ModUtils::loadIconFile(*this); return icon(size); diff --git a/launcher/minecraft/mod/ResourcePackFolderModel.cpp b/launcher/minecraft/mod/ResourcePackFolderModel.cpp index c8c0c773..349353a5 100644 --- a/launcher/minecraft/mod/ResourcePackFolderModel.cpp +++ b/launcher/minecraft/mod/ResourcePackFolderModel.cpp @@ -158,12 +158,10 @@ QVariant ResourcePackFolderModel::headerData(int section, Qt::Orientation orient return {}; } case Qt::SizeHintRole: - switch (section) { - case ImageColumn: - return QSize(64,0); - default: - return {}; + if (section == ImageColumn) { + return QSize(64,0); } + return {}; default: return {}; } diff --git a/launcher/minecraft/mod/TexturePackFolderModel.cpp b/launcher/minecraft/mod/TexturePackFolderModel.cpp index dd93a469..f053eab1 100644 --- a/launcher/minecraft/mod/TexturePackFolderModel.cpp +++ b/launcher/minecraft/mod/TexturePackFolderModel.cpp @@ -101,12 +101,10 @@ QVariant TexturePackFolderModel::data(const QModelIndex& index, int role) const return {}; } case Qt::CheckStateRole: - switch (column) { - case ActiveColumn: - return m_resources[row]->enabled() ? Qt::Checked : Qt::Unchecked; - default: - return {}; + if (column == ActiveColumn) { + return m_resources[row]->enabled() ? Qt::Checked : Qt::Unchecked; } + return {}; default: return {}; } diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp index 084b0afb..f045bde3 100644 --- a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp @@ -268,12 +268,12 @@ ModDetails ReadFabricModInfo(QByteArray contents) } } if (largest > 0) { - auto key = QString::number(largest) + "x" + largest; + auto key = QString::number(largest) + "x" + QString::number(largest); details.icon_file = obj.value(key).toString(); } else { // parsing the sizes failed // take the first - for (auto icon : obj) { - details.icon_file = icon.toString(); + for (auto i : obj) { + details.icon_file = i.toString(); break; } } @@ -355,12 +355,12 @@ ModDetails ReadQuiltModInfo(QByteArray contents) } } if (largest > 0) { - auto key = QString::number(largest) + "x" + largest; + auto key = QString::number(largest) + "x" + QString::number(largest); details.icon_file = obj.value(key).toString(); } else { // parsing the sizes failed // take the first - for (auto icon : obj) { - details.icon_file = icon.toString(); + for (auto i : obj) { + details.icon_file = i.toString(); break; } } -- cgit From 74e7c13a177afdb503a642cb9c97d71e72249291 Mon Sep 17 00:00:00 2001 From: Rachel Powers <508861+Ryex@users.noreply.github.com> Date: Fri, 5 May 2023 13:46:38 -0700 Subject: feat: display license and issue tracker Signed-off-by: Rachel Powers <508861+Ryex@users.noreply.github.com> --- launcher/minecraft/mod/Mod.cpp | 9 ++ launcher/minecraft/mod/Mod.h | 2 + launcher/minecraft/mod/ModDetails.h | 11 +- launcher/minecraft/mod/ModFolderModel.cpp | 1 - launcher/minecraft/mod/tasks/LocalModParseTask.cpp | 3 +- launcher/ui/widgets/InfoFrame.cpp | 120 ++++++++++++++++++++- launcher/ui/widgets/InfoFrame.h | 4 + launcher/ui/widgets/InfoFrame.ui | 92 +++++++++++----- 8 files changed, 209 insertions(+), 33 deletions(-) (limited to 'launcher/minecraft/mod/tasks/LocalModParseTask.cpp') diff --git a/launcher/minecraft/mod/Mod.cpp b/launcher/minecraft/mod/Mod.cpp index f236d2ac..e613ddeb 100644 --- a/launcher/minecraft/mod/Mod.cpp +++ b/launcher/minecraft/mod/Mod.cpp @@ -215,6 +215,15 @@ auto Mod::provider() const -> std::optional return {}; } +auto Mod::licenses() const -> const QList& +{ + return details().licenses; +} + + auto Mod::issueTracker() const -> QString +{ + return details().issue_tracker; +} void Mod::setIcon(QImage new_image) const { diff --git a/launcher/minecraft/mod/Mod.h b/launcher/minecraft/mod/Mod.h index 4be0842f..d4e419f4 100644 --- a/launcher/minecraft/mod/Mod.h +++ b/launcher/minecraft/mod/Mod.h @@ -68,6 +68,8 @@ public: auto authors() const -> QStringList; auto status() const -> ModStatus; auto provider() const -> std::optional; + auto licenses() const -> const QList&; + auto issueTracker() const -> QString; /** Get the intneral path to the mod's icon file*/ QString iconPath() const { return m_local_details.icon_file; }; diff --git a/launcher/minecraft/mod/ModDetails.h b/launcher/minecraft/mod/ModDetails.h index eb3770d6..b4e59d52 100644 --- a/launcher/minecraft/mod/ModDetails.h +++ b/launcher/minecraft/mod/ModDetails.h @@ -64,8 +64,11 @@ struct ModLicense { auto parts = license.split(' '); QStringList notNameParts = {}; for (auto part : parts) { - auto url = QUrl::fromUserInput(part); - if (url.isValid()) { + auto url = QUrl(part); + if (part.startsWith("(") && part.endsWith(")")) + url = QUrl(part.mid(1, part.size() - 2)); + + if (url.isValid() && !url.scheme().isEmpty() && !url.host().isEmpty()) { this->url = url.toString(); notNameParts.append(part); continue; @@ -119,6 +122,10 @@ struct ModLicense { return *this; } + + bool isEmpty() { + return this->name.isEmpty() && this->id.isEmpty() && this->url.isEmpty() && this->description.isEmpty(); + } }; struct ModDetails diff --git a/launcher/minecraft/mod/ModFolderModel.cpp b/launcher/minecraft/mod/ModFolderModel.cpp index f1c26e68..8843f79f 100644 --- a/launcher/minecraft/mod/ModFolderModel.cpp +++ b/launcher/minecraft/mod/ModFolderModel.cpp @@ -52,7 +52,6 @@ #include "minecraft/mod/tasks/LocalModParseTask.h" #include "minecraft/mod/tasks/ModFolderLoadTask.h" -#include "modplatform/ModIndex.h" ModFolderModel::ModFolderModel(const QString& dir, std::shared_ptr instance, bool is_indexed, bool create_dir) : ResourceFolderModel(QDir(dir), instance, nullptr, create_dir), m_is_indexed(is_indexed) diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp index f045bde3..264019f8 100644 --- a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp @@ -184,7 +184,8 @@ ModDetails ReadMCModTOML(QByteArray contents) } else if (auto licenseDatum =(*modsTable)["license"].as_string()) { license = QString::fromStdString(licenseDatum->get()); } - details.licenses.push_back(ModLicense(license)); + if (!license.isEmpty()) + details.licenses.append(ModLicense(license)); QString logoFile = ""; if (auto logoFileDatum = tomlData["logoFile"].as_string()) { diff --git a/launcher/ui/widgets/InfoFrame.cpp b/launcher/ui/widgets/InfoFrame.cpp index 6f4036a2..9c041bfe 100644 --- a/launcher/ui/widgets/InfoFrame.cpp +++ b/launcher/ui/widgets/InfoFrame.cpp @@ -47,6 +47,8 @@ InfoFrame::InfoFrame(QWidget *parent) : ui->setupUi(this); ui->descriptionLabel->setHidden(true); ui->nameLabel->setHidden(true); + ui->licenseLabel->setHidden(true); + ui->issueTrackerLabel->setHidden(true); updateHiddenState(); } @@ -89,6 +91,40 @@ void InfoFrame::updateWithMod(Mod const& m) } setImage(m.icon({64,64})); + + auto licenses = m.licenses(); + QString licenseText = ""; + if (!licenses.empty()) { + for (auto l : licenses) { + if (!licenseText.isEmpty()) { + licenseText += "\n"; // add newline between licenses + } + if (!l.name.isEmpty()) { + if (l.url.isEmpty()) { + licenseText += l.name; + } else { + licenseText += "" + l.name + ""; + } + } else if (!l.url.isEmpty()) { + licenseText += "" + l.url + ""; + } + if (!l.description.isEmpty() && l.description != l.name) { + licenseText += " " + l.description; + } + } + } + if (!licenseText.isEmpty()) { + setLicense(tr("License: %1").arg(licenseText)); + } else { + setLicense(); + } + + QString issueTracker = ""; + if (!m.issueTracker().isEmpty()) { + issueTracker += tr("Report issues to: "); + issueTracker += "" + m.issueTracker() + ""; + } + setIssueTracker(issueTracker); } void InfoFrame::updateWithResource(const Resource& resource) @@ -177,16 +213,16 @@ void InfoFrame::clear() setName(); setDescription(); setImage(); + setLicense(); + setIssueTracker(); } void InfoFrame::updateHiddenState() { - if(ui->descriptionLabel->isHidden() && ui->nameLabel->isHidden()) - { + if (ui->descriptionLabel->isHidden() && ui->nameLabel->isHidden() && ui->licenseLabel->isHidden() && + ui->issueTrackerLabel->isHidden()) { setHidden(true); - } - else - { + } else { setHidden(false); } } @@ -251,6 +287,66 @@ void InfoFrame::setDescription(QString text) ui->descriptionLabel->setText(labeltext); } +void InfoFrame::setLicense(QString text) +{ + if(text.isEmpty()) + { + ui->licenseLabel->setHidden(true); + updateHiddenState(); + return; + } + else + { + ui->licenseLabel->setHidden(false); + updateHiddenState(); + } + ui->licenseLabel->setToolTip(""); + QString intermediatetext = text.trimmed(); + bool prev(false); + QChar rem('\n'); + QString finaltext; + finaltext.reserve(intermediatetext.size()); + foreach(const QChar& c, intermediatetext) + { + if(c == rem && prev){ + continue; + } + prev = c == rem; + finaltext += c; + } + QString labeltext; + labeltext.reserve(300); + if(finaltext.length() > 290) + { + ui->licenseLabel->setOpenExternalLinks(false); + ui->licenseLabel->setTextFormat(Qt::TextFormat::RichText); + m_description = text; + // This allows injecting HTML here. + labeltext.append("" + finaltext.left(287) + "..."); + QObject::connect(ui->licenseLabel, &QLabel::linkActivated, this, &InfoFrame::licenseEllipsisHandler); + } + else + { + ui->licenseLabel->setTextFormat(Qt::TextFormat::AutoText); + labeltext.append(finaltext); + } + ui->licenseLabel->setText(labeltext); +} + +void InfoFrame::setIssueTracker(QString text) +{ + if(text.isEmpty()) + { + ui->issueTrackerLabel->setHidden(true); + } + else + { + ui->issueTrackerLabel->setText(text); + ui->issueTrackerLabel->setHidden(false); + } + updateHiddenState(); +} + void InfoFrame::setImage(QPixmap img) { if (img.isNull()) { @@ -275,6 +371,20 @@ void InfoFrame::descriptionEllipsisHandler(QString link) } } +void InfoFrame::licenseEllipsisHandler(QString link) +{ + if(!m_current_box) + { + m_current_box = CustomMessageBox::selectable(this, "", m_license); + connect(m_current_box, &QMessageBox::finished, this, &InfoFrame::boxClosed); + m_current_box->show(); + } + else + { + m_current_box->setText(m_license); + } +} + void InfoFrame::boxClosed(int result) { m_current_box = nullptr; diff --git a/launcher/ui/widgets/InfoFrame.h b/launcher/ui/widgets/InfoFrame.h index 84523e28..7eb679a9 100644 --- a/launcher/ui/widgets/InfoFrame.h +++ b/launcher/ui/widgets/InfoFrame.h @@ -36,6 +36,8 @@ class InfoFrame : public QFrame { void setName(QString text = {}); void setDescription(QString text = {}); void setImage(QPixmap img = {}); + void setLicense(QString text = {}); + void setIssueTracker(QString text = {}); void clear(); @@ -48,6 +50,7 @@ class InfoFrame : public QFrame { public slots: void descriptionEllipsisHandler(QString link); + void licenseEllipsisHandler(QString link); void boxClosed(int result); private: @@ -56,5 +59,6 @@ class InfoFrame : public QFrame { private: Ui::InfoFrame* ui; QString m_description; + QString m_license; class QMessageBox* m_current_box = nullptr; }; diff --git a/launcher/ui/widgets/InfoFrame.ui b/launcher/ui/widgets/InfoFrame.ui index 9e407ce9..c4d8c83d 100644 --- a/launcher/ui/widgets/InfoFrame.ui +++ b/launcher/ui/widgets/InfoFrame.ui @@ -35,8 +35,36 @@ 0 - - + + + + + 0 + 0 + + + + + 64 + 64 + + + + + + + false + + + 0 + + + + + + + + @@ -57,11 +85,8 @@ - - - - - + + @@ -82,28 +107,47 @@ - - - - - 0 - 0 - + + + + - - - 64 - 64 - + + Qt::RichText + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + - + - - false + + Qt::RichText - - 0 + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse -- cgit