2074 lines
67 KiB
C++
2074 lines
67 KiB
C++
/*
|
|
* This program source code file is part of KiCad, a free EDA CAD application.
|
|
*
|
|
* Copyright The KiCad Developers, see AUTHORS.txt for contributors.
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, you may find one here:
|
|
* http://www.gnu.org/licenses/gpl-3.0.html
|
|
* or you may search the http://www.gnu.org website for the version 3 license,
|
|
* or you may write to the Free Software Foundation, Inc.,
|
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
|
*/
|
|
|
|
#include <local_history.h>
|
|
#include <dialogs/dialog_restore_local_history.h>
|
|
#include <history_lock.h>
|
|
#include <io/kicad/kicad_io_utils.h>
|
|
#include <lockfile.h>
|
|
#include <settings/common_settings.h>
|
|
#include <pgm_base.h>
|
|
#include <thread_pool.h>
|
|
#include <trace_helpers.h>
|
|
#include <wildcards_and_files_ext.h>
|
|
#include <confirm.h>
|
|
|
|
#include <git2.h>
|
|
#include <wx/filename.h>
|
|
#include <wx/filefn.h>
|
|
#include <wx/ffile.h>
|
|
#include <wx/dir.h>
|
|
#include <wx/datetime.h>
|
|
#include <wx/log.h>
|
|
#include <wx/msgdlg.h>
|
|
|
|
#include <vector>
|
|
#include <string>
|
|
#include <memory>
|
|
#include <algorithm>
|
|
#include <set>
|
|
#include <map>
|
|
#include <functional>
|
|
#include <cstring>
|
|
|
|
static wxString historyPath( const wxString& aProjectPath )
|
|
{
|
|
wxFileName p( aProjectPath, wxEmptyString );
|
|
p.AppendDir( wxS( ".history" ) );
|
|
return p.GetPath();
|
|
}
|
|
|
|
LOCAL_HISTORY::LOCAL_HISTORY()
|
|
{
|
|
}
|
|
|
|
LOCAL_HISTORY::~LOCAL_HISTORY()
|
|
{
|
|
WaitForPendingSave();
|
|
}
|
|
|
|
void LOCAL_HISTORY::NoteFileChange( const wxString& aFile )
|
|
{
|
|
wxFileName fn( aFile );
|
|
|
|
if( fn.GetFullName() == wxS( "fp-info-cache" ) || !Pgm().GetCommonSettings()->m_Backup.enabled )
|
|
return;
|
|
|
|
m_pendingFiles.insert( fn.GetFullPath() );
|
|
}
|
|
|
|
|
|
void LOCAL_HISTORY::RegisterSaver(
|
|
const void* aSaverObject,
|
|
const std::function<void( const wxString&, std::vector<HISTORY_FILE_DATA>& )>& aSaver )
|
|
{
|
|
if( m_savers.find( aSaverObject ) != m_savers.end() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] Saver %p already registered, skipping"), aSaverObject );
|
|
return;
|
|
}
|
|
|
|
m_savers[aSaverObject] = aSaver;
|
|
wxLogTrace( traceAutoSave, wxS("[history] Registered saver %p (total=%zu)"), aSaverObject, m_savers.size() );
|
|
}
|
|
|
|
|
|
void LOCAL_HISTORY::UnregisterSaver( const void* aSaverObject )
|
|
{
|
|
WaitForPendingSave();
|
|
|
|
auto it = m_savers.find( aSaverObject );
|
|
|
|
if( it != m_savers.end() )
|
|
{
|
|
m_savers.erase( it );
|
|
wxLogTrace( traceAutoSave, wxS("[history] Unregistered saver %p (total=%zu)"), aSaverObject, m_savers.size() );
|
|
}
|
|
}
|
|
|
|
|
|
void LOCAL_HISTORY::ClearAllSavers()
|
|
{
|
|
WaitForPendingSave();
|
|
m_savers.clear();
|
|
wxLogTrace( traceAutoSave, wxS("[history] Cleared all savers") );
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::RunRegisteredSaversAndCommit( const wxString& aProjectPath, const wxString& aTitle )
|
|
{
|
|
if( !Pgm().GetCommonSettings()->m_Backup.enabled )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("Autosave disabled, returning" ) );
|
|
return true;
|
|
}
|
|
|
|
wxLogTrace( traceAutoSave, wxS("[history] RunRegisteredSaversAndCommit start project='%s' title='%s' savers=%zu"),
|
|
aProjectPath, aTitle, m_savers.size() );
|
|
|
|
if( m_savers.empty() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] no savers registered; skipping") );
|
|
return false;
|
|
}
|
|
|
|
// Skip if previous background save is still running
|
|
if( m_saveInProgress.load( std::memory_order_acquire ) )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] previous save still in progress; skipping cycle") );
|
|
return false;
|
|
}
|
|
|
|
// Phase 1 (UI thread): call savers to collect serialized data
|
|
std::vector<HISTORY_FILE_DATA> fileData;
|
|
|
|
for( const auto& [saverObject, saver] : m_savers )
|
|
{
|
|
size_t before = fileData.size();
|
|
saver( aProjectPath, fileData );
|
|
wxLogTrace( traceAutoSave, wxS("[history] saver %p produced %zu entries (total=%zu)"),
|
|
saverObject, fileData.size() - before, fileData.size() );
|
|
}
|
|
|
|
// Filter out any entries not within the project directory
|
|
wxString projectDir = aProjectPath;
|
|
if( !projectDir.EndsWith( wxFileName::GetPathSeparator() ) )
|
|
projectDir += wxFileName::GetPathSeparator();
|
|
|
|
auto it = std::remove_if( fileData.begin(), fileData.end(),
|
|
[&projectDir]( const HISTORY_FILE_DATA& entry )
|
|
{
|
|
if( !entry.path.StartsWith( projectDir ) )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] filtered out entry outside project: %s"), entry.path );
|
|
return true;
|
|
}
|
|
return false;
|
|
} );
|
|
fileData.erase( it, fileData.end() );
|
|
|
|
if( fileData.empty() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] saver set produced no entries; skipping") );
|
|
return false;
|
|
}
|
|
|
|
// Phase 2: submit Prettify + file I/O + git to background thread
|
|
m_saveInProgress.store( true, std::memory_order_release );
|
|
|
|
m_pendingFuture = GetKiCadThreadPool().submit_task(
|
|
[this, projectPath = aProjectPath, title = aTitle,
|
|
data = std::move( fileData )]() mutable -> bool
|
|
{
|
|
bool result = commitInBackground( projectPath, title, data );
|
|
m_saveInProgress.store( false, std::memory_order_release );
|
|
return result;
|
|
} );
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::commitInBackground( const wxString& aProjectPath, const wxString& aTitle,
|
|
const std::vector<HISTORY_FILE_DATA>& aFileData )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: writing %zu entries for '%s'"),
|
|
aFileData.size(), aProjectPath );
|
|
|
|
wxString hist = historyPath( aProjectPath );
|
|
|
|
// Write files to the .history mirror
|
|
for( const HISTORY_FILE_DATA& entry : aFileData )
|
|
{
|
|
if( !entry.content.empty() )
|
|
{
|
|
std::string buf = entry.content;
|
|
|
|
if( entry.prettify )
|
|
KICAD_FORMAT::Prettify( buf, entry.formatMode );
|
|
|
|
wxFFile fp( entry.path, wxS( "wb" ) );
|
|
|
|
if( fp.IsOpened() )
|
|
{
|
|
fp.Write( buf.data(), buf.size() );
|
|
fp.Close();
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: wrote %zu bytes to '%s'"),
|
|
buf.size(), entry.path );
|
|
}
|
|
else
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: failed to open '%s' for writing"),
|
|
entry.path );
|
|
}
|
|
}
|
|
else if( !entry.sourcePath.IsEmpty() )
|
|
{
|
|
wxCopyFile( entry.sourcePath, entry.path, true );
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: copied '%s' -> '%s'"),
|
|
entry.sourcePath, entry.path );
|
|
}
|
|
}
|
|
|
|
// Acquire locks using hybrid locking strategy
|
|
HISTORY_LOCK_MANAGER lock( aProjectPath );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: failed to acquire lock: %s"), lock.GetLockError() );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
git_index* index = lock.GetIndex();
|
|
|
|
// Stage all written files
|
|
for( const HISTORY_FILE_DATA& entry : aFileData )
|
|
{
|
|
wxFileName src( entry.path );
|
|
|
|
if( !src.FileExists() )
|
|
continue;
|
|
|
|
if( src.GetFullPath().StartsWith( hist + wxFILE_SEP_PATH ) )
|
|
{
|
|
std::string relHist = src.GetFullPath().ToStdString().substr( hist.length() + 1 );
|
|
git_index_add_bypath( index, relHist.c_str() );
|
|
}
|
|
}
|
|
|
|
// Compare index to HEAD; if no diff -> abort to avoid empty commit.
|
|
git_oid head_oid;
|
|
git_commit* head_commit = nullptr;
|
|
git_tree* head_tree = nullptr;
|
|
|
|
bool headExists = ( git_reference_name_to_id( &head_oid, repo, "HEAD" ) == 0 )
|
|
&& ( git_commit_lookup( &head_commit, repo, &head_oid ) == 0 )
|
|
&& ( git_commit_tree( &head_tree, head_commit ) == 0 );
|
|
|
|
git_tree* rawIndexTree = nullptr;
|
|
git_oid index_tree_oid;
|
|
|
|
if( git_index_write_tree( &index_tree_oid, index ) != 0 )
|
|
{
|
|
if( head_tree ) git_tree_free( head_tree );
|
|
if( head_commit ) git_commit_free( head_commit );
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: failed to write index tree" ) );
|
|
return false;
|
|
}
|
|
|
|
git_tree_lookup( &rawIndexTree, repo, &index_tree_oid );
|
|
std::unique_ptr<git_tree, decltype( &git_tree_free )> indexTree( rawIndexTree, &git_tree_free );
|
|
|
|
bool hasChanges = true;
|
|
|
|
if( headExists )
|
|
{
|
|
git_diff* diff = nullptr;
|
|
|
|
if( git_diff_tree_to_tree( &diff, repo, head_tree, indexTree.get(), nullptr ) == 0 )
|
|
{
|
|
hasChanges = git_diff_num_deltas( diff ) > 0;
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: diff deltas=%u"), (unsigned) git_diff_num_deltas( diff ) );
|
|
git_diff_free( diff );
|
|
}
|
|
}
|
|
|
|
if( head_tree ) git_tree_free( head_tree );
|
|
if( head_commit ) git_commit_free( head_commit );
|
|
|
|
if( !hasChanges )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: no changes detected; no commit") );
|
|
return false; // Nothing new; skip commit.
|
|
}
|
|
|
|
git_signature* rawSig = nullptr;
|
|
git_signature_now( &rawSig, "KiCad", "noreply@kicad.org" );
|
|
std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig, &git_signature_free );
|
|
|
|
git_commit* parent = nullptr;
|
|
git_oid parent_id;
|
|
int parents = 0;
|
|
|
|
if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
|
|
{
|
|
if( git_commit_lookup( &parent, repo, &parent_id ) == 0 )
|
|
parents = 1;
|
|
}
|
|
|
|
wxString msg = aTitle.IsEmpty() ? wxString( "Autosave" ) : aTitle;
|
|
git_oid commit_id;
|
|
const git_commit* constParent = parent;
|
|
|
|
int rc = git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr,
|
|
msg.mb_str().data(), indexTree.get(), parents,
|
|
parents ? &constParent : nullptr );
|
|
|
|
if( rc == 0 )
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: commit created %s (%s entries=%zu)"),
|
|
wxString::FromUTF8( git_oid_tostr_s( &commit_id ) ), msg, aFileData.size() );
|
|
else
|
|
wxLogTrace( traceAutoSave, wxS("[history] background: commit failed rc=%d"), rc );
|
|
|
|
if( parent ) git_commit_free( parent );
|
|
|
|
git_index_write( index );
|
|
return rc == 0;
|
|
}
|
|
|
|
|
|
void LOCAL_HISTORY::WaitForPendingSave()
|
|
{
|
|
if( m_pendingFuture.valid() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] waiting for pending background save") );
|
|
m_pendingFuture.get();
|
|
}
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::CommitPending()
|
|
{
|
|
std::vector<wxString> files( m_pendingFiles.begin(), m_pendingFiles.end() );
|
|
m_pendingFiles.clear();
|
|
return CommitSnapshot( files, wxS( "Autosave" ) );
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::Init( const wxString& aProjectPath )
|
|
{
|
|
if( aProjectPath.IsEmpty() )
|
|
return false;
|
|
|
|
if( !Pgm().GetCommonSettings()->m_Backup.enabled )
|
|
return true;
|
|
|
|
wxString hist = historyPath( aProjectPath );
|
|
|
|
if( !wxDirExists( hist ) )
|
|
{
|
|
if( wxIsWritable( aProjectPath ) )
|
|
{
|
|
if( !wxMkdir( hist ) )
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
git_repository* rawRepo = nullptr;
|
|
bool isNewRepo = false;
|
|
|
|
if( git_repository_open( &rawRepo, hist.mb_str().data() ) != 0 )
|
|
{
|
|
if( git_repository_init( &rawRepo, hist.mb_str().data(), 0 ) != 0 )
|
|
return false;
|
|
|
|
isNewRepo = true;
|
|
|
|
wxFileName ignoreFile( hist, wxS( ".gitignore" ) );
|
|
if( !ignoreFile.FileExists() )
|
|
{
|
|
wxFFile f( ignoreFile.GetFullPath(), wxT( "w" ) );
|
|
if( f.IsOpened() )
|
|
{
|
|
f.Write( wxS( "fp-info-cache\n*-backups\nREADME.txt\n" ) );
|
|
f.Close();
|
|
}
|
|
}
|
|
|
|
wxFileName readmeFile( hist, wxS( "README.txt" ) );
|
|
|
|
if( !readmeFile.FileExists() )
|
|
{
|
|
wxFFile f( readmeFile.GetFullPath(), wxT( "w" ) );
|
|
|
|
if( f.IsOpened() )
|
|
{
|
|
f.Write( wxS( "KiCad Local History Directory\n"
|
|
"=============================\n\n"
|
|
"This directory contains automatic snapshots of your project files.\n"
|
|
"KiCad periodically saves copies of your work here, allowing you to\n"
|
|
"recover from accidental changes or data loss.\n\n"
|
|
"You can browse and restore previous versions through KiCad's\n"
|
|
"File > Local History menu.\n\n"
|
|
"To disable this feature:\n"
|
|
" Preferences > Common > Project Backup > Enable automatic backups\n\n"
|
|
"This directory can be safely deleted if you no longer need the\n"
|
|
"history, but doing so will permanently remove all saved snapshots.\n" ) );
|
|
f.Close();
|
|
}
|
|
}
|
|
}
|
|
|
|
git_repository_free( rawRepo );
|
|
|
|
// If this is a newly initialized repository with no commits, create an initial snapshot
|
|
// of all existing project files
|
|
if( isNewRepo )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] Init: New repository created, collecting existing files" ) );
|
|
|
|
// Collect all files in the project directory (excluding backups and hidden files)
|
|
wxArrayString files;
|
|
std::function<void( const wxString& )> collect = [&]( const wxString& path )
|
|
{
|
|
wxString name;
|
|
wxDir d( path );
|
|
|
|
if( !d.IsOpened() )
|
|
return;
|
|
|
|
bool cont = d.GetFirst( &name );
|
|
|
|
while( cont )
|
|
{
|
|
// Skip hidden files/directories and backup directories
|
|
if( name.StartsWith( wxS( "." ) ) || name.EndsWith( wxS( "-backups" ) ) )
|
|
{
|
|
cont = d.GetNext( &name );
|
|
continue;
|
|
}
|
|
|
|
wxFileName fn( path, name );
|
|
wxString fullPath = fn.GetFullPath();
|
|
|
|
if( wxFileName::DirExists( fullPath ) )
|
|
{
|
|
collect( fullPath );
|
|
}
|
|
else if( fn.FileExists() )
|
|
{
|
|
// Skip transient files
|
|
if( fn.GetFullName() != wxS( "fp-info-cache" ) )
|
|
files.Add( fn.GetFullPath() );
|
|
}
|
|
|
|
cont = d.GetNext( &name );
|
|
}
|
|
};
|
|
|
|
collect( aProjectPath );
|
|
|
|
if( files.GetCount() > 0 )
|
|
{
|
|
std::vector<wxString> vec;
|
|
vec.reserve( files.GetCount() );
|
|
|
|
for( unsigned i = 0; i < files.GetCount(); ++i )
|
|
vec.push_back( files[i] );
|
|
|
|
wxLogTrace( traceAutoSave, wxS( "[history] Init: Creating initial snapshot with %zu files" ), vec.size() );
|
|
CommitSnapshot( vec, wxS( "Initial snapshot" ) );
|
|
|
|
// Tag the initial snapshot as saved so HeadNewerThanLastSave() doesn't
|
|
// incorrectly offer to restore when the project is first opened
|
|
TagSave( aProjectPath, wxS( "project" ) );
|
|
}
|
|
else
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] Init: No files found to add to initial snapshot" ) );
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
// Helper function to commit files using an already-acquired lock
|
|
enum class SNAPSHOT_COMMIT_RESULT
|
|
{
|
|
Error,
|
|
NoChanges,
|
|
Committed
|
|
};
|
|
|
|
|
|
static SNAPSHOT_COMMIT_RESULT commitSnapshotWithLock( git_repository* repo, git_index* index,
|
|
const wxString& aHistoryPath, const wxString& aProjectPath,
|
|
const std::vector<wxString>& aFiles, const wxString& aTitle )
|
|
{
|
|
std::vector<std::string> filesArrStr;
|
|
|
|
for( const wxString& file : aFiles )
|
|
{
|
|
wxFileName src( file );
|
|
wxString relPath;
|
|
|
|
if( src.GetFullPath().StartsWith( aProjectPath + wxFILE_SEP_PATH ) )
|
|
relPath = src.GetFullPath().Mid( aProjectPath.length() + 1 );
|
|
else
|
|
relPath = src.GetFullName(); // Fallback (should not normally happen)
|
|
|
|
relPath.Replace( "\\", "/" ); // libgit2 needs forward slashes on all platforms
|
|
std::string relPathStr = relPath.ToStdString();
|
|
|
|
unsigned int status = 0;
|
|
int rc = git_status_file( &status, repo, relPathStr.data() );
|
|
|
|
if( rc == 0 && status != 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "File %s status %d " ), relPath, status );
|
|
filesArrStr.emplace_back( relPathStr );
|
|
}
|
|
else if( rc != 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "File %s status error %d " ), relPath, rc );
|
|
filesArrStr.emplace_back( relPathStr ); // Add anyway even if the file is untracked.
|
|
}
|
|
}
|
|
|
|
std::vector<char*> cStrings( filesArrStr.size() );
|
|
|
|
for( size_t i = 0; i < filesArrStr.size(); i++ )
|
|
cStrings[i] = filesArrStr[i].data();
|
|
|
|
git_strarray filesArrGit;
|
|
filesArrGit.count = filesArrStr.size();
|
|
filesArrGit.strings = cStrings.data();
|
|
|
|
if( filesArrStr.size() == 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "No changes, skipping" ) );
|
|
return SNAPSHOT_COMMIT_RESULT::NoChanges;
|
|
}
|
|
|
|
int rc = git_index_add_all( index, &filesArrGit, GIT_INDEX_ADD_DISABLE_PATHSPEC_MATCH | GIT_INDEX_ADD_FORCE, NULL,
|
|
NULL );
|
|
wxLogTrace( traceAutoSave, wxS( "Adding %zu files, rc %d" ), filesArrStr.size(), rc );
|
|
|
|
if( rc != 0 )
|
|
return SNAPSHOT_COMMIT_RESULT::Error;
|
|
|
|
git_oid tree_id;
|
|
if( git_index_write_tree( &tree_id, index ) != 0 )
|
|
return SNAPSHOT_COMMIT_RESULT::Error;
|
|
|
|
git_tree* rawTree = nullptr;
|
|
git_tree_lookup( &rawTree, repo, &tree_id );
|
|
std::unique_ptr<git_tree, decltype( &git_tree_free )> tree( rawTree, &git_tree_free );
|
|
|
|
git_signature* rawSig = nullptr;
|
|
git_signature_now( &rawSig, "KiCad", "noreply@kicad.org" );
|
|
std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig,
|
|
&git_signature_free );
|
|
|
|
git_commit* rawParent = nullptr;
|
|
git_oid parent_id;
|
|
int parents = 0;
|
|
|
|
if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
|
|
{
|
|
git_commit_lookup( &rawParent, repo, &parent_id );
|
|
parents = 1;
|
|
}
|
|
|
|
std::unique_ptr<git_commit, decltype( &git_commit_free )> parent( rawParent,
|
|
&git_commit_free );
|
|
|
|
git_tree* rawParentTree = nullptr;
|
|
|
|
if( parent )
|
|
git_commit_tree( &rawParentTree, parent.get() );
|
|
|
|
std::unique_ptr<git_tree, decltype( &git_tree_free )> parentTree( rawParentTree, &git_tree_free );
|
|
|
|
git_diff* rawDiff = nullptr;
|
|
git_diff_tree_to_index( &rawDiff, repo, parentTree.get(), index, nullptr );
|
|
std::unique_ptr<git_diff, decltype( &git_diff_free )> diff( rawDiff, &git_diff_free );
|
|
|
|
size_t numChangedFiles = git_diff_num_deltas( diff.get() );
|
|
|
|
if( numChangedFiles == 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "No actual changes in tree, skipping commit" ) );
|
|
return SNAPSHOT_COMMIT_RESULT::NoChanges;
|
|
}
|
|
|
|
wxString msg;
|
|
|
|
if( !aTitle.IsEmpty() )
|
|
msg << aTitle << wxS( ": " );
|
|
|
|
msg << numChangedFiles << wxS( " files changed" );
|
|
|
|
for( size_t i = 0; i < numChangedFiles; ++i )
|
|
{
|
|
const git_diff_delta* delta = git_diff_get_delta( diff.get(), i );
|
|
git_patch* rawPatch = nullptr;
|
|
git_patch_from_diff( &rawPatch, diff.get(), i );
|
|
std::unique_ptr<git_patch, decltype( &git_patch_free )> patch( rawPatch,
|
|
&git_patch_free );
|
|
size_t context = 0, adds = 0, dels = 0;
|
|
git_patch_line_stats( &context, &adds, &dels, patch.get() );
|
|
size_t updated = std::min( adds, dels );
|
|
adds -= updated;
|
|
dels -= updated;
|
|
msg << wxS( "\n" ) << wxString::FromUTF8( delta->new_file.path )
|
|
<< wxS( " " ) << adds << wxS( "/" ) << dels << wxS( "/" ) << updated;
|
|
}
|
|
|
|
git_oid commit_id;
|
|
git_commit* parentPtr = parent.get();
|
|
const git_commit* constParentPtr = parentPtr;
|
|
if( git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr, msg.mb_str().data(), tree.get(),
|
|
parents, parentPtr ? &constParentPtr : nullptr )
|
|
!= 0 )
|
|
{
|
|
return SNAPSHOT_COMMIT_RESULT::Error;
|
|
}
|
|
|
|
git_index_write( index );
|
|
return SNAPSHOT_COMMIT_RESULT::Committed;
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::CommitSnapshot( const std::vector<wxString>& aFiles, const wxString& aTitle )
|
|
{
|
|
if( aFiles.empty() || !Pgm().GetCommonSettings()->m_Backup.enabled )
|
|
return true;
|
|
|
|
wxString proj = wxFileName( aFiles[0] ).GetPath();
|
|
wxString hist = historyPath( proj );
|
|
|
|
// Acquire locks using hybrid locking strategy
|
|
HISTORY_LOCK_MANAGER lock( proj );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS("[history] CommitSnapshot failed to acquire lock: %s"),
|
|
lock.GetLockError() );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
git_index* index = lock.GetIndex();
|
|
|
|
return commitSnapshotWithLock( repo, index, hist, proj, aFiles, aTitle ) == SNAPSHOT_COMMIT_RESULT::Committed;
|
|
}
|
|
|
|
|
|
// Helper to collect all project files (excluding .history, backups, and transient files)
|
|
static void collectProjectFiles( const wxString& aProjectPath, std::vector<wxString>& aFiles )
|
|
{
|
|
wxDir dir( aProjectPath );
|
|
|
|
if( !dir.IsOpened() )
|
|
return;
|
|
|
|
// Collect recursively.
|
|
std::function<void(const wxString&)> collect = [&]( const wxString& path )
|
|
{
|
|
wxString name;
|
|
wxDir d( path );
|
|
|
|
if( !d.IsOpened() )
|
|
return;
|
|
|
|
bool cont = d.GetFirst( &name );
|
|
|
|
while( cont )
|
|
{
|
|
if( name == wxS( ".history" ) || name.EndsWith( wxS( "-backups" ) ) )
|
|
{
|
|
cont = d.GetNext( &name );
|
|
continue; // Skip history repo itself.
|
|
}
|
|
|
|
wxFileName fn( path, name );
|
|
wxString fullPath = fn.GetFullPath();
|
|
|
|
if( wxFileName::DirExists( fullPath ) )
|
|
{
|
|
collect( fullPath );
|
|
}
|
|
else if( fn.FileExists() )
|
|
{
|
|
// Reuse NoteFileChange filters implicitly by skipping the transient names.
|
|
if( fn.GetFullName() != wxS( "fp-info-cache" ) )
|
|
aFiles.push_back( fn.GetFullPath() );
|
|
}
|
|
|
|
cont = d.GetNext( &name );
|
|
}
|
|
};
|
|
|
|
collect( aProjectPath );
|
|
}
|
|
|
|
|
|
bool LOCAL_HISTORY::CommitFullProjectSnapshot( const wxString& aProjectPath, const wxString& aTitle )
|
|
{
|
|
std::vector<wxString> files;
|
|
collectProjectFiles( aProjectPath, files );
|
|
|
|
if( files.empty() )
|
|
return false;
|
|
|
|
return CommitSnapshot( files, aTitle );
|
|
}
|
|
|
|
bool LOCAL_HISTORY::HistoryExists( const wxString& aProjectPath )
|
|
{
|
|
return wxDirExists( historyPath( aProjectPath ) );
|
|
}
|
|
|
|
bool LOCAL_HISTORY::TagSave( const wxString& aProjectPath, const wxString& aFileType )
|
|
{
|
|
HISTORY_LOCK_MANAGER lock( aProjectPath );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] TagSave: Failed to acquire lock for %s" ), aProjectPath );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
|
|
if( !repo )
|
|
return false;
|
|
|
|
git_oid head;
|
|
if( git_reference_name_to_id( &head, repo, "HEAD" ) != 0 )
|
|
return false;
|
|
|
|
wxString tagName;
|
|
int i = 1;
|
|
git_reference* ref = nullptr;
|
|
do
|
|
{
|
|
tagName.Printf( wxS( "Save_%s_%d" ), aFileType, i++ );
|
|
} while( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + tagName ).mb_str().data() ) == 0 );
|
|
|
|
git_oid tag_oid;
|
|
git_object* head_obj = nullptr;
|
|
git_object_lookup( &head_obj, repo, &head, GIT_OBJECT_COMMIT );
|
|
git_tag_create_lightweight( &tag_oid, repo, tagName.mb_str().data(), head_obj, 0 );
|
|
git_object_free( head_obj );
|
|
|
|
wxString lastName;
|
|
lastName.Printf( wxS( "Last_Save_%s" ), aFileType );
|
|
if( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + lastName ).mb_str().data() ) == 0 )
|
|
{
|
|
git_reference_delete( ref );
|
|
git_reference_free( ref );
|
|
}
|
|
|
|
git_oid last_tag_oid;
|
|
git_object* head_obj2 = nullptr;
|
|
git_object_lookup( &head_obj2, repo, &head, GIT_OBJECT_COMMIT );
|
|
git_tag_create_lightweight( &last_tag_oid, repo, lastName.mb_str().data(), head_obj2, 0 );
|
|
git_object_free( head_obj2 );
|
|
|
|
return true;
|
|
}
|
|
|
|
bool LOCAL_HISTORY::HeadNewerThanLastSave( const wxString& aProjectPath )
|
|
{
|
|
wxString hist = historyPath( aProjectPath );
|
|
git_repository* repo = nullptr;
|
|
|
|
if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
|
|
return false;
|
|
|
|
git_oid head_oid;
|
|
if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
|
|
{
|
|
git_repository_free( repo );
|
|
return false;
|
|
}
|
|
|
|
git_commit* head_commit = nullptr;
|
|
git_commit_lookup( &head_commit, repo, &head_oid );
|
|
git_time_t head_time = git_commit_time( head_commit );
|
|
|
|
git_strarray tags;
|
|
git_tag_list_match( &tags, "Last_Save_*", repo );
|
|
git_time_t save_time = 0;
|
|
|
|
for( size_t i = 0; i < tags.count; ++i )
|
|
{
|
|
git_reference* ref = nullptr;
|
|
if( git_reference_lookup( &ref, repo,
|
|
( wxS( "refs/tags/" ) +
|
|
wxString::FromUTF8( tags.strings[i] ) ).mb_str().data() ) == 0 )
|
|
{
|
|
const git_oid* oid = git_reference_target( ref );
|
|
git_commit* c = nullptr;
|
|
if( git_commit_lookup( &c, repo, oid ) == 0 )
|
|
{
|
|
git_time_t t = git_commit_time( c );
|
|
if( t > save_time )
|
|
save_time = t;
|
|
git_commit_free( c );
|
|
}
|
|
git_reference_free( ref );
|
|
}
|
|
}
|
|
|
|
git_strarray_free( &tags );
|
|
git_commit_free( head_commit );
|
|
git_repository_free( repo );
|
|
|
|
// If there are no Last_Save tags but there IS a HEAD commit, we have autosaved
|
|
// data that was never explicitly saved - offer to restore
|
|
if( save_time == 0 )
|
|
return true;
|
|
|
|
return head_time > save_time;
|
|
}
|
|
|
|
bool LOCAL_HISTORY::CommitDuplicateOfLastSave( const wxString& aProjectPath, const wxString& aFileType,
|
|
const wxString& aMessage )
|
|
{
|
|
HISTORY_LOCK_MANAGER lock( aProjectPath );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] CommitDuplicateOfLastSave: Failed to acquire lock for %s" ), aProjectPath );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
|
|
if( !repo )
|
|
return false;
|
|
|
|
wxString lastName; lastName.Printf( wxS("Last_Save_%s"), aFileType );
|
|
git_reference* lastRef = nullptr;
|
|
if( git_reference_lookup( &lastRef, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) != 0 )
|
|
return false; // no tag to duplicate
|
|
std::unique_ptr<git_reference, decltype( &git_reference_free )> lastRefPtr( lastRef, &git_reference_free );
|
|
|
|
const git_oid* lastOid = git_reference_target( lastRef );
|
|
git_commit* lastCommit = nullptr;
|
|
if( git_commit_lookup( &lastCommit, repo, lastOid ) != 0 )
|
|
return false;
|
|
std::unique_ptr<git_commit, decltype( &git_commit_free )> lastCommitPtr( lastCommit, &git_commit_free );
|
|
|
|
git_tree* lastTree = nullptr;
|
|
git_commit_tree( &lastTree, lastCommit );
|
|
std::unique_ptr<git_tree, decltype( &git_tree_free )> lastTreePtr( lastTree, &git_tree_free );
|
|
|
|
// Parent will be current HEAD (to keep linear history)
|
|
git_oid headOid;
|
|
git_commit* headCommit = nullptr;
|
|
int parents = 0;
|
|
const git_commit* parentArray[1];
|
|
if( git_reference_name_to_id( &headOid, repo, "HEAD" ) == 0 &&
|
|
git_commit_lookup( &headCommit, repo, &headOid ) == 0 )
|
|
{
|
|
parentArray[0] = headCommit;
|
|
parents = 1;
|
|
}
|
|
|
|
git_signature* sigRaw = nullptr;
|
|
git_signature_now( &sigRaw, "KiCad", "noreply@kicad.org" );
|
|
std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( sigRaw, &git_signature_free );
|
|
|
|
wxString msg = aMessage.IsEmpty() ? wxS("Discard unsaved ") + aFileType : aMessage;
|
|
git_oid newCommitOid;
|
|
int rc = git_commit_create( &newCommitOid, repo, "HEAD", sig.get(), sig.get(), nullptr,
|
|
msg.mb_str().data(), lastTree, parents, parents ? parentArray : nullptr );
|
|
if( headCommit ) git_commit_free( headCommit );
|
|
if( rc != 0 )
|
|
return false;
|
|
|
|
// Move Last_Save tag to new commit
|
|
git_reference* existing = nullptr;
|
|
if( git_reference_lookup( &existing, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) == 0 )
|
|
{
|
|
git_reference_delete( existing );
|
|
git_reference_free( existing );
|
|
}
|
|
git_object* newCommitObj = nullptr;
|
|
if( git_object_lookup( &newCommitObj, repo, &newCommitOid, GIT_OBJECT_COMMIT ) == 0 )
|
|
{
|
|
git_tag_create_lightweight( &newCommitOid, repo, lastName.mb_str().data(), newCommitObj, 0 );
|
|
git_object_free( newCommitObj );
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static size_t dirSizeRecursive( const wxString& path )
|
|
{
|
|
size_t total = 0;
|
|
wxDir dir( path );
|
|
if( !dir.IsOpened() )
|
|
return 0;
|
|
wxString name;
|
|
bool cont = dir.GetFirst( &name );
|
|
while( cont )
|
|
{
|
|
wxFileName fn( path, name );
|
|
wxString fullPath = fn.GetFullPath();
|
|
|
|
if( wxFileName::DirExists( fullPath ) )
|
|
total += dirSizeRecursive( fullPath );
|
|
else if( fn.FileExists() )
|
|
total += (size_t) fn.GetSize().GetValue();
|
|
cont = dir.GetNext( &name );
|
|
}
|
|
return total;
|
|
}
|
|
|
|
bool LOCAL_HISTORY::EnforceSizeLimit( const wxString& aProjectPath, size_t aMaxBytes )
|
|
{
|
|
if( aMaxBytes == 0 )
|
|
return false;
|
|
|
|
wxString hist = historyPath( aProjectPath );
|
|
|
|
if( !wxDirExists( hist ) )
|
|
return false;
|
|
|
|
size_t current = dirSizeRecursive( hist );
|
|
|
|
if( current <= aMaxBytes )
|
|
return true; // within limit
|
|
|
|
HISTORY_LOCK_MANAGER lock( aProjectPath );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Failed to acquire lock for %s" ), aProjectPath );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
|
|
if( !repo )
|
|
return false;
|
|
|
|
// Collect commits newest-first using revwalk
|
|
git_revwalk* walk = nullptr;
|
|
git_revwalk_new( &walk, repo );
|
|
git_revwalk_sorting( walk, GIT_SORT_TIME );
|
|
git_revwalk_push_head( walk );
|
|
std::vector<git_oid> commits;
|
|
git_oid oid;
|
|
|
|
while( git_revwalk_next( &oid, walk ) == 0 )
|
|
commits.push_back( oid );
|
|
|
|
git_revwalk_free( walk );
|
|
|
|
if( commits.empty() )
|
|
return true;
|
|
|
|
// Determine set of newest commits to keep based on blob sizes.
|
|
std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> seenBlobs(
|
|
[]( const git_oid& a, const git_oid& b )
|
|
{
|
|
return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
|
|
} );
|
|
|
|
size_t keptBytes = 0;
|
|
std::vector<git_oid> keep;
|
|
|
|
git_odb* odb = nullptr;
|
|
git_repository_odb( &odb, repo );
|
|
|
|
std::function<size_t( git_tree* )> accountTree = [&]( git_tree* tree )
|
|
{
|
|
size_t added = 0;
|
|
size_t cnt = git_tree_entrycount( tree );
|
|
|
|
for( size_t i = 0; i < cnt; ++i )
|
|
{
|
|
const git_tree_entry* entry = git_tree_entry_byindex( tree, i );
|
|
|
|
if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
|
|
{
|
|
const git_oid* bid = git_tree_entry_id( entry );
|
|
|
|
if( seenBlobs.find( *bid ) == seenBlobs.end() )
|
|
{
|
|
size_t len = 0;
|
|
git_object_t type = GIT_OBJECT_ANY;
|
|
|
|
if( odb && git_odb_read_header( &len, &type, odb, bid ) == 0 )
|
|
added += len;
|
|
|
|
seenBlobs.insert( *bid );
|
|
}
|
|
}
|
|
else if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
|
|
{
|
|
git_tree* sub = nullptr;
|
|
|
|
if( git_tree_lookup( &sub, repo, git_tree_entry_id( entry ) ) == 0 )
|
|
{
|
|
added += accountTree( sub );
|
|
git_tree_free( sub );
|
|
}
|
|
}
|
|
}
|
|
|
|
return added;
|
|
};
|
|
|
|
for( const git_oid& cOid : commits )
|
|
{
|
|
git_commit* c = nullptr;
|
|
|
|
if( git_commit_lookup( &c, repo, &cOid ) != 0 )
|
|
continue;
|
|
|
|
git_tree* tree = nullptr;
|
|
git_commit_tree( &tree, c );
|
|
size_t add = accountTree( tree );
|
|
git_tree_free( tree );
|
|
git_commit_free( c );
|
|
|
|
if( keep.empty() || keptBytes + add <= aMaxBytes )
|
|
{
|
|
keep.push_back( cOid );
|
|
keptBytes += add;
|
|
}
|
|
else
|
|
break; // stop once limit exceeded
|
|
}
|
|
|
|
git_odb_free( odb );
|
|
|
|
if( keep.empty() )
|
|
keep.push_back( commits.front() ); // ensure at least head
|
|
|
|
// Collect tags we want to preserve (Save_*/Last_Save_*). We'll recreate them if their
|
|
// target commit is retained. Also ensure tagged commits are ALWAYS kept.
|
|
std::vector<std::pair<wxString, git_oid>> tagTargets;
|
|
std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> taggedCommits(
|
|
[]( const git_oid& a, const git_oid& b )
|
|
{
|
|
return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
|
|
} );
|
|
git_strarray tagList;
|
|
|
|
if( git_tag_list( &tagList, repo ) == 0 )
|
|
{
|
|
for( size_t i = 0; i < tagList.count; ++i )
|
|
{
|
|
wxString name = wxString::FromUTF8( tagList.strings[i] );
|
|
if( name.StartsWith( wxS("Save_") ) || name.StartsWith( wxS("Last_Save_") ) )
|
|
{
|
|
git_reference* tref = nullptr;
|
|
|
|
if( git_reference_lookup( &tref, repo, ( wxS( "refs/tags/" ) + name ).mb_str().data() ) == 0 )
|
|
{
|
|
const git_oid* toid = git_reference_target( tref );
|
|
|
|
if( toid )
|
|
{
|
|
tagTargets.emplace_back( name, *toid );
|
|
taggedCommits.insert( *toid );
|
|
|
|
// Ensure this tagged commit is in the keep list
|
|
bool found = false;
|
|
for( const auto& k : keep )
|
|
{
|
|
if( memcmp( &k, toid, sizeof( git_oid ) ) == 0 )
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if( !found )
|
|
{
|
|
// Add tagged commit to keep list (even if it exceeds size limit)
|
|
keep.push_back( *toid );
|
|
wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Preserving tagged commit %s" ),
|
|
name );
|
|
}
|
|
}
|
|
|
|
git_reference_free( tref );
|
|
}
|
|
}
|
|
}
|
|
git_strarray_free( &tagList );
|
|
}
|
|
|
|
// Rebuild trimmed repo in temp dir
|
|
wxFileName trimFn( hist + wxS("_trim"), wxEmptyString );
|
|
wxString trimPath = trimFn.GetPath();
|
|
|
|
if( wxDirExists( trimPath ) )
|
|
wxFileName::Rmdir( trimPath, wxPATH_RMDIR_RECURSIVE );
|
|
|
|
wxMkdir( trimPath );
|
|
git_repository* newRepo = nullptr;
|
|
|
|
if( git_repository_init( &newRepo, trimPath.mb_str().data(), 0 ) != 0 )
|
|
return false;
|
|
|
|
// We will materialize each kept commit chronologically (oldest first) to preserve order.
|
|
std::reverse( keep.begin(), keep.end() );
|
|
git_commit* parent = nullptr;
|
|
struct MAP_ENTRY { git_oid orig; git_oid neu; };
|
|
std::vector<MAP_ENTRY> commitMap;
|
|
|
|
for( const git_oid& co : keep )
|
|
{
|
|
git_commit* orig = nullptr;
|
|
|
|
if( git_commit_lookup( &orig, repo, &co ) != 0 )
|
|
continue;
|
|
|
|
git_tree* tree = nullptr;
|
|
git_commit_tree( &tree, orig );
|
|
|
|
// Checkout tree into filesystem (simple extractor)
|
|
// Remove all files first (except .git).
|
|
wxArrayString toDelete;
|
|
wxDir d( trimPath );
|
|
wxString nm;
|
|
bool cont = d.GetFirst( &nm );
|
|
while( cont )
|
|
{
|
|
if( nm != wxS(".git") )
|
|
toDelete.Add( nm );
|
|
cont = d.GetNext( &nm );
|
|
}
|
|
|
|
for( auto& del : toDelete )
|
|
{
|
|
wxFileName f( trimPath, del );
|
|
if( f.DirExists() )
|
|
wxFileName::Rmdir( f.GetFullPath(), wxPATH_RMDIR_RECURSIVE );
|
|
else if( f.FileExists() )
|
|
wxRemoveFile( f.GetFullPath() );
|
|
}
|
|
|
|
// Recursively write tree entries
|
|
std::function<void(git_tree*, const wxString&)> writeTree = [&]( git_tree* t, const wxString& base )
|
|
{
|
|
size_t ecnt = git_tree_entrycount( t );
|
|
for( size_t i = 0; i < ecnt; ++i )
|
|
{
|
|
const git_tree_entry* e = git_tree_entry_byindex( t, i );
|
|
wxString name = wxString::FromUTF8( git_tree_entry_name( e ) );
|
|
|
|
if( git_tree_entry_type( e ) == GIT_OBJECT_TREE )
|
|
{
|
|
wxFileName dir( base, name );
|
|
wxMkdir( dir.GetFullPath() );
|
|
git_tree* sub = nullptr;
|
|
|
|
if( git_tree_lookup( &sub, repo, git_tree_entry_id( e ) ) == 0 )
|
|
{
|
|
writeTree( sub, dir.GetFullPath() );
|
|
git_tree_free( sub );
|
|
}
|
|
}
|
|
else if( git_tree_entry_type( e ) == GIT_OBJECT_BLOB )
|
|
{
|
|
git_blob* blob = nullptr;
|
|
|
|
if( git_blob_lookup( &blob, repo, git_tree_entry_id( e ) ) == 0 )
|
|
{
|
|
wxFileName file( base, name );
|
|
wxFFile f( file.GetFullPath(), wxT("wb") );
|
|
|
|
if( f.IsOpened() )
|
|
{
|
|
f.Write( (const char*) git_blob_rawcontent( blob ), git_blob_rawsize( blob ) );
|
|
f.Close();
|
|
}
|
|
git_blob_free( blob );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
writeTree( tree, trimPath );
|
|
|
|
git_index* newIndex = nullptr;
|
|
git_repository_index( &newIndex, newRepo );
|
|
git_index_add_all( newIndex, nullptr, 0, nullptr, nullptr );
|
|
git_index_write( newIndex );
|
|
git_oid newTreeOid;
|
|
git_index_write_tree( &newTreeOid, newIndex );
|
|
git_tree* newTree = nullptr;
|
|
git_tree_lookup( &newTree, newRepo, &newTreeOid );
|
|
|
|
// Recreate original author/committer signatures preserving timestamp.
|
|
const git_signature* origAuthor = git_commit_author( orig );
|
|
const git_signature* origCommitter = git_commit_committer( orig );
|
|
git_signature* sigAuthor = nullptr;
|
|
git_signature* sigCommitter = nullptr;
|
|
|
|
git_signature_new( &sigAuthor, origAuthor->name, origAuthor->email,
|
|
origAuthor->when.time, origAuthor->when.offset );
|
|
git_signature_new( &sigCommitter, origCommitter->name, origCommitter->email,
|
|
origCommitter->when.time, origCommitter->when.offset );
|
|
|
|
const git_commit* parents[1];
|
|
int parentCount = 0;
|
|
|
|
if( parent )
|
|
{
|
|
parents[0] = parent;
|
|
parentCount = 1;
|
|
}
|
|
|
|
git_oid newCommitOid;
|
|
git_commit_create( &newCommitOid, newRepo, "HEAD", sigAuthor, sigCommitter, nullptr, git_commit_message( orig ),
|
|
newTree, parentCount, parentCount ? parents : nullptr );
|
|
if( parent )
|
|
git_commit_free( parent );
|
|
|
|
git_commit_lookup( &parent, newRepo, &newCommitOid );
|
|
|
|
commitMap.emplace_back( co, newCommitOid );
|
|
|
|
git_signature_free( sigAuthor );
|
|
git_signature_free( sigCommitter );
|
|
git_tree_free( newTree );
|
|
git_index_free( newIndex );
|
|
git_tree_free( tree );
|
|
git_commit_free( orig );
|
|
}
|
|
|
|
if( parent )
|
|
git_commit_free( parent );
|
|
|
|
// Recreate preserved tags pointing to new commit OIDs where possible.
|
|
for( const auto& tt : tagTargets )
|
|
{
|
|
// Find mapping
|
|
const git_oid* newOid = nullptr;
|
|
|
|
for( const auto& m : commitMap )
|
|
{
|
|
if( memcmp( &m.orig, &tt.second, sizeof( git_oid ) ) == 0 )
|
|
{
|
|
newOid = &m.neu;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if( !newOid )
|
|
continue; // commit trimmed away
|
|
|
|
git_object* obj = nullptr;
|
|
|
|
if( git_object_lookup( &obj, newRepo, newOid, GIT_OBJECT_COMMIT ) == 0 )
|
|
{
|
|
git_oid tag_oid; git_tag_create_lightweight( &tag_oid, newRepo, tt.first.mb_str().data(), obj, 0 );
|
|
git_object_free( obj );
|
|
}
|
|
}
|
|
|
|
// Close both repositories before swapping directories to avoid file locking issues.
|
|
// Note: The lock manager will automatically free the original repo when it goes out of scope,
|
|
// but we need to manually free the new trimmed repo we created.
|
|
git_repository_free( newRepo );
|
|
|
|
// Replace old history dir with trimmed one
|
|
wxString backupOld = hist + wxS("_old");
|
|
wxRenameFile( hist, backupOld );
|
|
wxRenameFile( trimPath, hist );
|
|
wxFileName::Rmdir( backupOld, wxPATH_RMDIR_RECURSIVE );
|
|
return true;
|
|
}
|
|
|
|
wxString LOCAL_HISTORY::GetHeadHash( const wxString& aProjectPath )
|
|
{
|
|
wxString hist = historyPath( aProjectPath );
|
|
git_repository* repo = nullptr;
|
|
|
|
if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
|
|
return wxEmptyString;
|
|
|
|
git_oid head_oid;
|
|
if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
|
|
{
|
|
git_repository_free( repo );
|
|
return wxEmptyString;
|
|
}
|
|
|
|
wxString hash = wxString::FromUTF8( git_oid_tostr_s( &head_oid ) );
|
|
git_repository_free( repo );
|
|
return hash;
|
|
}
|
|
|
|
|
|
// Helper functions for RestoreCommit
|
|
namespace
|
|
{
|
|
|
|
/**
|
|
* Check if any files in the project are locked (open in editors).
|
|
*/
|
|
bool checkForLockedFiles( const wxString& aProjectPath, std::vector<wxString>& aLockedFiles )
|
|
{
|
|
std::function<void( const wxString& )> findLocks = [&]( const wxString& dirPath )
|
|
{
|
|
wxDir dir( dirPath );
|
|
if( !dir.IsOpened() )
|
|
return;
|
|
|
|
wxString filename;
|
|
bool cont = dir.GetFirst( &filename );
|
|
|
|
while( cont )
|
|
{
|
|
wxFileName fullPath( dirPath, filename );
|
|
|
|
// Skip special directories
|
|
if( filename == wxS(".history") || filename == wxS(".git") )
|
|
{
|
|
cont = dir.GetNext( &filename );
|
|
continue;
|
|
}
|
|
|
|
if( fullPath.DirExists() )
|
|
{
|
|
findLocks( fullPath.GetFullPath() );
|
|
}
|
|
else if( fullPath.FileExists()
|
|
&& filename.StartsWith( FILEEXT::LockFilePrefix )
|
|
&& filename.EndsWith( wxString( wxS( "." ) ) + FILEEXT::LockFileExtension ) )
|
|
{
|
|
// Reconstruct the original filename from the lock file name
|
|
// Lock files are: ~<original>.<ext>.lck -> need to get <original>.<ext>
|
|
wxString baseName = filename.Mid( FILEEXT::LockFilePrefix.length() );
|
|
baseName = baseName.BeforeLast( '.' ); // Remove .lck
|
|
wxFileName originalFile( dirPath, baseName );
|
|
|
|
// Check if this is a valid LOCKFILE (not stale and not ours)
|
|
LOCKFILE testLock( originalFile.GetFullPath() );
|
|
if( testLock.Valid() && !testLock.IsLockedByMe() )
|
|
{
|
|
aLockedFiles.push_back( fullPath.GetFullPath() );
|
|
}
|
|
}
|
|
|
|
cont = dir.GetNext( &filename );
|
|
}
|
|
};
|
|
|
|
findLocks( aProjectPath );
|
|
return aLockedFiles.empty();
|
|
}
|
|
|
|
|
|
/**
|
|
* Extract a git tree to a temporary directory.
|
|
*/
|
|
bool extractCommitToTemp( git_repository* aRepo, git_tree* aTree, const wxString& aTempPath )
|
|
{
|
|
bool extractSuccess = true;
|
|
|
|
std::function<void( git_tree*, const wxString& )> extractTree =
|
|
[&]( git_tree* t, const wxString& prefix )
|
|
{
|
|
if( !extractSuccess )
|
|
return;
|
|
|
|
size_t cnt = git_tree_entrycount( t );
|
|
for( size_t i = 0; i < cnt; ++i )
|
|
{
|
|
const git_tree_entry* entry = git_tree_entry_byindex( t, i );
|
|
wxString name = wxString::FromUTF8( git_tree_entry_name( entry ) );
|
|
wxString fullPath = prefix.IsEmpty() ? name : prefix + wxS("/") + name;
|
|
|
|
if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
|
|
{
|
|
wxFileName dirPath( aTempPath + wxFileName::GetPathSeparator() + fullPath,
|
|
wxEmptyString );
|
|
if( !wxFileName::Mkdir( dirPath.GetPath(), 0777, wxPATH_MKDIR_FULL ) )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] extractCommitToTemp: Failed to create directory '%s'" ),
|
|
dirPath.GetPath() );
|
|
extractSuccess = false;
|
|
return;
|
|
}
|
|
|
|
git_tree* sub = nullptr;
|
|
if( git_tree_lookup( &sub, aRepo, git_tree_entry_id( entry ) ) == 0 )
|
|
{
|
|
extractTree( sub, fullPath );
|
|
git_tree_free( sub );
|
|
}
|
|
}
|
|
else if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
|
|
{
|
|
git_blob* blob = nullptr;
|
|
if( git_blob_lookup( &blob, aRepo, git_tree_entry_id( entry ) ) == 0 )
|
|
{
|
|
wxFileName dst( aTempPath + wxFileName::GetPathSeparator() + fullPath );
|
|
|
|
wxFileName dstDir( dst );
|
|
dstDir.SetFullName( wxEmptyString );
|
|
wxFileName::Mkdir( dstDir.GetPath(), 0777, wxPATH_MKDIR_FULL );
|
|
|
|
wxFFile f( dst.GetFullPath(), wxT( "wb" ) );
|
|
if( f.IsOpened() )
|
|
{
|
|
f.Write( git_blob_rawcontent( blob ), git_blob_rawsize( blob ) );
|
|
f.Close();
|
|
}
|
|
else
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] extractCommitToTemp: Failed to write '%s'" ),
|
|
dst.GetFullPath() );
|
|
extractSuccess = false;
|
|
git_blob_free( blob );
|
|
return;
|
|
}
|
|
|
|
git_blob_free( blob );
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
extractTree( aTree, wxEmptyString );
|
|
return extractSuccess;
|
|
}
|
|
|
|
|
|
/**
|
|
* Collect all files in a directory into a set (recursively).
|
|
*/
|
|
void collectFilesInDirectory( const wxString& aRootPath, const wxString& aSearchPath,
|
|
std::set<wxString>& aFiles )
|
|
{
|
|
wxDir dir( aSearchPath );
|
|
if( !dir.IsOpened() )
|
|
return;
|
|
|
|
wxString filename;
|
|
bool cont = dir.GetFirst( &filename );
|
|
|
|
while( cont )
|
|
{
|
|
wxFileName fullPath( aSearchPath, filename );
|
|
wxString relativePath = fullPath.GetFullPath().Mid( aRootPath.Length() + 1 );
|
|
|
|
if( fullPath.IsDir() && fullPath.DirExists() )
|
|
{
|
|
collectFilesInDirectory( aRootPath, fullPath.GetFullPath(), aFiles );
|
|
}
|
|
else if( fullPath.FileExists() )
|
|
{
|
|
aFiles.insert( relativePath );
|
|
}
|
|
|
|
cont = dir.GetNext( &filename );
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Check if a file should be excluded from backup (and thus not deleted during restore).
|
|
*/
|
|
bool shouldExcludeFromBackup( const wxString& aFilename )
|
|
{
|
|
// Files explicitly excluded from backup should not be deleted during restore
|
|
return aFilename == wxS( "fp-info-cache" );
|
|
}
|
|
|
|
|
|
/**
|
|
* Find files in current project that won't exist in the restored version.
|
|
*/
|
|
void findFilesToDelete( const wxString& aProjectPath, const std::set<wxString>& aRestoredFiles,
|
|
std::vector<wxString>& aFilesToDelete )
|
|
{
|
|
std::function<void( const wxString&, const wxString& )> scanDirectory =
|
|
[&]( const wxString& dirPath, const wxString& relativeBase )
|
|
{
|
|
wxDir dir( dirPath );
|
|
if( !dir.IsOpened() )
|
|
return;
|
|
|
|
wxString filename;
|
|
bool cont = dir.GetFirst( &filename );
|
|
|
|
while( cont )
|
|
{
|
|
// Skip special directories
|
|
if( filename == wxS(".history") || filename == wxS(".git") ||
|
|
filename == wxS("_restore_backup") || filename == wxS("_restore_temp") )
|
|
{
|
|
cont = dir.GetNext( &filename );
|
|
continue;
|
|
}
|
|
|
|
wxFileName fullPath( dirPath, filename );
|
|
wxString relativePath = relativeBase.IsEmpty() ? filename :
|
|
relativeBase + wxS("/") + filename;
|
|
|
|
if( fullPath.IsDir() && fullPath.DirExists() )
|
|
{
|
|
scanDirectory( fullPath.GetFullPath(), relativePath );
|
|
}
|
|
else if( fullPath.FileExists() )
|
|
{
|
|
// Check if this file exists in the restored commit
|
|
if( aRestoredFiles.find( relativePath ) == aRestoredFiles.end() )
|
|
{
|
|
// Don't propose deletion of files that were never in backup scope
|
|
if( !shouldExcludeFromBackup( filename ) )
|
|
aFilesToDelete.push_back( relativePath );
|
|
}
|
|
}
|
|
|
|
cont = dir.GetNext( &filename );
|
|
}
|
|
};
|
|
|
|
scanDirectory( aProjectPath, wxEmptyString );
|
|
}
|
|
|
|
|
|
/**
|
|
* Show confirmation dialog for files that will be deleted.
|
|
* Returns true to proceed, false to abort. Sets aKeepAllFiles based on user choice.
|
|
*/
|
|
bool confirmFileDeletion( wxWindow* aParent, const std::vector<wxString>& aFilesToDelete,
|
|
bool& aKeepAllFiles )
|
|
{
|
|
if( aFilesToDelete.empty() || !aParent )
|
|
{
|
|
aKeepAllFiles = false;
|
|
return true;
|
|
}
|
|
|
|
wxString message = _( "The following files will be deleted when restoring this commit:\n\n" );
|
|
|
|
// Limit display to first 20 files to avoid overwhelming dialog
|
|
size_t displayCount = std::min( aFilesToDelete.size(), size_t(20) );
|
|
for( size_t i = 0; i < displayCount; ++i )
|
|
{
|
|
message += wxS(" • ") + aFilesToDelete[i] + wxS("\n");
|
|
}
|
|
|
|
if( aFilesToDelete.size() > displayCount )
|
|
{
|
|
message += wxString::Format( _( "\n... and %zu more files\n" ),
|
|
aFilesToDelete.size() - displayCount );
|
|
}
|
|
|
|
KICAD_MESSAGE_DIALOG dlg( aParent, message, _( "Delete Files during Restore" ),
|
|
wxYES_NO | wxCANCEL | wxICON_QUESTION );
|
|
dlg.SetYesNoCancelLabels( _( "Proceed" ), _( "Keep All Files" ), _( "Abort" ) );
|
|
dlg.SetExtendedMessage(
|
|
_( "Choosing 'Keep All Files' will restore the selected commit but retain any existing "
|
|
"files in the project directory. Choosing 'Proceed' will delete files that are not "
|
|
"present in the restored commit." ) );
|
|
|
|
int choice = dlg.ShowModal();
|
|
|
|
if( choice == wxID_CANCEL )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] User cancelled restore" ) );
|
|
return false;
|
|
}
|
|
else if( choice == wxID_NO ) // Keep All Files
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] User chose to keep all files" ) );
|
|
aKeepAllFiles = true;
|
|
}
|
|
else // Proceed with deletion
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] User chose to proceed with deletion" ) );
|
|
aKeepAllFiles = false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Backup current project files before restore.
|
|
*/
|
|
bool backupCurrentFiles( const wxString& aProjectPath, const wxString& aBackupPath,
|
|
const wxString& aTempRestorePath, bool aKeepAllFiles,
|
|
std::set<wxString>& aBackedUpFiles )
|
|
{
|
|
wxDir currentDir( aProjectPath );
|
|
if( !currentDir.IsOpened() )
|
|
return false;
|
|
|
|
wxString filename;
|
|
bool cont = currentDir.GetFirst( &filename );
|
|
|
|
while( cont )
|
|
{
|
|
if( filename != wxS( ".history" ) && filename != wxS( ".git" ) &&
|
|
filename != wxS( "_restore_backup" ) && filename != wxS( "_restore_temp" ) )
|
|
{
|
|
// If keepAllFiles is true, only backup files that will be overwritten
|
|
bool shouldBackup = !aKeepAllFiles;
|
|
|
|
if( aKeepAllFiles )
|
|
{
|
|
// Check if this file exists in the restored commit
|
|
wxFileName testPath( aTempRestorePath, filename );
|
|
shouldBackup = testPath.Exists();
|
|
}
|
|
|
|
if( shouldBackup )
|
|
{
|
|
wxFileName source( aProjectPath, filename );
|
|
wxFileName dest( aBackupPath, filename );
|
|
|
|
// Create backup directory if needed
|
|
if( !wxDirExists( aBackupPath ) )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] backupCurrentFiles: Creating backup directory %s" ),
|
|
aBackupPath );
|
|
wxFileName::Mkdir( aBackupPath, 0777, wxPATH_MKDIR_FULL );
|
|
}
|
|
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] backupCurrentFiles: Backing up '%s' to '%s'" ),
|
|
source.GetFullPath(), dest.GetFullPath() );
|
|
|
|
if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] backupCurrentFiles: Failed to backup '%s'" ),
|
|
source.GetFullPath() );
|
|
return false;
|
|
}
|
|
|
|
aBackedUpFiles.insert( filename );
|
|
}
|
|
}
|
|
cont = currentDir.GetNext( &filename );
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Restore files from temporary location to project directory.
|
|
*/
|
|
bool restoreFilesFromTemp( const wxString& aTempRestorePath, const wxString& aProjectPath,
|
|
std::set<wxString>& aRestoredFiles )
|
|
{
|
|
wxDir tempDir( aTempRestorePath );
|
|
if( !tempDir.IsOpened() )
|
|
return false;
|
|
|
|
wxString filename;
|
|
bool cont = tempDir.GetFirst( &filename );
|
|
|
|
while( cont )
|
|
{
|
|
wxFileName source( aTempRestorePath, filename );
|
|
wxFileName dest( aProjectPath, filename );
|
|
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] restoreFilesFromTemp: Restoring '%s' to '%s'" ),
|
|
source.GetFullPath(), dest.GetFullPath() );
|
|
|
|
if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] restoreFilesFromTemp: Failed to move '%s'" ),
|
|
source.GetFullPath() );
|
|
return false;
|
|
}
|
|
|
|
aRestoredFiles.insert( filename );
|
|
cont = tempDir.GetNext( &filename );
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Rollback a failed restore operation.
|
|
*/
|
|
void rollbackRestore( const wxString& aProjectPath, const wxString& aBackupPath,
|
|
const wxString& aTempRestorePath, const std::set<wxString>& aBackedUpFiles,
|
|
const std::set<wxString>& aRestoredFiles )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Rolling back due to failure" ) );
|
|
|
|
// Remove ONLY the files we successfully moved from temp directory
|
|
// This preserves any files that were NOT in the backup (never tracked in history)
|
|
for( const wxString& filename : aRestoredFiles )
|
|
{
|
|
wxFileName toRemove( aProjectPath, filename );
|
|
wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Removing '%s'" ),
|
|
toRemove.GetFullPath() );
|
|
|
|
if( toRemove.DirExists() )
|
|
{
|
|
wxFileName::Rmdir( toRemove.GetFullPath(), wxPATH_RMDIR_RECURSIVE );
|
|
}
|
|
else if( toRemove.FileExists() )
|
|
{
|
|
wxRemoveFile( toRemove.GetFullPath() );
|
|
}
|
|
}
|
|
|
|
// Restore from backup - put back only what we moved
|
|
if( wxDirExists( aBackupPath ) )
|
|
{
|
|
for( const wxString& filename : aBackedUpFiles )
|
|
{
|
|
wxFileName source( aBackupPath, filename );
|
|
wxFileName dest( aProjectPath, filename );
|
|
|
|
if( source.Exists() )
|
|
{
|
|
wxRenameFile( source.GetFullPath(), dest.GetFullPath() );
|
|
wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Restored '%s'" ),
|
|
dest.GetFullPath() );
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up temporary directories
|
|
wxFileName::Rmdir( aTempRestorePath, wxPATH_RMDIR_RECURSIVE );
|
|
wxFileName::Rmdir( aBackupPath, wxPATH_RMDIR_RECURSIVE );
|
|
}
|
|
|
|
|
|
/**
|
|
* Record the restore operation in git history.
|
|
*/
|
|
bool recordRestoreInHistory( git_repository* aRepo, git_commit* aCommit, git_tree* aTree,
|
|
const wxString& aHash )
|
|
{
|
|
git_time_t t = git_commit_time( aCommit );
|
|
wxDateTime dt( (time_t) t );
|
|
git_signature* sig = nullptr;
|
|
git_signature_now( &sig, "KiCad", "noreply@kicad.org" );
|
|
git_commit* parent = nullptr;
|
|
git_oid parent_id;
|
|
|
|
if( git_reference_name_to_id( &parent_id, aRepo, "HEAD" ) == 0 )
|
|
git_commit_lookup( &parent, aRepo, &parent_id );
|
|
|
|
wxString msg;
|
|
msg.Printf( wxS( "Restored from %s %s" ), aHash, dt.FormatISOCombined().c_str() );
|
|
|
|
git_oid new_id;
|
|
const git_commit* constParent = parent;
|
|
int result = git_commit_create( &new_id, aRepo, "HEAD", sig, sig, nullptr,
|
|
msg.mb_str().data(), aTree, parent ? 1 : 0,
|
|
parent ? &constParent : nullptr );
|
|
|
|
if( parent )
|
|
git_commit_free( parent );
|
|
git_signature_free( sig );
|
|
|
|
return result == 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
|
|
bool LOCAL_HISTORY::RestoreCommit( const wxString& aProjectPath, const wxString& aHash,
|
|
wxWindow* aParent )
|
|
{
|
|
// STEP 1: Verify no files are open by checking for LOCKFILEs
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Checking for open files in %s" ),
|
|
aProjectPath );
|
|
|
|
std::vector<wxString> lockedFiles;
|
|
if( !checkForLockedFiles( aProjectPath, lockedFiles ) )
|
|
{
|
|
wxString lockList;
|
|
for( const auto& f : lockedFiles )
|
|
lockList += wxS("\n - ") + f;
|
|
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] RestoreCommit: Cannot restore - files are open:%s" ),
|
|
lockList );
|
|
|
|
// Show user-visible warning dialog
|
|
if( aParent )
|
|
{
|
|
wxString msg = _( "Cannot restore - the following files are open by another user:" );
|
|
msg += lockList;
|
|
wxMessageBox( msg, _( "Restore Failed" ), wxOK | wxICON_WARNING, aParent );
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// STEP 2: Acquire history lock and verify target commit
|
|
HISTORY_LOCK_MANAGER lock( aProjectPath );
|
|
|
|
if( !lock.IsLocked() )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] RestoreCommit: Failed to acquire lock for %s" ),
|
|
aProjectPath );
|
|
return false;
|
|
}
|
|
|
|
git_repository* repo = lock.GetRepository();
|
|
if( !repo )
|
|
return false;
|
|
|
|
// Verify the target commit exists
|
|
git_oid oid;
|
|
if( git_oid_fromstr( &oid, aHash.mb_str().data() ) != 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Invalid hash %s" ), aHash );
|
|
return false;
|
|
}
|
|
|
|
git_commit* commit = nullptr;
|
|
if( git_commit_lookup( &commit, repo, &oid ) != 0 )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Commit not found %s" ), aHash );
|
|
return false;
|
|
}
|
|
|
|
git_tree* tree = nullptr;
|
|
git_commit_tree( &tree, commit );
|
|
|
|
// Create pre-restore backup snapshot using the existing lock
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Creating pre-restore backup" ) );
|
|
|
|
std::vector<wxString> backupFiles;
|
|
collectProjectFiles( aProjectPath, backupFiles );
|
|
|
|
if( !backupFiles.empty() )
|
|
{
|
|
wxString hist = historyPath( aProjectPath );
|
|
SNAPSHOT_COMMIT_RESULT backupResult = commitSnapshotWithLock( repo, lock.GetIndex(), hist, aProjectPath,
|
|
backupFiles, wxS( "Pre-restore backup" ) );
|
|
|
|
if( backupResult == SNAPSHOT_COMMIT_RESULT::Error )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] RestoreCommit: Failed to create pre-restore backup" ) );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
if( backupResult == SNAPSHOT_COMMIT_RESULT::NoChanges )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Current state already matches HEAD; "
|
|
"continuing without a new backup commit" ) );
|
|
}
|
|
}
|
|
|
|
// STEP 3: Extract commit to temporary location
|
|
wxString tempRestorePath = aProjectPath + wxS("_restore_temp");
|
|
|
|
if( wxDirExists( tempRestorePath ) )
|
|
wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
|
|
|
|
if( !wxFileName::Mkdir( tempRestorePath, 0777, wxPATH_MKDIR_FULL ) )
|
|
{
|
|
wxLogTrace( traceAutoSave,
|
|
wxS( "[history] RestoreCommit: Failed to create temp directory %s" ),
|
|
tempRestorePath );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extracting to temp location %s" ),
|
|
tempRestorePath );
|
|
|
|
if( !extractCommitToTemp( repo, tree, tempRestorePath ) )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extraction failed, cleaning up" ) );
|
|
wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
// STEP 4: Determine which files will be deleted and ask for confirmation
|
|
std::set<wxString> restoredFiles;
|
|
collectFilesInDirectory( tempRestorePath, tempRestorePath, restoredFiles );
|
|
|
|
std::vector<wxString> filesToDelete;
|
|
findFilesToDelete( aProjectPath, restoredFiles, filesToDelete );
|
|
|
|
bool keepAllFiles = true;
|
|
if( !confirmFileDeletion( aParent, filesToDelete, keepAllFiles ) )
|
|
{
|
|
// User cancelled
|
|
wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
// STEP 5: Perform atomic swap - backup current, move temp to current
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Performing atomic swap" ) );
|
|
|
|
wxString backupPath = aProjectPath + wxS("_restore_backup");
|
|
|
|
// Remove old backup if exists
|
|
if( wxDirExists( backupPath ) )
|
|
{
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Removing old backup %s" ),
|
|
backupPath );
|
|
wxFileName::Rmdir( backupPath, wxPATH_RMDIR_RECURSIVE );
|
|
}
|
|
|
|
// Track which files we moved to backup and restored (for rollback)
|
|
std::set<wxString> backedUpFiles;
|
|
std::set<wxString> restoredFilesSet;
|
|
|
|
// Backup current files
|
|
if( !backupCurrentFiles( aProjectPath, backupPath, tempRestorePath, keepAllFiles,
|
|
backedUpFiles ) )
|
|
{
|
|
rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
|
|
restoredFilesSet );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
// Restore files from temp
|
|
if( !restoreFilesFromTemp( tempRestorePath, aProjectPath, restoredFilesSet ) )
|
|
{
|
|
rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
|
|
restoredFilesSet );
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
return false;
|
|
}
|
|
|
|
// SUCCESS - Clean up temp and backup directories
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Restore successful, cleaning up" ) );
|
|
wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
|
|
wxFileName::Rmdir( backupPath, wxPATH_RMDIR_RECURSIVE );
|
|
|
|
// Record the restore in history
|
|
recordRestoreInHistory( repo, commit, tree, aHash );
|
|
|
|
git_tree_free( tree );
|
|
git_commit_free( commit );
|
|
|
|
wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Complete" ) );
|
|
return true;
|
|
}
|
|
|
|
void LOCAL_HISTORY::ShowRestoreDialog( const wxString& aProjectPath, wxWindow* aParent )
|
|
{
|
|
if( !HistoryExists( aProjectPath ) )
|
|
return;
|
|
|
|
std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> snapshots = LoadSnapshots( aProjectPath );
|
|
|
|
if( snapshots.empty() )
|
|
return;
|
|
|
|
DIALOG_RESTORE_LOCAL_HISTORY dlg( aParent, snapshots );
|
|
|
|
if( dlg.ShowModal() == wxID_OK )
|
|
{
|
|
wxString selectedHash = dlg.GetSelectedHash();
|
|
|
|
if( !selectedHash.IsEmpty() )
|
|
RestoreCommit( aProjectPath, selectedHash, aParent );
|
|
}
|
|
}
|
|
|
|
std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> LOCAL_HISTORY::LoadSnapshots( const wxString& aProjectPath )
|
|
{
|
|
std::vector<LOCAL_HISTORY_SNAPSHOT_INFO> snapshots;
|
|
|
|
wxString hist = historyPath( aProjectPath );
|
|
git_repository* repo = nullptr;
|
|
|
|
if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
|
|
return snapshots;
|
|
|
|
git_revwalk* walk = nullptr;
|
|
if( git_revwalk_new( &walk, repo ) != 0 )
|
|
{
|
|
git_repository_free( repo );
|
|
return snapshots;
|
|
}
|
|
|
|
git_revwalk_push_head( walk );
|
|
|
|
git_oid oid;
|
|
|
|
while( git_revwalk_next( &oid, walk ) == 0 )
|
|
{
|
|
git_commit* commit = nullptr;
|
|
|
|
if( git_commit_lookup( &commit, repo, &oid ) != 0 )
|
|
continue;
|
|
|
|
LOCAL_HISTORY_SNAPSHOT_INFO info;
|
|
info.hash = wxString::FromUTF8( git_oid_tostr_s( &oid ) );
|
|
info.date = wxDateTime( static_cast<time_t>( git_commit_time( commit ) ) );
|
|
info.message = wxString::FromUTF8( git_commit_message( commit ) );
|
|
|
|
wxString firstLine = info.message.BeforeFirst( '\n' );
|
|
|
|
long parsedCount = 0;
|
|
wxString remainder;
|
|
firstLine.BeforeFirst( ':', &remainder );
|
|
remainder.Trim( true ).Trim( false );
|
|
|
|
if( remainder.EndsWith( wxS( "files changed" ) ) )
|
|
{
|
|
wxString countText = remainder.BeforeFirst( ' ' );
|
|
|
|
if( countText.ToLong( &parsedCount ) )
|
|
info.filesChanged = static_cast<int>( parsedCount );
|
|
}
|
|
|
|
info.summary = firstLine.BeforeFirst( ':' );
|
|
|
|
wxString rest;
|
|
info.message.BeforeFirst( '\n', &rest );
|
|
wxArrayString lines = wxSplit( rest, '\n', '\0' );
|
|
|
|
for( const wxString& line : lines )
|
|
{
|
|
if( !line.IsEmpty() )
|
|
info.changedFiles.Add( line );
|
|
}
|
|
|
|
snapshots.push_back( std::move( info ) );
|
|
git_commit_free( commit );
|
|
}
|
|
|
|
git_revwalk_free( walk );
|
|
git_repository_free( repo );
|
|
return snapshots;
|
|
}
|