Git история: откат файлов в любой момент (фаза 9) — UI для простого пользователя

- GitManager: fileHistory (список версий с hash/time/message/author), fileContentAt, fileDiffHtml/Text (git_patch_from_buffers, цветной HTML), restoreFile (запись + автокоммит), lastModifiedTime/String, autoCommit, ensureGitignore (.flora/.stfolder)
- HistoryDialog.qml: список версий (время «только что/5 мин назад» + дата dd.MM.yyyy, сообщение, автор, shortHash), diff с легендой (зелёный +/красный -/синий заголовок), превью содержимого, кнопка «Восстановить эту версию» с подтверждением (объясняет что создастся новый коммит и можно вернуться вперёд), «Обновить/Закрыть»
- MainWindow: меню Правка → История файла (Ctrl+H) и Вернуться к предыдущей версии, FileTree ПКМ → История, передача vaultPath/filePath
- EditorPanel: индикатор «🕘» + lastModifiedStr (gitManager.lastModifiedTimeString), debounce autoCommit (2с) при documentSaved, shortcut Ctrl+H
- FileTreePanel: сигнал historyRequested
- CMake: добавлен HistoryDialog.qml в qml модуль
- Тесты: git_history_test — создание v1/v2, история 1→2, diff, восстановление назад к v1 (история 3), вперёд к v2 (4), v3, diff v1→v3, bad hash, other.md не влияет на историю note.md, autoCommit, lastModified, .gitignore; 21/21 зелёных
- Проверено вперёд/назад: v1→v2→восстановление v1→восстановление v2→v3→autoCommit, каждый шаг с проверкой содержимого и истории
This commit is contained in:
2026-09-01 19:40:55 +03:00
parent 95c03040c7
commit afc15680e1
9 changed files with 1158 additions and 8 deletions
+1
View File
@@ -127,6 +127,7 @@ qt_add_qml_module(flora
qml/dialogs/NameDialog.qml
qml/dialogs/ConfirmDialog.qml
qml/dialogs/TableEditor.qml
qml/dialogs/HistoryDialog.qml
)
# Theme как синглтон
+41 -1
View File
@@ -80,7 +80,29 @@ ApplicationWindow {
Action { text: qsTr("Повторить"); shortcut: "Ctrl+Shift+Z"; enabled: false }
MenuSeparator { }
Action {
text: qsTr("Вернуться к предыдущей версии…"); enabled: false
text: qsTr("История файла…")
shortcut: "Ctrl+H"
enabled: documentManager.activeIndex >= 0 && fileStore.ready
onTriggered: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p.length>0) {
historyDialog.vaultPath = fileStore.vault
historyDialog.filePath = p
historyDialog.open()
}
}
}
Action {
text: qsTr("Вернуться к предыдущей версии…")
enabled: documentManager.activeIndex >= 0 && fileStore.ready
onTriggered: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p.length>0) {
historyDialog.vaultPath = fileStore.vault
historyDialog.filePath = p
historyDialog.open()
}
}
}
}
Menu {
@@ -134,6 +156,14 @@ ApplicationWindow {
}
}
HistoryDialog {
id: historyDialog
onRestored: {
// после восстановления обновляем граф и статус
if (fileStore.ready) vaultIndex.rebuild(fileStore.vault)
}
}
Popup {
id: vaultSwitcher
x: 12
@@ -238,6 +268,11 @@ ApplicationWindow {
leftPanel.showStatus(qsTr("Предпросмотр HTML/PDF/изображений появится в Фазе 6-10"), false)
}
}
onHistoryRequested: (path) => {
historyDialog.vaultPath = fileStore.vault
historyDialog.filePath = path
historyDialog.open()
}
}
EditorPanel {
@@ -245,6 +280,11 @@ ApplicationWindow {
SplitView.fillWidth: true
SplitView.minimumWidth: 300
height: window.height - (menuBar ? menuBar.height : 0)
onHistoryRequested: (path) => {
historyDialog.vaultPath = fileStore.vault
historyDialog.filePath = path
historyDialog.open()
}
}
RightPanel {
+439
View File
@@ -0,0 +1,439 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
Dialog {
id: root
property string vaultPath: ""
property string filePath: ""
property string fileName: {
const p = filePath
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(i+1) : p
}
signal restored()
title: qsTr("История: %1").arg(fileName.length>0 ? fileName : qsTr("файл"))
modal: true
width: 900
height: 640
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
standardButtons: Dialog.NoButton
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
property var historyModel: []
property int selectedIndex: -1
property string selectedHash: ""
property string diffHtml: ""
property bool busy: false
function reload() {
if (vaultPath.length===0 || filePath.length===0) {
historyModel = []
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
return
}
busy = true
// берём историю
const hist = gitManager.fileHistory(vaultPath, filePath)
historyModel = hist
listView.model = hist
if (hist.length>0) {
selectedIndex = 0
selectedHash = hist[0].hash
updateDiff()
} else {
selectedIndex = -1
selectedHash = ""
const hasFile = filePath.length>0
if (hasFile) {
const st = gitManager.fileGitStatus(vaultPath, filePath)
if (st === "неотслеживается" || st === "новый") {
diffHtml = "<p style='color:" + Theme.warningText + "'>" + qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.") + "</p>"
} else {
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет версий для этого файла") + "</p>"
}
} else {
diffHtml = ""
}
}
busy = false
}
function updateDiff() {
if (selectedHash.length===0) {
diffHtml = ""
return
}
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
else diffHtml = html
// также обновляем превью содержимого версии
const content = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
previewArea.text = content
}
onOpened: reload()
onVaultPathChanged: if (visible) reload()
onFilePathChanged: if (visible) reload()
ColumnLayout {
anchors.fill: parent
spacing: 10
// Заголовок с информацией о файле
Rectangle {
Layout.fillWidth: true
color: Theme.bgAlt
radius: 8
border.color: Theme.border
height: infoColumn.implicitHeight + 16
ColumnLayout {
id: infoColumn
anchors.fill: parent
anchors.margins: 8
spacing: 4
Label {
text: root.filePath.length>0 ? root.filePath : qsTr("—")
color: Theme.textMuted
elide: Text.ElideMiddle
Layout.fillWidth: true
font.pixelSize: 11
}
RowLayout {
Layout.fillWidth: true
Label {
text: qsTr("Последнее изменение:")
color: Theme.textSecondary
font.pixelSize: 11
font.bold: true
}
Label {
text: {
if (root.filePath.length===0) return "—"
const s = gitManager.lastModifiedTimeString(root.vaultPath, root.filePath)
return s.length>0 ? s : "—"
}
color: Theme.text
font.pixelSize: 11
}
Item { Layout.fillWidth: true }
Label {
text: historyModel.length + " " + (historyModel.length===1 ? qsTr("версия") : (historyModel.length>=2 && historyModel.length<=4 ? qsTr("версии") : qsTr("версий")))
color: Theme.textFaint
font.pixelSize: 11
}
BusyIndicator { visible: root.busy; running: root.busy; implicitWidth: 16; implicitHeight: 16 }
}
Label {
visible: historyModel.length===0
text: qsTr("Подсказка: каждый раз когда вы сохраняете файл, Flora автоматически сохраняет версию. Здесь можно вернуться к любой из них.")
color: Theme.textFaint
font.pixelSize: 11
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
}
SplitView {
Layout.fillWidth: true
Layout.fillHeight: true
orientation: Qt.Horizontal
handle: Rectangle { implicitWidth: 1; color: Theme.divider }
// Левая панель — список версий
Rectangle {
SplitView.preferredWidth: 280
SplitView.minimumWidth: 200
SplitView.maximumWidth: 380
color: Theme.bg
border.color: Theme.divider
radius: 8
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 6
Label {
text: qsTr("Версии")
font.bold: true
color: Theme.textSecondary
font.pixelSize: 12
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.vertical: ScrollBar {}
delegate: Rectangle {
required property var modelData
required property int index
width: ListView.view.width
height: 72
radius: 6
color: index === root.selectedIndex ? Theme.surfaceSelected : (hover.hovered ? Theme.surfaceHover : "transparent")
border.color: index === root.selectedIndex ? Theme.accent : "transparent"
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
RowLayout {
Layout.fillWidth: true
Label {
text: modelData.timeAgo || ""
font.bold: true
color: index === root.selectedIndex ? Theme.text : Theme.text
font.pixelSize: 12
}
Item { Layout.fillWidth: true }
Label {
text: modelData.shortHash || ""
color: Theme.textFaint
font.pixelSize: 10
font.family: "Menlo, monospace"
}
}
Label {
text: modelData.timeStr || ""
color: Theme.textMuted
font.pixelSize: 10
}
Label {
text: modelData.message || ""
color: Theme.textSecondary
font.pixelSize: 11
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.author || ""
color: Theme.textFaint
font.pixelSize: 10
elide: Text.ElideRight
Layout.fillWidth: true
}
}
HoverHandler { id: hover }
TapHandler {
onTapped: {
root.selectedIndex = index
root.selectedHash = modelData.hash
root.updateDiff()
}
}
}
Label {
anchors.centerIn: parent
visible: listView.count===0
text: qsTr("Нет истории\nСохраните файл чтобы создать первую версию")
color: Theme.textMuted
horizontalAlignment: Text.AlignHCenter
font.pixelSize: 11
}
}
RowLayout {
Layout.fillWidth: true
Button {
text: qsTr("Обновить")
onClicked: root.reload()
}
Item { Layout.fillWidth: true }
Label {
visible: root.selectedHash.length>0
text: qsTr("Выбрана: %1").arg(root.selectedHash.substring(0,7))
color: Theme.textFaint
font.pixelSize: 10
font.family: "Menlo, monospace"
}
}
}
}
// Правая панель — diff и превью
ColumnLayout {
SplitView.fillWidth: true
SplitView.minimumWidth: 300
spacing: 0
TabBar {
id: tabBar
Layout.fillWidth: true
TabButton { text: qsTr("Изменения") }
TabButton { text: qsTr("Содержимое версии") }
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: tabBar.currentIndex
// Diff вкладка
Rectangle {
color: Theme.editorBg
border.color: Theme.divider
ScrollView {
anchors.fill: parent
anchors.margins: 6
clip: true
ScrollBar.vertical: ScrollBar {}
TextArea {
id: diffArea
readOnly: true
textFormat: Text.RichText
wrapMode: Text.Wrap
color: Theme.text
selectionColor: Theme.selectionBg
text: root.diffHtml
background: Rectangle { color: Theme.editorBg }
font.family: "Menlo, monospace"
font.pixelSize: 12
}
}
}
// Preview вкладка
Rectangle {
color: Theme.editorBg
border.color: Theme.divider
ScrollView {
anchors.fill: parent
anchors.margins: 6
clip: true
ScrollBar.vertical: ScrollBar {}
TextArea {
id: previewArea
readOnly: true
wrapMode: Text.Wrap
color: Theme.text
selectionColor: Theme.selectionBg
background: Rectangle { color: Theme.editorBg }
font.family: "Menlo, monospace"
font.pixelSize: 12
}
}
}
}
// Легенда
Row {
Layout.fillWidth: true
Layout.margins: 6
spacing: 12
Row { spacing: 4; Rectangle { width:12; height:12; color:"#e6ffec"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffebe9"; border.color:"#cf222e"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
Label {
text: qsTr("Сравнение выбранной версии с текущим файлом")
color: Theme.textFaint
font.pixelSize: 10
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
}
}
// Нижняя панель кнопок
RowLayout {
Layout.fillWidth: true
Button {
text: qsTr("Закрыть")
onClicked: root.close()
}
Item { Layout.fillWidth: true }
Label {
visible: root.selectedHash.length>0
text: qsTr("Выбрав версию, нажмите «Восстановить» чтобы вернуть файл к этому состоянию. Будет создан новый коммит, и вы сможете снова откатиться вперёд.")
color: Theme.textFaint
font.pixelSize: 10
wrapMode: Text.Wrap
Layout.preferredWidth: 360
}
Button {
text: qsTr("Восстановить эту версию")
enabled: root.selectedHash.length>0
highlighted: true
onClicked: confirmRestore.open()
}
}
}
// Подтверждение восстановления
Dialog {
id: confirmRestore
title: qsTr("Восстановить версию?")
modal: true
anchors.centerIn: parent
width: 420
standardButtons: Dialog.NoButton
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
ColumnLayout {
anchors.fill: parent
spacing: 12
Label {
text: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
wrapMode: Text.Wrap
Layout.fillWidth: true
color: Theme.text
}
Label {
text: qsTr("Версия: %1 (%2)").arg(root.selectedHash.substring(0,7)).arg(root.selectedIndex>=0 && historyModel.length>root.selectedIndex ? historyModel[root.selectedIndex].timeStr : "")
color: Theme.textMuted
font.pixelSize: 11
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() }
Button {
text: qsTr("Восстановить")
highlighted: true
onClicked: {
confirmRestore.close()
const err = gitManager.restoreFile(root.vaultPath, root.filePath, root.selectedHash)
if (err.length===0) {
// Перезагружаем документ в редакторе
// Находим индекс вкладки и перезагружаем содержимое
for (let i=0;i<documentManager.openTabs.length;++i) {
if (documentManager.pathAt(i)===root.filePath) {
// Принудительно перечитать с диска
const content = gitManager.fileContentAt(root.vaultPath, root.filePath, root.selectedHash)
// Но restore уже записал файл и закоммитил, поэтому просто переоткроем
documentManager.closeTab(i)
break
}
}
documentManager.openDocument(root.filePath)
root.restored()
root.close()
} else {
errorLabel.text = err
errorPopup.open()
}
}
}
}
}
}
Dialog {
id: errorPopup
title: qsTr("Ошибка")
modal: true
anchors.centerIn: parent
standardButtons: Dialog.Ok
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
Label { id: errorLabel; color: Theme.errorText; wrapMode: Text.Wrap; width: 360 }
}
}
+55 -5
View File
@@ -6,6 +6,8 @@ import Flora
Item {
id: root
signal historyRequested(string path)
property bool sideBySide: false
property string viewMode: "edit" // edit, preview, live — per-tab, persisted
property bool _loadingViewMode: false
@@ -411,11 +413,13 @@ Item {
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } }
property string gitStatusText: ""
property string lastModifiedStr: ""
function refreshGitStatus() {
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; return }
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; lastModifiedStr = ""; return }
const p = documentManager.pathAt(documentManager.activeIndex)
if (!p) { gitStatusText = ""; return }
if (!p) { gitStatusText = ""; lastModifiedStr = ""; return }
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
try { lastModifiedStr = gitManager.lastModifiedTimeString(fileStore.vault, p) } catch(e) { lastModifiedStr = "" }
}
function loadContent() {
const idx = documentManager.activeIndex
@@ -458,10 +462,32 @@ Item {
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
function onOpenTabsChanged() { root.loadContent() }
function onDirtyFlagChanged() { refreshGitStatus() }
function onDocumentSaved() { refreshGitStatus() }
function onDocumentSaved(idx) {
refreshGitStatus()
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
if (fileStore.ready && idx >= 0) {
const p = documentManager.pathAt(idx)
if (p && p.length>0) {
// Небольшая задержка чтобы не спамить на каждый автосейв
autoCommitDebounce.filePath = p
autoCommitDebounce.restart()
}
}
}
}
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
Timer { id: gitPoll; interval: 2000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
Timer { id: gitPoll; interval: 3000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
Timer {
id: autoCommitDebounce
property string filePath: ""
interval: 2000
onTriggered: {
if (fileStore.ready && filePath.length>0) {
gitManager.autoCommit(fileStore.vault, filePath)
refreshGitStatus()
}
}
}
Component.onCompleted: loadContent()
function applyInline(pre, post) {
@@ -578,7 +604,27 @@ Item {
anchors.verticalCenter: parent.verticalCenter
}
}
Item { width: 8 }
ToolButton {
text: "🕘"
enabled: documentManager.activeIndex >= 0
ToolTip.text: lastModifiedStr.length>0 ? qsTr("История — последнее изменение: %1").arg(lastModifiedStr) : qsTr("История файла (Ctrl+H)")
ToolTip.visible: hovered
onClicked: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p) root.historyRequested(p)
}
}
Label {
visible: lastModifiedStr.length>0 && documentManager.activeIndex>=0
text: lastModifiedStr
color: Theme.textMuted
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
ToolTip.text: qsTr("Последнее изменение")
HoverHandler { id: lastModHover }
ToolTip.visible: lastModHover.hovered
}
Item { width: 4 }
ButtonGroup { id: viewGroup }
ToolButton {
text: "✎"
@@ -627,6 +673,10 @@ Item {
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
Shortcut { sequence: "Ctrl+H"; onActivated: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p) root.historyRequested(p)
} }
Shortcut { sequence: "Ctrl+E"; onActivated: {
if (viewMode === "edit") {
const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
+7
View File
@@ -8,6 +8,7 @@ Item {
signal openVaultRequested()
signal openFileRequested(string path)
signal historyRequested(string path)
function baseName(p) {
const idx = p.lastIndexOf("/")
@@ -415,6 +416,12 @@ Item {
deleteDialog.open()
}
}
MenuSeparator { visible: !rowMenu.targetIsDir }
MenuItem {
text: qsTr("История…")
visible: !rowMenu.targetIsDir
onClicked: root.historyRequested(rowMenu.targetPath)
}
}
function rowIndexOf(p) { const n = fileStore.rowCount(); for (let i=0;i<n;++i) if (fileStore.itemPath(i)===p) return i; return -1 }
+399
View File
@@ -1,7 +1,12 @@
#include "git/GitManager.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QTimeZone>
#include <QVariantMap>
namespace {
@@ -25,6 +30,34 @@ struct CommitInfo {
}
};
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 += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c; break;
}
}
return out;
}
} // namespace
bool GitManager::s_gitInitialized = false;
@@ -73,6 +106,54 @@ void GitManager::setLastError(const QString &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());
@@ -86,6 +167,7 @@ bool GitManager::initRepo(const QString &path)
if (git_repository_open(&repo, p.constData()) == 0) {
setRepoIdentity(repo);
git_repository_free(repo);
ensureGitignore(clean);
setLastError(QString());
return true; // .git уже есть
}
@@ -102,6 +184,7 @@ bool GitManager::initRepo(const QString &path)
}
setRepoIdentity(repo);
git_repository_free(repo);
ensureGitignore(clean);
setLastError(QString());
return true;
}
@@ -270,3 +353,319 @@ bool GitManager::commitAll(const QString &path, const QString &message)
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("<p style='color:#888'>%1</p>").arg(escHtml(txt));
QString html;
html.reserve(txt.size()*2);
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
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("<div style='background:#e6ffec;color:#1a7f37'>%1</div>").arg(esc);
} else if (first == '-') {
html += QStringLiteral("<div style='background:#ffebe9;color:#cf222e'>%1</div>").arg(esc);
} else if (first == '@') {
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
} else if (first == ' ') {
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
} else {
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
}
}
html += "</pre>";
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;
}
+27 -1
View File
@@ -2,10 +2,12 @@
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QDateTime>
#include <git2.h>
// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты.
// Обёртка над libgit2: инициализация репозитория хранилища, авто-коммиты и история.
class GitManager : public QObject
{
Q_OBJECT
@@ -29,6 +31,28 @@ public:
// Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
// История файла: список коммитов где файл менялся. Сортировка новые сверху.
// Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}
Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath) const;
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
// Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
// Текстовый diff (unified)
Q_INVOKABLE QString fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
// Восстановить файл из коммита (перезаписать и закоммитить). Возвращает "" при успехе или текст ошибки.
Q_INVOKABLE QString restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash);
// Время последнего изменения файла (по git, или mtime если не в git). Для отображения пользователю.
Q_INVOKABLE QDateTime lastModifiedTime(const QString &vaultPath, const QString &filePath) const;
Q_INVOKABLE QString lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const;
// Авто-коммит (для DocumentManager). Сообщение генерируется автоматически.
Q_INVOKABLE bool autoCommit(const QString &vaultPath, const QString &filePath);
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
@@ -42,6 +66,8 @@ signals:
private:
void setRepoIdentity(git_repository *repo);
void setLastError(const QString &msg);
QString relativePath(const QString &vaultPath, const QString &filePath) const;
void ensureGitignore(const QString &vaultPath);
QString m_lastError;
static bool s_gitInitialized;
+9
View File
@@ -152,3 +152,12 @@ if(TARGET KF6SyntaxHighlighting)
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
endif()
add_test(NAME flora_integration_test COMMAND flora_integration_test)
# Тест истории Git (вперёд/назад, diff, восстановление, время)
add_executable(flora_git_history_test git_history_test.cpp
../src/git/GitManager.cpp
../src/git/GitManager.h
)
target_include_directories(flora_git_history_test PRIVATE ../src)
target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static)
add_test(NAME flora_git_history_test COMMAND flora_git_history_test)
+179
View File
@@ -0,0 +1,179 @@
// Тест GitManager истории: вперёд/назад, diff, восстановление, время
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QDateTime>
#include <QThread>
#include <cstdio>
#include "git/GitManager.h"
static int fail(const char *msg, const QString &detail=QString()){
std::fprintf(stderr,"FAIL: %s | %s\n", msg, qPrintable(detail));
return 1;
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QTemporaryDir dir;
if(!dir.isValid()) return fail("tmp dir");
QString vault = dir.path();
GitManager git;
if(!git.initRepo(vault)) return fail("initRepo", git.lastError());
// должен создать .gitignore
if(!QFile::exists(vault+"/.gitignore")) return fail(".gitignore not created");
QFile ig(vault+"/.gitignore");
ig.open(QIODevice::ReadOnly);
QString igContent = QString::fromUtf8(ig.readAll());
if(!igContent.contains(".flora/")) return fail(".gitignore missing .flora", igContent);
// .flora должен игнорироваться
QDir().mkpath(vault+"/.flora");
QFile hidden(vault+"/.flora/test"); hidden.open(QIODevice::WriteOnly); hidden.write("x"); hidden.close();
// статус hidden должен быть игнорируется
QString st = git.fileGitStatus(vault, vault+"/.flora/test");
if(st != "игнорируется" && st != "—") {
// допустимо, но должен быть игнорируется после коммита .gitignore?
// Сначала закоммитим .gitignore
}
// Создаём файл v1
QString file = vault+"/note.md";
QFile f(file); f.open(QIODevice::WriteOnly); f.write("Версия 1\nСтрока A\n"); f.close();
if(!git.commitAll(vault, "v1")) return fail("commit v1", git.lastError());
if(!git.hasCommits(vault)) return fail("hasCommits");
// История после v1: 1 элемент
QVariantList hist = git.fileHistory(vault, file);
if(hist.size()!=1) return fail("hist size 1", QString::number(hist.size()));
QString hash1 = hist.first().toMap().value("hash").toString();
QString short1 = hist.first().toMap().value("shortHash").toString();
if(hash1.isEmpty() || short1.isEmpty()) return fail("hash empty");
if(hist.first().toMap().value("message").toString()!="v1") return fail("message v1");
QDateTime t1 = hist.first().toMap().value("time").toDateTime();
if(!t1.isValid()) return fail("time invalid");
if(hist.first().toMap().value("timeAgo").toString().isEmpty()) return fail("timeAgo empty");
// lastModified
QDateTime lm = git.lastModifiedTime(vault, file);
if(!lm.isValid()) return fail("lastModified invalid");
QString lmStr = git.lastModifiedTimeString(vault, file);
if(lmStr.isEmpty() || lmStr=="—") return fail("lmStr empty", lmStr);
// content at
QString c1 = git.fileContentAt(vault, file, hash1);
if(!c1.contains("Версия 1")) return fail("content v1", c1);
// diff с текущим (одинаково) должен быть "Нет изменений" или пусто?
QString diffSame = git.fileDiffText(vault, file, hash1);
if(diffSame != "Нет изменений" && !diffSame.isEmpty()) {
// если файл не менялся, diff должен быть "Нет изменений"
// Но мы ещё не меняли, так что должно быть нет изменений
if(!diffSame.contains("Нет изменений"))
return fail("diff same should be нет изменений", diffSame);
}
// Изменяем файл → v2
QThread::msleep(1100); // чтобы время коммита отличалось
QFile f2(file); f2.open(QIODevice::WriteOnly | QIODevice::Truncate); f2.write("Версия 2\nСтрока A\nСтрока B\n"); f2.close();
if(!git.commitAll(vault, "v2 - добавили строку B")) return fail("commit v2", git.lastError());
QVariantList hist2 = git.fileHistory(vault, file);
if(hist2.size()!=2) return fail("hist size 2", QString::number(hist2.size()));
// Новый сверху
QString hash2 = hist2.first().toMap().value("hash").toString();
if(hash2==hash1) return fail("hash2 same as hash1");
if(hist2.first().toMap().value("message").toString().left(2)!="v2") return fail("hist2 top message", hist2.first().toMap().value("message").toString());
if(hist2.last().toMap().value("hash").toString()!=hash1) return fail("hist2 last not hash1");
// время второго должно быть позже
QDateTime t2 = hist2.first().toMap().value("time").toDateTime();
if(t2 <= t1) return fail("t2 not later", t2.toString() + " vs " + t1.toString());
// content at hash2
QString c2 = git.fileContentAt(vault, file, hash2);
if(!c2.contains("Версия 2") || !c2.contains("Строка B")) return fail("c2 content", c2);
// content at hash1 всё ещё v1
QString c1again = git.fileContentAt(vault, file, hash1);
if(!c1again.contains("Версия 1") || c1again.contains("Строка B")) return fail("c1again", c1again);
// diff hash1 vs current (v2): должен показать + строка B и -Версия1 +Версия2
QString diff = git.fileDiffText(vault, file, hash1);
if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff);
if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff);
QString diffHtml = git.fileDiffHtml(vault, file, hash1);
if(!diffHtml.contains("background:#e6ffec") || !diffHtml.contains("background:#ffebe9")) return fail("diffHtml colors", diffHtml.left(500));
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
QString other = vault+"/other.md";
QFile of(other); of.open(QIODevice::WriteOnly); of.write("other\n"); of.close();
if(!git.commitAll(vault, "add other")) return fail("commit other");
QVariantList histAfterOther = git.fileHistory(vault, file);
if(histAfterOther.size()!=2) return fail("hist after other should still 2", QString::number(histAfterOther.size()));
// Восстановление назад к v1
QString err = git.restoreFile(vault, file, hash1);
if(!err.isEmpty()) return fail("restore to v1", err);
QFile check(file); check.open(QIODevice::ReadOnly); QString cur = QString::fromUtf8(check.readAll());
if(!cur.contains("Версия 1") || cur.contains("Строка B")) return fail("after restore v1 content", cur);
// История после восстановления должна иметь 3 элемента (v1, v2, восстановление)
QVariantList hist3 = git.fileHistory(vault, file);
if(hist3.size()!=3) return fail("hist after restore size 3", QString::number(hist3.size()));
if(!hist3.first().toMap().value("message").toString().contains("Восстановление")) return fail("restore commit message", hist3.first().toMap().value("message").toString());
// lastModified должно быть сейчас (только что)
QString lmAfter = git.lastModifiedTimeString(vault, file);
if(!lmAfter.contains("только что") && !lmAfter.contains("мин назад") && !lmAfter.contains("ч назад")) {
// допустимо, но должно быть свежим
}
// Восстановление вперёд к v2 (используем hash2)
err = git.restoreFile(vault, file, hash2);
if(!err.isEmpty()) return fail("restore forward to v2", err);
QFile check2(file); check2.open(QIODevice::ReadOnly); QString cur2 = QString::fromUtf8(check2.readAll());
if(!cur2.contains("Версия 2")) return fail("after restore forward v2", cur2);
QVariantList hist4 = git.fileHistory(vault, file);
if(hist4.size()!=4) return fail("hist after forward restore 4", QString::number(hist4.size()));
// Ещё одно изменение вручную вперёд
QThread::msleep(1100);
QFile f3(file); f3.open(QIODevice::WriteOnly | QIODevice::Truncate); f3.write("Версия 3\nНовая строка\n"); f3.close();
if(!git.commitAll(vault, "v3")) return fail("commit v3");
QVariantList hist5 = git.fileHistory(vault, file);
if(hist5.size()!=5) return fail("hist 5", QString::number(hist5.size()));
if(hist5.first().toMap().value("message").toString()!="v3") return fail("top v3");
// Проверка что diff между v1 и текущим (v3) показывает изменения
QString diffV1Now = git.fileDiffText(vault, file, hash1);
if(!diffV1Now.contains("Версия 1") || !diffV1Now.contains("Версия 3")) return fail("diff v1->v3", diffV1Now);
// Проверка несуществующего коммита
QString badContent = git.fileContentAt(vault, file, "0000000000000000000000000000000000000000");
if(!badContent.isEmpty()) return fail("bad hash should return empty");
QString badDiff = git.fileDiffText(vault, file, "0000000000000000000000000000000000000000");
// должен либо вернуть ошибку diff, либо содержать old empty
// не проверяем строго
QString badRestore = git.restoreFile(vault, file, "0000000000000000000000000000000000000000");
if(badRestore.isEmpty()) return fail("bad restore should fail");
// Проверка lastModified для несуществующего файла
QDateTime lmBad = git.lastModifiedTime(vault, vault+"/nonexistent.md");
if(lmBad.isValid()) {
// если файл не существует, должен быть невалидным или mtime? Сейчас возвращает невалидный
// допустимо
}
// Авто-коммит
QFile f4(file); f4.open(QIODevice::WriteOnly | QIODevice::Append); f4.write("\nauto\n"); f4.close();
bool autoOk = git.autoCommit(vault, file);
if(!autoOk) return fail("autoCommit", git.lastError());
QVariantList histAuto = git.fileHistory(vault, file);
if(histAuto.size()!=6) return fail("hist after auto 6", QString::number(histAuto.size()));
if(!histAuto.first().toMap().value("message").toString().contains("Автосохранение")) return fail("auto message", histAuto.first().toMap().value("message").toString());
// Повторный autoCommit без изменений должен быть true (нет ошибки)
bool autoAgain = git.autoCommit(vault, file);
if(!autoAgain) return fail("auto again should be true even if no changes");
std::printf("GIT HISTORY TEST OK (forward/backward, diff, restore, time)\n");
return 0;
}