#include "git/GitManager.h" #include #include #include #include #include #include #include namespace { QString gitErr() { const git_error *e = git_error_last(); return e && e->message ? QString::fromUtf8(e->message) : QStringLiteral("неизвестная ошибка git"); } struct CommitInfo { git_signature *author = nullptr; git_index *index = nullptr; git_repository *repo = nullptr; git_oid treeId{}; git_oid commitId{}; ~CommitInfo() { if (author) git_signature_free(author); if (index) git_index_free(index); if (repo) git_repository_free(repo); } }; QString timeAgoString(const QDateTime &dt) { const qint64 secs = dt.secsTo(QDateTime::currentDateTime()); if (secs < 60) return QStringLiteral("только что"); if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60); if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600); if (secs < 604800) return QStringLiteral("%1 дн назад").arg(secs/86400); // старше недели — показываем дату return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm")); } QString escHtml(const QString &s) { QString out; out.reserve(s.size()); for (const QChar &c : s) { switch (c.unicode()) { case '&': out += "&"; break; case '<': out += "<"; break; case '>': out += ">"; break; case '"': out += """; break; case '\'': out += "'"; break; default: out += c; break; } } return out; } } // namespace bool GitManager::s_gitInitialized = false; GitManager::GitManager(QObject *parent) : QObject(parent) { if (!s_gitInitialized) { git_libgit2_init(); s_gitInitialized = true; } } QString GitManager::repoExists(const QString &path) { const QString clean = QDir::cleanPath(path); git_repository *repo = nullptr; if (git_repository_open(&repo, clean.toUtf8().constData()) < 0) return QString(); git_repository_free(repo); return clean; } void GitManager::setRepoIdentity(git_repository *repo) { git_config *cfg = nullptr; if (git_repository_config(&cfg, repo) < 0) return; auto setIfMissing = [cfg](const char *key, const char *value) { git_buf buf{}; if (git_config_get_string_buf(&buf, cfg, key) < 0) git_config_set_string(cfg, key, value); git_buf_dispose(&buf); }; setIfMissing("user.name", "Flora"); setIfMissing("user.email", "flora@localhost"); git_config_free(cfg); } void GitManager::setLastError(const QString &msg) { if (m_lastError == msg) return; m_lastError = msg; emit lastErrorChanged(); } QString GitManager::relativePath(const QString &vaultPath, const QString &filePath) const { const QString vault = QDir::cleanPath(vaultPath); const QString file = QDir::cleanPath(filePath); if (file == vault) return QString(); if (!file.startsWith(vault + '/')) return QString(); QString rel = file.mid(vault.length()); while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0,1); return rel; } void GitManager::ensureGitignore(const QString &vaultPath) { const QString gi = QDir(vaultPath).filePath(".gitignore"); const QStringList needed = {".flora/", ".stfolder", ".stfolder/", "Thumbs.db", ".DS_Store", "*.tmp", "*.log"}; QString existing; if (QFile::exists(gi)) { QFile f(gi); if (f.open(QIODevice::ReadOnly)) existing = QString::fromUtf8(f.readAll()); } else { // создаём файл } bool changed = false; QStringList lines = existing.split('\n'); for (const QString &need : needed) { const QString trimmed = need.trimmed(); bool found = false; for (const QString &l : lines) { if (l.trimmed() == trimmed) { found = true; break; } } if (!found) { if (!existing.isEmpty() && !existing.endsWith('\n')) existing += '\n'; existing += trimmed + "\n"; changed = true; } } if (changed) { QFile f(gi); if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { f.write(existing.toUtf8()); f.close(); } } } bool GitManager::initRepo(const QString &path) { const QString clean = QDir::cleanPath(path.trimmed()); if (clean.isEmpty()) { setLastError(QStringLiteral("Путь к хранилищу пустой")); return false; } const QByteArray p = clean.toUtf8(); git_repository *repo = nullptr; if (git_repository_open(&repo, p.constData()) == 0) { setRepoIdentity(repo); git_repository_free(repo); ensureGitignore(clean); setLastError(QString()); return true; // .git уже есть } git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT; opts.flags = GIT_REPOSITORY_INIT_MKPATH | GIT_REPOSITORY_INIT_MKDIR; opts.initial_head = "main"; const int rc = git_repository_init_ext(&repo, p.constData(), &opts); if (rc < 0) { setLastError(QStringLiteral("Не удалось создать git-репозиторий: %1") .arg(gitErr())); return false; } setRepoIdentity(repo); git_repository_free(repo); ensureGitignore(clean); setLastError(QString()); return true; } bool GitManager::hasCommits(const QString &path) const { const QString clean = QDir::cleanPath(path); git_repository *repo = nullptr; if (git_repository_open(&repo, clean.toUtf8().constData()) < 0) return false; const bool has = git_repository_head_unborn(repo) == 0; git_repository_free(repo); return has; } QString GitManager::fileGitStatus(const QString &vaultPath, const QString &filePath) const { if (vaultPath.isEmpty() || filePath.isEmpty()) return QStringLiteral("—"); const QString vault = QDir::cleanPath(vaultPath); const QString file = QDir::cleanPath(filePath); if (!file.startsWith(vault + '/') && file != vault) return QStringLiteral("—"); git_repository *repo = nullptr; if (git_repository_open(&repo, vault.toUtf8().constData()) < 0) return QStringLiteral("—"); // относительный путь внутри репозитория QString rel = file; if (rel == vault) { git_repository_free(repo); return QStringLiteral("—"); } if (rel.startsWith(vault)) { rel = rel.mid(vault.length()); while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0, 1); } if (rel.isEmpty()) { git_repository_free(repo); return QStringLiteral("—"); } unsigned int status = 0; const int rc = git_status_file(&status, repo, rel.toUtf8().constData()); git_repository_free(repo); if (rc != 0) { // файл не найден в индексе или ошибка — считаем неотслеживаемым если файл существует if (QFile::exists(filePath)) return QStringLiteral("неотслеживается"); return QStringLiteral("—"); } if (status == GIT_STATUS_CURRENT) return QStringLiteral("чисто"); if (status & GIT_STATUS_CONFLICTED) return QStringLiteral("конфликт"); if (status & GIT_STATUS_WT_NEW) return QStringLiteral("неотслеживается"); if (status & GIT_STATUS_INDEX_NEW) return QStringLiteral("в индексе (новый)"); if (status & GIT_STATUS_INDEX_MODIFIED) return QStringLiteral("в индексе"); if (status & GIT_STATUS_WT_MODIFIED) return QStringLiteral("изменён"); if (status & GIT_STATUS_WT_DELETED) return QStringLiteral("удалён"); if (status & GIT_STATUS_IGNORED) return QStringLiteral("игнорируется"); return QStringLiteral("чисто"); } QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &filePath) const { const QString s = fileGitStatus(vaultPath, filePath); if (s == "чисто") return QStringLiteral("чисто"); if (s == "изменён") return QStringLiteral("изменён"); if (s == "неотслеживается") return QStringLiteral("новый"); if (s.contains("конфликт")) return QStringLiteral("конфликт"); if (s == "—") return QStringLiteral("—"); return s; } bool GitManager::commitAll(const QString &path, const QString &message) { const QString cleanPath = QDir::cleanPath(path); CommitInfo info; if (git_repository_open(&info.repo, cleanPath.toUtf8().constData()) < 0) { setLastError(QStringLiteral("Хранилище не является git-репозиторием")); return false; } if (git_repository_index(&info.index, info.repo) < 0) { setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr())); return false; } // Добавляем все файлы, включая новые и удаления (с учётом .gitignore) git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr); const int wrc = git_index_write(info.index); if (wrc < 0) { setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr())); return false; } // Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов, // что тоже нужно коммитить. Проверяем равенство дерева ниже. // Создаём дерево git_tree *tree = nullptr; if (git_index_write_tree(&info.treeId, info.index) < 0 || git_tree_lookup(&tree, info.repo, &info.treeId) < 0) { setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr())); return false; } // Родитель: head, если коммиты уже есть git_reference *headRef = nullptr; git_commit *parentCommit = nullptr; int nParents = 0; if (git_repository_head(&headRef, info.repo) == 0) { git_object *obj = nullptr; if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) { parentCommit = reinterpret_cast(obj); nParents = 1; } if (headRef) git_reference_free(headRef); } // Если дерево не изменилось относительно родителя — коммитить нечего. if (parentCommit) { const git_oid *prevTree = git_commit_tree_id(parentCommit); if (prevTree && git_oid_equal(prevTree, &info.treeId)) { git_commit_free(parentCommit); git_tree_free(tree); setLastError(QStringLiteral("Нет изменений для коммита")); return false; } } const git_commit *parents[1] = {parentCommit}; // Подпись из конфига репозитория git_signature *author = nullptr; if (git_signature_default(&author, info.repo) < 0) { git_commit_free(parentCommit); git_tree_free(tree); setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr())); return false; } info.author = author; const int crc = git_commit_create(&info.commitId, info.repo, "HEAD", author, author, "UTF-8", message.toUtf8().constData(), tree, nParents, parents); if (parentCommit) git_commit_free(parentCommit); git_tree_free(tree); if (crc < 0) { setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr())); return false; } setLastError(QString()); return true; } QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const { QVariantList out; const QString vault = QDir::cleanPath(vaultPath); const QString rel = relativePath(vaultPath, filePath); if (vault.isEmpty() || rel.isEmpty()) return out; git_repository *repo = nullptr; if (git_repository_open(&repo, vault.toUtf8().constData()) < 0) return out; if (git_repository_head_unborn(repo)) { git_repository_free(repo); return out; } git_revwalk *walk = nullptr; if (git_revwalk_new(&walk, repo) < 0) { git_repository_free(repo); return out; } git_revwalk_sorting(walk, GIT_SORT_TIME); git_revwalk_push_head(walk); git_oid oid; int iter = 0; const int maxIter = 500; // ограничение для скорости while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) { ++iter; git_commit *commit = nullptr; if (git_commit_lookup(&commit, repo, &oid) < 0) continue; // Проверяем был ли файл в этом коммите и менялся ли он относительно родителя bool relevant = false; QString fileContent; // Получаем дерево коммита git_tree *tree = nullptr; if (git_commit_tree(&tree, commit) == 0) { git_tree_entry *entry = nullptr; if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) { // файл есть в этом коммите // Сравниваем с родителем if (git_commit_parentcount(commit) == 0) { relevant = true; // первый коммит где файл появился } else { git_commit *parent = nullptr; if (git_commit_parent(&parent, commit, 0) == 0) { git_tree *parentTree = nullptr; if (git_commit_tree(&parentTree, parent) == 0) { git_tree_entry *parentEntry = nullptr; if (git_tree_entry_bypath(&parentEntry, parentTree, rel.toUtf8().constData()) == 0) { // оба есть — сравниваем oid const git_oid *a = git_tree_entry_id(entry); const git_oid *b = git_tree_entry_id(parentEntry); if (!git_oid_equal(a, b)) relevant = true; git_tree_entry_free(parentEntry); } else { // файл появился в этом коммите (не было в родителе) relevant = true; } git_tree_free(parentTree); } git_commit_free(parent); } else { relevant = true; } } git_tree_entry_free(entry); } git_tree_free(tree); } if (!relevant) { git_commit_free(commit); continue; } char hashStr[41] = {0}; git_oid_tostr(hashStr, sizeof(hashStr), &oid); QString hash = QString::fromUtf8(hashStr); QString shortHash = hash.left(7); const char *msg = git_commit_message(commit); QString message = msg ? QString::fromUtf8(msg).split('\n').first().trimmed() : QString(); if (message.isEmpty()) message = QStringLiteral("(без сообщения)"); if (message.length() > 80) message = message.left(80) + "..."; const git_signature *author = git_commit_author(commit); QString authorName = author && author->name ? QString::fromUtf8(author->name) : QStringLiteral("Flora"); git_time_t t = git_commit_time(commit); int offset = git_commit_time_offset(commit); QDateTime dt = QDateTime::fromSecsSinceEpoch((qint64)t, QTimeZone(offset*60)); // Если смещение 0 — используем локальное if (!dt.isValid()) dt = QDateTime::fromSecsSinceEpoch((qint64)t); QVariantMap m; m["hash"] = hash; m["shortHash"] = shortHash; m["message"] = message; m["author"] = authorName; m["time"] = dt; m["timeStr"] = dt.toString(QStringLiteral("dd.MM.yyyy hh:mm:ss")); m["timeAgo"] = timeAgoString(dt); m["hasContent"] = true; out.append(m); git_commit_free(commit); } git_revwalk_free(walk); git_repository_free(repo); return out; } QString GitManager::fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const { const QString vault = QDir::cleanPath(vaultPath); const QString rel = relativePath(vaultPath, filePath); if (vault.isEmpty() || rel.isEmpty() || commitHash.isEmpty()) return QString(); git_repository *repo = nullptr; if (git_repository_open(&repo, vault.toUtf8().constData()) < 0) return QString(); git_oid oid; if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) < 0) { git_repository_free(repo); return QString(); } git_commit *commit = nullptr; if (git_commit_lookup(&commit, repo, &oid) < 0) { git_repository_free(repo); return QString(); } git_tree *tree = nullptr; QString result; if (git_commit_tree(&tree, commit) == 0) { git_tree_entry *entry = nullptr; if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) { const git_oid *blobOid = git_tree_entry_id(entry); git_blob *blob = nullptr; if (git_blob_lookup(&blob, repo, blobOid) == 0) { const void *raw = git_blob_rawcontent(blob); git_off_t len = git_blob_rawsize(blob); result = QString::fromUtf8((const char*)raw, (int)len); git_blob_free(blob); } git_tree_entry_free(entry); } git_tree_free(tree); } git_commit_free(commit); git_repository_free(repo); return result; } QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const { const QString oldContent = fileContentAt(vaultPath, filePath, commitHash); QFile f(filePath); QString newContent; if (f.open(QIODevice::ReadOnly)) newContent = QString::fromUtf8(f.readAll()); else newContent = QString(); if (oldContent == newContent) return QStringLiteral("Нет изменений"); // Используем git_patch_from_buffers для диффа git_patch *patch = nullptr; const QByteArray oldBytes = oldContent.toUtf8(); const QByteArray newBytes = newContent.toUtf8(); const QString rel = relativePath(vaultPath, filePath); const QByteArray relBytes = rel.toUtf8(); git_patch_from_buffers(&patch, oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(), newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(), nullptr); if (!patch) return QStringLiteral("Не удалось построить diff"); QString out; // Заголовок out += QStringLiteral("--- a/%1\n").arg(rel); out += QStringLiteral("+++ b/%1\n").arg(rel); size_t hunks = git_patch_num_hunks(patch); for (size_t h = 0; h < hunks; ++h) { const git_diff_hunk *hk = nullptr; size_t hunkLines = 0; if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue; out += QString::fromUtf8(hk->header, (int)hk->header_len); if (!out.endsWith('\n')) out += '\n'; for (size_t l = 0; l < hunkLines; ++l) { const git_diff_line *line = nullptr; if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue; QChar origin = QChar(line->origin); QString text = QString::fromUtf8(line->content, (int)line->content_len); // line->content уже содержит \n в конце out += origin + text; if (!text.endsWith('\n') && line->content_len > 0) out += '\n'; } } git_patch_free(patch); if (out.trimmed().isEmpty()) return QStringLiteral("Нет изменений"); return out; } QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const { const QString txt = fileDiffText(vaultPath, filePath, commitHash); if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось"))) return QStringLiteral("

%1

").arg(escHtml(txt)); QString html; html.reserve(txt.size()*2); html += "
";
    const QStringList lines = txt.split('\n');
    for (const QString &rawLine : lines) {
        if (rawLine.isEmpty()) { html += "\n"; continue; }
        QChar first = rawLine.at(0);
        QString esc = escHtml(rawLine);
        if (first == '+') {
            html += QStringLiteral("
%1
").arg(esc); } else if (first == '-') { html += QStringLiteral("
%1
").arg(esc); } else if (first == '@') { html += QStringLiteral("
%1
").arg(esc); } else if (first == ' ') { html += QStringLiteral("
%1
").arg(esc); } else { html += QStringLiteral("
%1
").arg(esc); } } html += "
"; return html; } QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash) { const QString vault = QDir::cleanPath(vaultPath); const QString rel = relativePath(vaultPath, filePath); if (vault.isEmpty() || rel.isEmpty()) return QStringLiteral("Неверные пути"); const QString content = fileContentAt(vaultPath, filePath, commitHash); // Если файл не существовал в коммите — удаляем текущий? // Для простоты: если контента нет, удаляем файл const QString absPath = QDir(vault).filePath(rel); if (content.isNull() && content.isEmpty()) { // Проверяем был ли файл в коммите вообще QVariantList hist = fileHistory(vaultPath, filePath); bool found = false; for (auto v : hist) if (v.toMap().value("hash").toString() == commitHash) { found = true; break; } if (!found) return QStringLiteral("Коммит не найден"); // Файл не существовал — удаляем если есть if (QFile::exists(absPath)) { if (!QFile::remove(absPath)) return QStringLiteral("Не удалось удалить файл: %1").arg(absPath); } } else { QDir().mkpath(QFileInfo(absPath).absolutePath()); QSaveFile f(absPath); if (!f.open(QIODevice::WriteOnly)) return QStringLiteral("Не удалось записать файл: %1").arg(absPath); f.write(content.toUtf8()); if (!f.commit()) return QStringLiteral("Не удалось сохранить файл"); } // Коммитим восстановление const QString shortHash = commitHash.left(7); const QString msg = QStringLiteral("Восстановление %1 из %2").arg(QFileInfo(filePath).fileName(), shortHash); if (!commitAll(vault, msg)) return lastError().isEmpty() ? QStringLiteral("Не удалось закоммитить восстановление") : lastError(); return QString(); } QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const { const QVariantList hist = fileHistory(vaultPath, filePath); if (!hist.isEmpty()) { QDateTime dt = hist.first().toMap().value("time").toDateTime(); if (dt.isValid()) return dt; } QFileInfo fi(filePath); if (fi.exists()) return fi.lastModified(); return QDateTime(); } QString GitManager::lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const { QDateTime dt = lastModifiedTime(vaultPath, filePath); if (!dt.isValid()) return QStringLiteral("—"); // Формат как в истории const qint64 secs = dt.secsTo(QDateTime::currentDateTime()); if (secs < 60) return QStringLiteral("только что"); if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60); if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600); if (secs < 172800) return QStringLiteral("вчера %1").arg(dt.toString("hh:mm")); return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm")); } bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath) { if (vaultPath.isEmpty()) return false; const QString fileName = QFileInfo(filePath).fileName(); const QString msg = fileName.isEmpty() ? QStringLiteral("Автосохранение") : QStringLiteral("Автосохранение: %1").arg(fileName); // commitAll вернёт false если нет изменений — это нормально bool ok = commitAll(vaultPath, msg); if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) { setLastError(QString()); return true; // считаем успехом } return ok; }