KiCad PCB EDA Suite
Loading...
Searching...
No Matches
local_history.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 3
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program; if not, you may find one here:
18 * http://www.gnu.org/licenses/gpl-3.0.html
19 * or you may search the http://www.gnu.org website for the version 3 license,
20 * or you may write to the Free Software Foundation, Inc.,
21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
22 */
23
24#include <local_history.h>
25#include <history_lock.h>
26#include <lockfile.h>
28#include <pgm_base.h>
29#include <trace_helpers.h>
31#include <confirm.h>
32
33#include <git2.h>
34#include <wx/filename.h>
35#include <wx/filefn.h>
36#include <wx/ffile.h>
37#include <wx/dir.h>
38#include <wx/datetime.h>
39#include <wx/log.h>
40#include <wx/msgdlg.h>
41#include <wx/choicdlg.h>
42
43#include <vector>
44#include <string>
45#include <memory>
46#include <algorithm>
47#include <set>
48#include <map>
49#include <functional>
50#include <cstring>
51
52static wxString historyPath( const wxString& aProjectPath )
53{
54 wxFileName p( aProjectPath, wxEmptyString );
55 p.AppendDir( wxS( ".history" ) );
56 return p.GetPath();
57}
58
62
66
67void LOCAL_HISTORY::NoteFileChange( const wxString& aFile )
68{
69 wxFileName fn( aFile );
70
71 if( fn.GetFullName() == wxS( "fp-info-cache" ) || !Pgm().GetCommonSettings()->m_Backup.enabled )
72 return;
73
74 m_pendingFiles.insert( fn.GetFullPath() );
75}
76
77
78void LOCAL_HISTORY::RegisterSaver( const void* aSaverObject,
79 const std::function<void( const wxString&, std::vector<wxString>& )>& aSaver )
80{
81 if( m_savers.find( aSaverObject ) != m_savers.end() )
82 {
83 wxLogTrace( traceAutoSave, wxS("[history] Saver %p already registered, skipping"), aSaverObject );
84 return;
85 }
86
87 m_savers[aSaverObject] = aSaver;
88 wxLogTrace( traceAutoSave, wxS("[history] Registered saver %p (total=%zu)"), aSaverObject, m_savers.size() );
89}
90
91
92void LOCAL_HISTORY::UnregisterSaver( const void* aSaverObject )
93{
94 auto it = m_savers.find( aSaverObject );
95
96 if( it != m_savers.end() )
97 {
98 m_savers.erase( it );
99 wxLogTrace( traceAutoSave, wxS("[history] Unregistered saver %p (total=%zu)"), aSaverObject, m_savers.size() );
100 }
101}
102
103
105{
106 m_savers.clear();
107 wxLogTrace( traceAutoSave, wxS("[history] Cleared all savers") );
108}
109
110
111bool LOCAL_HISTORY::RunRegisteredSaversAndCommit( const wxString& aProjectPath, const wxString& aTitle )
112{
113 if( !Pgm().GetCommonSettings()->m_Backup.enabled )
114 {
115 wxLogTrace( traceAutoSave, wxS("Autosave disabled, returning" ) );
116 return true;
117 }
118
119 wxLogTrace( traceAutoSave, wxS("[history] RunRegisteredSaversAndCommit start project='%s' title='%s' savers=%zu"),
120 aProjectPath, aTitle, m_savers.size() );
121
122 if( m_savers.empty() )
123 {
124 wxLogTrace( traceAutoSave, wxS("[history] no savers registered; skipping") );
125 return false;
126 }
127
128 std::vector<wxString> files;
129
130 for( const auto& [saverObject, saver] : m_savers )
131 {
132 size_t before = files.size();
133 saver( aProjectPath, files );
134 wxLogTrace( traceAutoSave, wxS("[history] saver %p added %zu files (total=%zu)"),
135 saverObject, files.size() - before, files.size() );
136 }
137
138 // Filter out any files not within the project directory
139 wxString projectDir = aProjectPath;
140 if( !projectDir.EndsWith( wxFileName::GetPathSeparator() ) )
141 projectDir += wxFileName::GetPathSeparator();
142
143 auto it = std::remove_if( files.begin(), files.end(),
144 [&projectDir]( const wxString& file )
145 {
146 if( !file.StartsWith( projectDir ) )
147 {
148 wxLogTrace( traceAutoSave, wxS("[history] filtered out file outside project: %s"), file );
149 return true;
150 }
151 return false;
152 } );
153 files.erase( it, files.end() );
154
155 if( files.empty() )
156 {
157 wxLogTrace( traceAutoSave, wxS("[history] saver set produced no files; skipping") );
158 return false;
159 }
160
161 // Acquire locks using hybrid locking strategy
162 HISTORY_LOCK_MANAGER lock( aProjectPath );
163
164 if( !lock.IsLocked() )
165 {
166 wxLogTrace( traceAutoSave, wxS("[history] failed to acquire lock: %s"), lock.GetLockError() );
167 return false;
168 }
169
170 git_repository* repo = lock.GetRepository();
171 git_index* index = lock.GetIndex();
172 wxString hist = historyPath( aProjectPath );
173
174 // Stage selected files (mirroring logic from CommitSnapshot but limited to given files)
175 for( const wxString& file : files )
176 {
177 wxFileName src( file );
178
179 if( !src.FileExists() )
180 {
181 wxLogTrace( traceAutoSave, wxS("[history] skip missing '%s'"), file );
182 continue;
183 }
184
185 // If saver already produced a path inside history mirror, just stage it.
186 if( src.GetFullPath().StartsWith( hist + wxFILE_SEP_PATH ) )
187 {
188 std::string relHist = src.GetFullPath().ToStdString().substr( hist.length() + 1 );
189 git_index_add_bypath( index, relHist.c_str() );
190 wxLogTrace( traceAutoSave, wxS("[history] staged pre-mirrored '%s'"), file );
191 continue;
192 }
193
194 wxString relStr;
195 wxString proj = wxFileName( aProjectPath ).GetFullPath();
196
197 if( src.GetFullPath().StartsWith( proj + wxFILE_SEP_PATH ) )
198 relStr = src.GetFullPath().Mid( proj.length() + 1 );
199 else
200 relStr = src.GetFullName();
201
202 wxFileName dst( hist + wxFILE_SEP_PATH + relStr );
203 wxFileName dstDir( dst );
204 dstDir.SetFullName( wxEmptyString );
205 wxFileName::Mkdir( dstDir.GetPath(), 0777, wxPATH_MKDIR_FULL );
206 wxCopyFile( src.GetFullPath(), dst.GetFullPath(), true );
207 std::string rel = dst.GetFullPath().ToStdString().substr( hist.length() + 1 );
208 git_index_add_bypath( index, rel.c_str() );
209 wxLogTrace( traceAutoSave, wxS("[history] staged '%s' as '%s'"), file, wxString::FromUTF8( rel ) );
210 }
211
212 // Compare index to HEAD; if no diff -> abort to avoid empty commit.
213 git_oid head_oid;
214 git_commit* head_commit = nullptr;
215 git_tree* head_tree = nullptr;
216
217 bool headExists = ( git_reference_name_to_id( &head_oid, repo, "HEAD" ) == 0 )
218 && ( git_commit_lookup( &head_commit, repo, &head_oid ) == 0 )
219 && ( git_commit_tree( &head_tree, head_commit ) == 0 );
220
221 git_tree* rawIndexTree = nullptr;
222 git_oid index_tree_oid;
223
224 if( git_index_write_tree( &index_tree_oid, index ) != 0 )
225 {
226 if( head_tree ) git_tree_free( head_tree );
227 if( head_commit ) git_commit_free( head_commit );
228 wxLogTrace( traceAutoSave, wxS("[history] failed to write index tree" ) );
229 return false;
230 }
231
232 git_tree_lookup( &rawIndexTree, repo, &index_tree_oid );
233 std::unique_ptr<git_tree, decltype( &git_tree_free )> indexTree( rawIndexTree, &git_tree_free );
234
235 bool hasChanges = true;
236
237 if( headExists )
238 {
239 git_diff* diff = nullptr;
240
241 if( git_diff_tree_to_tree( &diff, repo, head_tree, indexTree.get(), nullptr ) == 0 )
242 {
243 hasChanges = git_diff_num_deltas( diff ) > 0;
244 wxLogTrace( traceAutoSave, wxS("[history] diff deltas=%u"), (unsigned) git_diff_num_deltas( diff ) );
245 git_diff_free( diff );
246 }
247 }
248
249 if( head_tree ) git_tree_free( head_tree );
250 if( head_commit ) git_commit_free( head_commit );
251
252 if( !hasChanges )
253 {
254 wxLogTrace( traceAutoSave, wxS("[history] no changes detected; no commit") );
255 return false; // Nothing new; skip commit.
256 }
257
258 git_signature* rawSig = nullptr;
259 git_signature_now( &rawSig, "KiCad", "[email protected]" );
260 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig, &git_signature_free );
261
262 git_commit* parent = nullptr;
263 git_oid parent_id;
264 int parents = 0;
265
266 if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
267 {
268 if( git_commit_lookup( &parent, repo, &parent_id ) == 0 )
269 parents = 1;
270 }
271
272 wxString msg = aTitle.IsEmpty() ? wxString( "Autosave" ) : aTitle;
273 git_oid commit_id;
274 const git_commit* constParent = parent;
275
276 int rc = git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr,
277 msg.mb_str().data(), indexTree.get(), parents,
278 parents ? &constParent : nullptr );
279
280 if( rc == 0 )
281 wxLogTrace( traceAutoSave, wxS("[history] commit created %s (%s files=%zu)"),
282 wxString::FromUTF8( git_oid_tostr_s( &commit_id ) ), msg, files.size() );
283 else
284 wxLogTrace( traceAutoSave, wxS("[history] commit failed rc=%d"), rc );
285
286 if( parent ) git_commit_free( parent );
287
288 git_index_write( index );
289 return rc == 0;
290}
291
292
294{
295 std::vector<wxString> files( m_pendingFiles.begin(), m_pendingFiles.end() );
296 m_pendingFiles.clear();
297 return CommitSnapshot( files, wxS( "Autosave" ) );
298}
299
300
301bool LOCAL_HISTORY::Init( const wxString& aProjectPath )
302{
303 if( aProjectPath.IsEmpty() )
304 return false;
305
306 if( !Pgm().GetCommonSettings()->m_Backup.enabled )
307 return true;
308
309 wxString hist = historyPath( aProjectPath );
310
311 if( !wxDirExists( hist ) )
312 {
313 if( wxIsWritable( aProjectPath ) )
314 {
315 if( !wxMkdir( hist ) )
316 {
317 return false;
318 }
319 }
320 }
321
322 git_repository* rawRepo = nullptr;
323 bool isNewRepo = false;
324
325 if( git_repository_open( &rawRepo, hist.mb_str().data() ) != 0 )
326 {
327 if( git_repository_init( &rawRepo, hist.mb_str().data(), 0 ) != 0 )
328 return false;
329
330 isNewRepo = true;
331
332 wxFileName ignoreFile( hist, wxS( ".gitignore" ) );
333 if( !ignoreFile.FileExists() )
334 {
335 wxFFile f( ignoreFile.GetFullPath(), wxT( "w" ) );
336 if( f.IsOpened() )
337 {
338 f.Write( wxS( "fp-info-cache\n*-backups\nREADME.txt\n" ) );
339 f.Close();
340 }
341 }
342
343 wxFileName readmeFile( hist, wxS( "README.txt" ) );
344
345 if( !readmeFile.FileExists() )
346 {
347 wxFFile f( readmeFile.GetFullPath(), wxT( "w" ) );
348
349 if( f.IsOpened() )
350 {
351 f.Write( wxS( "KiCad Local History Directory\n"
352 "=============================\n\n"
353 "This directory contains automatic snapshots of your project files.\n"
354 "KiCad periodically saves copies of your work here, allowing you to\n"
355 "recover from accidental changes or data loss.\n\n"
356 "You can browse and restore previous versions through KiCad's\n"
357 "File > Local History menu.\n\n"
358 "To disable this feature:\n"
359 " Preferences > Common > Project Backup > Enable automatic backups\n\n"
360 "This directory can be safely deleted if you no longer need the\n"
361 "history, but doing so will permanently remove all saved snapshots.\n" ) );
362 f.Close();
363 }
364 }
365 }
366
367 git_repository_free( rawRepo );
368
369 // If this is a newly initialized repository with no commits, create an initial snapshot
370 // of all existing project files
371 if( isNewRepo )
372 {
373 wxLogTrace( traceAutoSave, wxS( "[history] Init: New repository created, collecting existing files" ) );
374
375 // Collect all files in the project directory (excluding backups and hidden files)
376 wxArrayString files;
377 std::function<void( const wxString& )> collect = [&]( const wxString& path )
378 {
379 wxString name;
380 wxDir d( path );
381
382 if( !d.IsOpened() )
383 return;
384
385 bool cont = d.GetFirst( &name );
386
387 while( cont )
388 {
389 // Skip hidden files/directories and backup directories
390 if( name.StartsWith( wxS( "." ) ) || name.EndsWith( wxS( "-backups" ) ) )
391 {
392 cont = d.GetNext( &name );
393 continue;
394 }
395
396 wxFileName fn( path, name );
397 wxString fullPath = fn.GetFullPath();
398
399 if( wxFileName::DirExists( fullPath ) )
400 {
401 collect( fullPath );
402 }
403 else if( fn.FileExists() )
404 {
405 // Skip transient files
406 if( fn.GetFullName() != wxS( "fp-info-cache" ) )
407 files.Add( fn.GetFullPath() );
408 }
409
410 cont = d.GetNext( &name );
411 }
412 };
413
414 collect( aProjectPath );
415
416 if( files.GetCount() > 0 )
417 {
418 std::vector<wxString> vec;
419 vec.reserve( files.GetCount() );
420
421 for( unsigned i = 0; i < files.GetCount(); ++i )
422 vec.push_back( files[i] );
423
424 wxLogTrace( traceAutoSave, wxS( "[history] Init: Creating initial snapshot with %zu files" ), vec.size() );
425 CommitSnapshot( vec, wxS( "Initial snapshot" ) );
426
427 // Tag the initial snapshot as saved so HeadNewerThanLastSave() doesn't
428 // incorrectly offer to restore when the project is first opened
429 TagSave( aProjectPath, wxS( "project" ) );
430 }
431 else
432 {
433 wxLogTrace( traceAutoSave, wxS( "[history] Init: No files found to add to initial snapshot" ) );
434 }
435 }
436
437 return true;
438}
439
440
441// Helper function to commit files using an already-acquired lock
442static bool commitSnapshotWithLock( git_repository* repo, git_index* index,
443 const wxString& aHistoryPath, const wxString& aProjectPath,
444 const std::vector<wxString>& aFiles, const wxString& aTitle )
445{
446 std::vector<std::string> filesArrStr;
447
448 for( const wxString& file : aFiles )
449 {
450 wxFileName src( file );
451 wxString relPath;
452
453 if( src.GetFullPath().StartsWith( aProjectPath + wxFILE_SEP_PATH ) )
454 relPath = src.GetFullPath().Mid( aProjectPath.length() + 1 );
455 else
456 relPath = src.GetFullName(); // Fallback (should not normally happen)
457
458 relPath.Replace( "\\", "/" ); // libgit2 needs forward slashes on all platforms
459 std::string relPathStr = relPath.ToStdString();
460
461 unsigned int status = 0;
462 int rc = git_status_file( &status, repo, relPathStr.data() );
463
464 if( rc == 0 && status != 0 )
465 {
466 wxLogTrace( traceAutoSave, wxS( "File %s status %d " ), relPath, status );
467 filesArrStr.emplace_back( relPathStr );
468 }
469 else if( rc != 0 )
470 {
471 wxLogTrace( traceAutoSave, wxS( "File %s status error %d " ), relPath, rc );
472 filesArrStr.emplace_back( relPathStr ); // Add anyway even if the file is untracked.
473 }
474 }
475
476 std::vector<char*> cStrings( filesArrStr.size() );
477
478 for( size_t i = 0; i < filesArrStr.size(); i++ )
479 cStrings[i] = filesArrStr[i].data();
480
481 git_strarray filesArrGit;
482 filesArrGit.count = filesArrStr.size();
483 filesArrGit.strings = cStrings.data();
484
485 if( filesArrStr.size() == 0 )
486 {
487 wxLogTrace( traceAutoSave, wxS( "No changes, skipping" ) );
488 return false;
489 }
490
491 int rc = git_index_add_all( index, &filesArrGit, GIT_INDEX_ADD_DISABLE_PATHSPEC_MATCH | GIT_INDEX_ADD_FORCE, NULL,
492 NULL );
493 wxLogTrace( traceAutoSave, wxS( "Adding %zu files, rc %d" ), filesArrStr.size(), rc );
494
495 if( rc != 0 )
496 return false;
497
498 git_oid tree_id;
499 if( git_index_write_tree( &tree_id, index ) != 0 )
500 return false;
501
502 git_tree* rawTree = nullptr;
503 git_tree_lookup( &rawTree, repo, &tree_id );
504 std::unique_ptr<git_tree, decltype( &git_tree_free )> tree( rawTree, &git_tree_free );
505
506 git_signature* rawSig = nullptr;
507 git_signature_now( &rawSig, "KiCad", "[email protected]" );
508 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( rawSig,
509 &git_signature_free );
510
511 git_commit* rawParent = nullptr;
512 git_oid parent_id;
513 int parents = 0;
514
515 if( git_reference_name_to_id( &parent_id, repo, "HEAD" ) == 0 )
516 {
517 git_commit_lookup( &rawParent, repo, &parent_id );
518 parents = 1;
519 }
520
521 std::unique_ptr<git_commit, decltype( &git_commit_free )> parent( rawParent,
522 &git_commit_free );
523
524 git_tree* rawParentTree = nullptr;
525
526 if( parent )
527 git_commit_tree( &rawParentTree, parent.get() );
528
529 std::unique_ptr<git_tree, decltype( &git_tree_free )> parentTree( rawParentTree, &git_tree_free );
530
531 git_diff* rawDiff = nullptr;
532 git_diff_tree_to_index( &rawDiff, repo, parentTree.get(), index, nullptr );
533 std::unique_ptr<git_diff, decltype( &git_diff_free )> diff( rawDiff, &git_diff_free );
534
535 size_t numChangedFiles = git_diff_num_deltas( diff.get() );
536
537 if( numChangedFiles == 0 )
538 {
539 wxLogTrace( traceAutoSave, wxS( "No actual changes in tree, skipping commit" ) );
540 return false;
541 }
542
543 wxString msg;
544
545 if( !aTitle.IsEmpty() )
546 msg << aTitle << wxS( ": " );
547
548 msg << numChangedFiles << wxS( " files changed" );
549
550 for( size_t i = 0; i < numChangedFiles; ++i )
551 {
552 const git_diff_delta* delta = git_diff_get_delta( diff.get(), i );
553 git_patch* rawPatch = nullptr;
554 git_patch_from_diff( &rawPatch, diff.get(), i );
555 std::unique_ptr<git_patch, decltype( &git_patch_free )> patch( rawPatch,
556 &git_patch_free );
557 size_t context = 0, adds = 0, dels = 0;
558 git_patch_line_stats( &context, &adds, &dels, patch.get() );
559 size_t updated = std::min( adds, dels );
560 adds -= updated;
561 dels -= updated;
562 msg << wxS( "\n" ) << wxString::FromUTF8( delta->new_file.path )
563 << wxS( " " ) << adds << wxS( "/" ) << dels << wxS( "/" ) << updated;
564 }
565
566 git_oid commit_id;
567 git_commit* parentPtr = parent.get();
568 const git_commit* constParentPtr = parentPtr;
569 git_commit_create( &commit_id, repo, "HEAD", sig.get(), sig.get(), nullptr,
570 msg.mb_str().data(), tree.get(), parents,
571 parentPtr ? &constParentPtr : nullptr );
572 git_index_write( index );
573 return true;
574}
575
576
577bool LOCAL_HISTORY::CommitSnapshot( const std::vector<wxString>& aFiles, const wxString& aTitle )
578{
579 if( aFiles.empty() || !Pgm().GetCommonSettings()->m_Backup.enabled )
580 return true;
581
582 wxString proj = wxFileName( aFiles[0] ).GetPath();
583 wxString hist = historyPath( proj );
584
585 // Acquire locks using hybrid locking strategy
586 HISTORY_LOCK_MANAGER lock( proj );
587
588 if( !lock.IsLocked() )
589 {
590 wxLogTrace( traceAutoSave, wxS("[history] CommitSnapshot failed to acquire lock: %s"),
591 lock.GetLockError() );
592 return false;
593 }
594
595 git_repository* repo = lock.GetRepository();
596 git_index* index = lock.GetIndex();
597
598 return commitSnapshotWithLock( repo, index, hist, proj, aFiles, aTitle );
599}
600
601
602// Helper to collect all project files (excluding .history, backups, and transient files)
603static void collectProjectFiles( const wxString& aProjectPath, std::vector<wxString>& aFiles )
604{
605 wxDir dir( aProjectPath );
606
607 if( !dir.IsOpened() )
608 return;
609
610 // Collect recursively.
611 std::function<void(const wxString&)> collect = [&]( const wxString& path )
612 {
613 wxString name;
614 wxDir d( path );
615
616 if( !d.IsOpened() )
617 return;
618
619 bool cont = d.GetFirst( &name );
620
621 while( cont )
622 {
623 if( name == wxS( ".history" ) || name.EndsWith( wxS( "-backups" ) ) )
624 {
625 cont = d.GetNext( &name );
626 continue; // Skip history repo itself.
627 }
628
629 wxFileName fn( path, name );
630 wxString fullPath = fn.GetFullPath();
631
632 if( wxFileName::DirExists( fullPath ) )
633 {
634 collect( fullPath );
635 }
636 else if( fn.FileExists() )
637 {
638 // Reuse NoteFileChange filters implicitly by skipping the transient names.
639 if( fn.GetFullName() != wxS( "fp-info-cache" ) )
640 aFiles.push_back( fn.GetFullPath() );
641 }
642
643 cont = d.GetNext( &name );
644 }
645 };
646
647 collect( aProjectPath );
648}
649
650
651bool LOCAL_HISTORY::CommitFullProjectSnapshot( const wxString& aProjectPath, const wxString& aTitle )
652{
653 std::vector<wxString> files;
654 collectProjectFiles( aProjectPath, files );
655
656 if( files.empty() )
657 return false;
658
659 return CommitSnapshot( files, aTitle );
660}
661
662bool LOCAL_HISTORY::HistoryExists( const wxString& aProjectPath )
663{
664 return wxDirExists( historyPath( aProjectPath ) );
665}
666
667bool LOCAL_HISTORY::TagSave( const wxString& aProjectPath, const wxString& aFileType )
668{
669 HISTORY_LOCK_MANAGER lock( aProjectPath );
670
671 if( !lock.IsLocked() )
672 {
673 wxLogTrace( traceAutoSave, wxS( "[history] TagSave: Failed to acquire lock for %s" ), aProjectPath );
674 return false;
675 }
676
677 git_repository* repo = lock.GetRepository();
678
679 if( !repo )
680 return false;
681
682 git_oid head;
683 if( git_reference_name_to_id( &head, repo, "HEAD" ) != 0 )
684 return false;
685
686 wxString tagName;
687 int i = 1;
688 git_reference* ref = nullptr;
689 do
690 {
691 tagName.Printf( wxS( "Save_%s_%d" ), aFileType, i++ );
692 } while( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + tagName ).mb_str().data() ) == 0 );
693
694 git_oid tag_oid;
695 git_object* head_obj = nullptr;
696 git_object_lookup( &head_obj, repo, &head, GIT_OBJECT_COMMIT );
697 git_tag_create_lightweight( &tag_oid, repo, tagName.mb_str().data(), head_obj, 0 );
698 git_object_free( head_obj );
699
700 wxString lastName;
701 lastName.Printf( wxS( "Last_Save_%s" ), aFileType );
702 if( git_reference_lookup( &ref, repo, ( wxS( "refs/tags/" ) + lastName ).mb_str().data() ) == 0 )
703 {
704 git_reference_delete( ref );
705 git_reference_free( ref );
706 }
707
708 git_oid last_tag_oid;
709 git_object* head_obj2 = nullptr;
710 git_object_lookup( &head_obj2, repo, &head, GIT_OBJECT_COMMIT );
711 git_tag_create_lightweight( &last_tag_oid, repo, lastName.mb_str().data(), head_obj2, 0 );
712 git_object_free( head_obj2 );
713
714 return true;
715}
716
717bool LOCAL_HISTORY::HeadNewerThanLastSave( const wxString& aProjectPath )
718{
719 wxString hist = historyPath( aProjectPath );
720 git_repository* repo = nullptr;
721
722 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
723 return false;
724
725 git_oid head_oid;
726 if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
727 {
728 git_repository_free( repo );
729 return false;
730 }
731
732 git_commit* head_commit = nullptr;
733 git_commit_lookup( &head_commit, repo, &head_oid );
734 git_time_t head_time = git_commit_time( head_commit );
735
736 git_strarray tags;
737 git_tag_list_match( &tags, "Last_Save_*", repo );
738 git_time_t save_time = 0;
739
740 for( size_t i = 0; i < tags.count; ++i )
741 {
742 git_reference* ref = nullptr;
743 if( git_reference_lookup( &ref, repo,
744 ( wxS( "refs/tags/" ) +
745 wxString::FromUTF8( tags.strings[i] ) ).mb_str().data() ) == 0 )
746 {
747 const git_oid* oid = git_reference_target( ref );
748 git_commit* c = nullptr;
749 if( git_commit_lookup( &c, repo, oid ) == 0 )
750 {
751 git_time_t t = git_commit_time( c );
752 if( t > save_time )
753 save_time = t;
754 git_commit_free( c );
755 }
756 git_reference_free( ref );
757 }
758 }
759
760 git_strarray_free( &tags );
761 git_commit_free( head_commit );
762 git_repository_free( repo );
763
764 // If there are no Last_Save tags but there IS a HEAD commit, we have autosaved
765 // data that was never explicitly saved - offer to restore
766 if( save_time == 0 )
767 return true;
768
769 return head_time > save_time;
770}
771
772bool LOCAL_HISTORY::CommitDuplicateOfLastSave( const wxString& aProjectPath, const wxString& aFileType,
773 const wxString& aMessage )
774{
775 HISTORY_LOCK_MANAGER lock( aProjectPath );
776
777 if( !lock.IsLocked() )
778 {
779 wxLogTrace( traceAutoSave, wxS( "[history] CommitDuplicateOfLastSave: Failed to acquire lock for %s" ), aProjectPath );
780 return false;
781 }
782
783 git_repository* repo = lock.GetRepository();
784
785 if( !repo )
786 return false;
787
788 wxString lastName; lastName.Printf( wxS("Last_Save_%s"), aFileType );
789 git_reference* lastRef = nullptr;
790 if( git_reference_lookup( &lastRef, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) != 0 )
791 return false; // no tag to duplicate
792 std::unique_ptr<git_reference, decltype( &git_reference_free )> lastRefPtr( lastRef, &git_reference_free );
793
794 const git_oid* lastOid = git_reference_target( lastRef );
795 git_commit* lastCommit = nullptr;
796 if( git_commit_lookup( &lastCommit, repo, lastOid ) != 0 )
797 return false;
798 std::unique_ptr<git_commit, decltype( &git_commit_free )> lastCommitPtr( lastCommit, &git_commit_free );
799
800 git_tree* lastTree = nullptr;
801 git_commit_tree( &lastTree, lastCommit );
802 std::unique_ptr<git_tree, decltype( &git_tree_free )> lastTreePtr( lastTree, &git_tree_free );
803
804 // Parent will be current HEAD (to keep linear history)
805 git_oid headOid;
806 git_commit* headCommit = nullptr;
807 int parents = 0;
808 const git_commit* parentArray[1];
809 if( git_reference_name_to_id( &headOid, repo, "HEAD" ) == 0 &&
810 git_commit_lookup( &headCommit, repo, &headOid ) == 0 )
811 {
812 parentArray[0] = headCommit;
813 parents = 1;
814 }
815
816 git_signature* sigRaw = nullptr;
817 git_signature_now( &sigRaw, "KiCad", "[email protected]" );
818 std::unique_ptr<git_signature, decltype( &git_signature_free )> sig( sigRaw, &git_signature_free );
819
820 wxString msg = aMessage.IsEmpty() ? wxS("Discard unsaved ") + aFileType : aMessage;
821 git_oid newCommitOid;
822 int rc = git_commit_create( &newCommitOid, repo, "HEAD", sig.get(), sig.get(), nullptr,
823 msg.mb_str().data(), lastTree, parents, parents ? parentArray : nullptr );
824 if( headCommit ) git_commit_free( headCommit );
825 if( rc != 0 )
826 return false;
827
828 // Move Last_Save tag to new commit
829 git_reference* existing = nullptr;
830 if( git_reference_lookup( &existing, repo, ( wxS("refs/tags/") + lastName ).mb_str().data() ) == 0 )
831 {
832 git_reference_delete( existing );
833 git_reference_free( existing );
834 }
835 git_object* newCommitObj = nullptr;
836 if( git_object_lookup( &newCommitObj, repo, &newCommitOid, GIT_OBJECT_COMMIT ) == 0 )
837 {
838 git_tag_create_lightweight( &newCommitOid, repo, lastName.mb_str().data(), newCommitObj, 0 );
839 git_object_free( newCommitObj );
840 }
841 return true;
842}
843
844static size_t dirSizeRecursive( const wxString& path )
845{
846 size_t total = 0;
847 wxDir dir( path );
848 if( !dir.IsOpened() )
849 return 0;
850 wxString name;
851 bool cont = dir.GetFirst( &name );
852 while( cont )
853 {
854 wxFileName fn( path, name );
855 wxString fullPath = fn.GetFullPath();
856
857 if( wxFileName::DirExists( fullPath ) )
858 total += dirSizeRecursive( fullPath );
859 else if( fn.FileExists() )
860 total += (size_t) fn.GetSize().GetValue();
861 cont = dir.GetNext( &name );
862 }
863 return total;
864}
865
866bool LOCAL_HISTORY::EnforceSizeLimit( const wxString& aProjectPath, size_t aMaxBytes )
867{
868 if( aMaxBytes == 0 )
869 return false;
870
871 wxString hist = historyPath( aProjectPath );
872
873 if( !wxDirExists( hist ) )
874 return false;
875
876 size_t current = dirSizeRecursive( hist );
877
878 if( current <= aMaxBytes )
879 return true; // within limit
880
881 HISTORY_LOCK_MANAGER lock( aProjectPath );
882
883 if( !lock.IsLocked() )
884 {
885 wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Failed to acquire lock for %s" ), aProjectPath );
886 return false;
887 }
888
889 git_repository* repo = lock.GetRepository();
890
891 if( !repo )
892 return false;
893
894 // Collect commits newest-first using revwalk
895 git_revwalk* walk = nullptr;
896 git_revwalk_new( &walk, repo );
897 git_revwalk_sorting( walk, GIT_SORT_TIME );
898 git_revwalk_push_head( walk );
899 std::vector<git_oid> commits;
900 git_oid oid;
901
902 while( git_revwalk_next( &oid, walk ) == 0 )
903 commits.push_back( oid );
904
905 git_revwalk_free( walk );
906
907 if( commits.empty() )
908 return true;
909
910 // Determine set of newest commits to keep based on blob sizes.
911 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> seenBlobs(
912 []( const git_oid& a, const git_oid& b )
913 {
914 return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
915 } );
916
917 size_t keptBytes = 0;
918 std::vector<git_oid> keep;
919
920 std::function<size_t( git_tree* )> accountTree = [&]( git_tree* tree )
921 {
922 size_t added = 0;
923 size_t cnt = git_tree_entrycount( tree );
924 for( size_t i = 0; i < cnt; ++i )
925 {
926 const git_tree_entry* entry = git_tree_entry_byindex( tree, i );
927
928 if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
929 {
930 const git_oid* bid = git_tree_entry_id( entry );
931
932 if( seenBlobs.find( *bid ) == seenBlobs.end() )
933 {
934 git_blob* blob = nullptr;
935
936 if( git_blob_lookup( &blob, repo, bid ) == 0 )
937 {
938 added += git_blob_rawsize( blob );
939 git_blob_free( blob );
940 }
941
942 seenBlobs.insert( *bid );
943 }
944 }
945 else if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
946 {
947 git_tree* sub = nullptr;
948
949 if( git_tree_lookup( &sub, repo, git_tree_entry_id( entry ) ) == 0 )
950 {
951 added += accountTree( sub );
952 git_tree_free( sub );
953 }
954 }
955 }
956 return added;
957 };
958
959 for( const git_oid& cOid : commits )
960 {
961 git_commit* c = nullptr;
962
963 if( git_commit_lookup( &c, repo, &cOid ) != 0 )
964 continue;
965
966 git_tree* tree = nullptr;
967 git_commit_tree( &tree, c );
968 size_t add = accountTree( tree );
969 git_tree_free( tree );
970 git_commit_free( c );
971
972 if( keep.empty() || keptBytes + add <= aMaxBytes )
973 {
974 keep.push_back( cOid );
975 keptBytes += add;
976 }
977 else
978 break; // stop once limit exceeded
979 }
980
981 if( keep.empty() )
982 keep.push_back( commits.front() ); // ensure at least head
983
984 // Collect tags we want to preserve (Save_*/Last_Save_*). We'll recreate them if their
985 // target commit is retained. Also ensure tagged commits are ALWAYS kept.
986 std::vector<std::pair<wxString, git_oid>> tagTargets;
987 std::set<git_oid, bool ( * )( const git_oid&, const git_oid& )> taggedCommits(
988 []( const git_oid& a, const git_oid& b )
989 {
990 return memcmp( &a, &b, sizeof( git_oid ) ) < 0;
991 } );
992 git_strarray tagList;
993
994 if( git_tag_list( &tagList, repo ) == 0 )
995 {
996 for( size_t i = 0; i < tagList.count; ++i )
997 {
998 wxString name = wxString::FromUTF8( tagList.strings[i] );
999 if( name.StartsWith( wxS("Save_") ) || name.StartsWith( wxS("Last_Save_") ) )
1000 {
1001 git_reference* tref = nullptr;
1002
1003 if( git_reference_lookup( &tref, repo, ( wxS( "refs/tags/" ) + name ).mb_str().data() ) == 0 )
1004 {
1005 const git_oid* toid = git_reference_target( tref );
1006
1007 if( toid )
1008 {
1009 tagTargets.emplace_back( name, *toid );
1010 taggedCommits.insert( *toid );
1011
1012 // Ensure this tagged commit is in the keep list
1013 bool found = false;
1014 for( const auto& k : keep )
1015 {
1016 if( memcmp( &k, toid, sizeof( git_oid ) ) == 0 )
1017 {
1018 found = true;
1019 break;
1020 }
1021 }
1022
1023 if( !found )
1024 {
1025 // Add tagged commit to keep list (even if it exceeds size limit)
1026 keep.push_back( *toid );
1027 wxLogTrace( traceAutoSave, wxS( "[history] EnforceSizeLimit: Preserving tagged commit %s" ),
1028 name );
1029 }
1030 }
1031
1032 git_reference_free( tref );
1033 }
1034 }
1035 }
1036 git_strarray_free( &tagList );
1037 }
1038
1039 // Rebuild trimmed repo in temp dir
1040 wxFileName trimFn( hist + wxS("_trim"), wxEmptyString );
1041 wxString trimPath = trimFn.GetPath();
1042
1043 if( wxDirExists( trimPath ) )
1044 wxFileName::Rmdir( trimPath, wxPATH_RMDIR_RECURSIVE );
1045
1046 wxMkdir( trimPath );
1047 git_repository* newRepo = nullptr;
1048
1049 if( git_repository_init( &newRepo, trimPath.mb_str().data(), 0 ) != 0 )
1050 return false;
1051
1052 // We will materialize each kept commit chronologically (oldest first) to preserve order.
1053 std::reverse( keep.begin(), keep.end() );
1054 git_commit* parent = nullptr;
1055 struct MAP_ENTRY { git_oid orig; git_oid neu; };
1056 std::vector<MAP_ENTRY> commitMap;
1057
1058 for( const git_oid& co : keep )
1059 {
1060 git_commit* orig = nullptr;
1061
1062 if( git_commit_lookup( &orig, repo, &co ) != 0 )
1063 continue;
1064
1065 git_tree* tree = nullptr;
1066 git_commit_tree( &tree, orig );
1067
1068 // Checkout tree into filesystem (simple extractor)
1069 // Remove all files first (except .git).
1070 wxArrayString toDelete;
1071 wxDir d( trimPath );
1072 wxString nm;
1073 bool cont = d.GetFirst( &nm );
1074 while( cont )
1075 {
1076 if( nm != wxS(".git") )
1077 toDelete.Add( nm );
1078 cont = d.GetNext( &nm );
1079 }
1080
1081 for( auto& del : toDelete )
1082 {
1083 wxFileName f( trimPath, del );
1084 if( f.DirExists() )
1085 wxFileName::Rmdir( f.GetFullPath(), wxPATH_RMDIR_RECURSIVE );
1086 else if( f.FileExists() )
1087 wxRemoveFile( f.GetFullPath() );
1088 }
1089
1090 // Recursively write tree entries
1091 std::function<void(git_tree*, const wxString&)> writeTree = [&]( git_tree* t, const wxString& base )
1092 {
1093 size_t ecnt = git_tree_entrycount( t );
1094 for( size_t i = 0; i < ecnt; ++i )
1095 {
1096 const git_tree_entry* e = git_tree_entry_byindex( t, i );
1097 wxString name = wxString::FromUTF8( git_tree_entry_name( e ) );
1098
1099 if( git_tree_entry_type( e ) == GIT_OBJECT_TREE )
1100 {
1101 wxFileName dir( base, name );
1102 wxMkdir( dir.GetFullPath() );
1103 git_tree* sub = nullptr;
1104
1105 if( git_tree_lookup( &sub, repo, git_tree_entry_id( e ) ) == 0 )
1106 {
1107 writeTree( sub, dir.GetFullPath() );
1108 git_tree_free( sub );
1109 }
1110 }
1111 else if( git_tree_entry_type( e ) == GIT_OBJECT_BLOB )
1112 {
1113 git_blob* blob = nullptr;
1114
1115 if( git_blob_lookup( &blob, repo, git_tree_entry_id( e ) ) == 0 )
1116 {
1117 wxFileName file( base, name );
1118 wxFFile f( file.GetFullPath(), wxT("wb") );
1119
1120 if( f.IsOpened() )
1121 {
1122 f.Write( (const char*) git_blob_rawcontent( blob ), git_blob_rawsize( blob ) );
1123 f.Close();
1124 }
1125 git_blob_free( blob );
1126 }
1127 }
1128 }
1129 };
1130
1131 writeTree( tree, trimPath );
1132
1133 git_index* newIndex = nullptr;
1134 git_repository_index( &newIndex, newRepo );
1135 git_index_add_all( newIndex, nullptr, 0, nullptr, nullptr );
1136 git_index_write( newIndex );
1137 git_oid newTreeOid;
1138 git_index_write_tree( &newTreeOid, newIndex );
1139 git_tree* newTree = nullptr;
1140 git_tree_lookup( &newTree, newRepo, &newTreeOid );
1141
1142 // Recreate original author/committer signatures preserving timestamp.
1143 const git_signature* origAuthor = git_commit_author( orig );
1144 const git_signature* origCommitter = git_commit_committer( orig );
1145 git_signature* sigAuthor = nullptr;
1146 git_signature* sigCommitter = nullptr;
1147
1148 git_signature_new( &sigAuthor, origAuthor->name, origAuthor->email,
1149 origAuthor->when.time, origAuthor->when.offset );
1150 git_signature_new( &sigCommitter, origCommitter->name, origCommitter->email,
1151 origCommitter->when.time, origCommitter->when.offset );
1152
1153 const git_commit* parents[1];
1154 int parentCount = 0;
1155
1156 if( parent )
1157 {
1158 parents[0] = parent;
1159 parentCount = 1;
1160 }
1161
1162 git_oid newCommitOid;
1163 git_commit_create( &newCommitOid, newRepo, "HEAD", sigAuthor, sigCommitter, nullptr, git_commit_message( orig ),
1164 newTree, parentCount, parentCount ? parents : nullptr );
1165 if( parent )
1166 git_commit_free( parent );
1167
1168 git_commit_lookup( &parent, newRepo, &newCommitOid );
1169
1170 commitMap.emplace_back( co, newCommitOid );
1171
1172 git_signature_free( sigAuthor );
1173 git_signature_free( sigCommitter );
1174 git_tree_free( newTree );
1175 git_index_free( newIndex );
1176 git_tree_free( tree );
1177 git_commit_free( orig );
1178 }
1179
1180 if( parent )
1181 git_commit_free( parent );
1182
1183 // Recreate preserved tags pointing to new commit OIDs where possible.
1184 for( const auto& tt : tagTargets )
1185 {
1186 // Find mapping
1187 const git_oid* newOid = nullptr;
1188
1189 for( const auto& m : commitMap )
1190 {
1191 if( memcmp( &m.orig, &tt.second, sizeof( git_oid ) ) == 0 )
1192 {
1193 newOid = &m.neu;
1194 break;
1195 }
1196 }
1197
1198 if( !newOid )
1199 continue; // commit trimmed away
1200
1201 git_object* obj = nullptr;
1202
1203 if( git_object_lookup( &obj, newRepo, newOid, GIT_OBJECT_COMMIT ) == 0 )
1204 {
1205 git_oid tag_oid; git_tag_create_lightweight( &tag_oid, newRepo, tt.first.mb_str().data(), obj, 0 );
1206 git_object_free( obj );
1207 }
1208 }
1209
1210 // Close both repositories before swapping directories to avoid file locking issues.
1211 // Note: The lock manager will automatically free the original repo when it goes out of scope,
1212 // but we need to manually free the new trimmed repo we created.
1213 git_repository_free( newRepo );
1214
1215 // Replace old history dir with trimmed one
1216 wxString backupOld = hist + wxS("_old");
1217 wxRenameFile( hist, backupOld );
1218 wxRenameFile( trimPath, hist );
1219 wxFileName::Rmdir( backupOld, wxPATH_RMDIR_RECURSIVE );
1220 return true;
1221}
1222
1223wxString LOCAL_HISTORY::GetHeadHash( const wxString& aProjectPath )
1224{
1225 wxString hist = historyPath( aProjectPath );
1226 git_repository* repo = nullptr;
1227
1228 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
1229 return wxEmptyString;
1230
1231 git_oid head_oid;
1232 if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 )
1233 {
1234 git_repository_free( repo );
1235 return wxEmptyString;
1236 }
1237
1238 wxString hash = wxString::FromUTF8( git_oid_tostr_s( &head_oid ) );
1239 git_repository_free( repo );
1240 return hash;
1241}
1242
1243
1244// Helper functions for RestoreCommit
1245namespace
1246{
1247
1251bool checkForLockedFiles( const wxString& aProjectPath, std::vector<wxString>& aLockedFiles )
1252{
1253 std::function<void( const wxString& )> findLocks = [&]( const wxString& dirPath )
1254 {
1255 wxDir dir( dirPath );
1256 if( !dir.IsOpened() )
1257 return;
1258
1259 wxString filename;
1260 bool cont = dir.GetFirst( &filename );
1261
1262 while( cont )
1263 {
1264 wxFileName fullPath( dirPath, filename );
1265
1266 // Skip special directories
1267 if( filename == wxS(".history") || filename == wxS(".git") )
1268 {
1269 cont = dir.GetNext( &filename );
1270 continue;
1271 }
1272
1273 if( fullPath.DirExists() )
1274 {
1275 findLocks( fullPath.GetFullPath() );
1276 }
1277 else if( fullPath.FileExists()
1278 && filename.StartsWith( FILEEXT::LockFilePrefix )
1279 && filename.EndsWith( wxString( wxS( "." ) ) + FILEEXT::LockFileExtension ) )
1280 {
1281 // Reconstruct the original filename from the lock file name
1282 // Lock files are: ~<original>.<ext>.lck -> need to get <original>.<ext>
1283 wxString baseName = filename.Mid( FILEEXT::LockFilePrefix.length() );
1284 baseName = baseName.BeforeLast( '.' ); // Remove .lck
1285 wxFileName originalFile( dirPath, baseName );
1286
1287 // Check if this is a valid LOCKFILE (not stale and not ours)
1288 LOCKFILE testLock( originalFile.GetFullPath() );
1289 if( testLock.Valid() && !testLock.IsLockedByMe() )
1290 {
1291 aLockedFiles.push_back( fullPath.GetFullPath() );
1292 }
1293 }
1294
1295 cont = dir.GetNext( &filename );
1296 }
1297 };
1298
1299 findLocks( aProjectPath );
1300 return aLockedFiles.empty();
1301}
1302
1303
1307bool extractCommitToTemp( git_repository* aRepo, git_tree* aTree, const wxString& aTempPath )
1308{
1309 bool extractSuccess = true;
1310
1311 std::function<void( git_tree*, const wxString& )> extractTree =
1312 [&]( git_tree* t, const wxString& prefix )
1313 {
1314 if( !extractSuccess )
1315 return;
1316
1317 size_t cnt = git_tree_entrycount( t );
1318 for( size_t i = 0; i < cnt; ++i )
1319 {
1320 const git_tree_entry* entry = git_tree_entry_byindex( t, i );
1321 wxString name = wxString::FromUTF8( git_tree_entry_name( entry ) );
1322 wxString fullPath = prefix.IsEmpty() ? name : prefix + wxS("/") + name;
1323
1324 if( git_tree_entry_type( entry ) == GIT_OBJECT_TREE )
1325 {
1326 wxFileName dirPath( aTempPath + wxFileName::GetPathSeparator() + fullPath,
1327 wxEmptyString );
1328 if( !wxFileName::Mkdir( dirPath.GetPath(), 0777, wxPATH_MKDIR_FULL ) )
1329 {
1330 wxLogTrace( traceAutoSave,
1331 wxS( "[history] extractCommitToTemp: Failed to create directory '%s'" ),
1332 dirPath.GetPath() );
1333 extractSuccess = false;
1334 return;
1335 }
1336
1337 git_tree* sub = nullptr;
1338 if( git_tree_lookup( &sub, aRepo, git_tree_entry_id( entry ) ) == 0 )
1339 {
1340 extractTree( sub, fullPath );
1341 git_tree_free( sub );
1342 }
1343 }
1344 else if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
1345 {
1346 git_blob* blob = nullptr;
1347 if( git_blob_lookup( &blob, aRepo, git_tree_entry_id( entry ) ) == 0 )
1348 {
1349 wxFileName dst( aTempPath + wxFileName::GetPathSeparator() + fullPath );
1350
1351 wxFileName dstDir( dst );
1352 dstDir.SetFullName( wxEmptyString );
1353 wxFileName::Mkdir( dstDir.GetPath(), 0777, wxPATH_MKDIR_FULL );
1354
1355 wxFFile f( dst.GetFullPath(), wxT( "wb" ) );
1356 if( f.IsOpened() )
1357 {
1358 f.Write( git_blob_rawcontent( blob ), git_blob_rawsize( blob ) );
1359 f.Close();
1360 }
1361 else
1362 {
1363 wxLogTrace( traceAutoSave,
1364 wxS( "[history] extractCommitToTemp: Failed to write '%s'" ),
1365 dst.GetFullPath() );
1366 extractSuccess = false;
1367 git_blob_free( blob );
1368 return;
1369 }
1370
1371 git_blob_free( blob );
1372 }
1373 }
1374 }
1375 };
1376
1377 extractTree( aTree, wxEmptyString );
1378 return extractSuccess;
1379}
1380
1381
1385void collectFilesInDirectory( const wxString& aRootPath, const wxString& aSearchPath,
1386 std::set<wxString>& aFiles )
1387{
1388 wxDir dir( aSearchPath );
1389 if( !dir.IsOpened() )
1390 return;
1391
1392 wxString filename;
1393 bool cont = dir.GetFirst( &filename );
1394
1395 while( cont )
1396 {
1397 wxFileName fullPath( aSearchPath, filename );
1398 wxString relativePath = fullPath.GetFullPath().Mid( aRootPath.Length() + 1 );
1399
1400 if( fullPath.IsDir() && fullPath.DirExists() )
1401 {
1402 collectFilesInDirectory( aRootPath, fullPath.GetFullPath(), aFiles );
1403 }
1404 else if( fullPath.FileExists() )
1405 {
1406 aFiles.insert( relativePath );
1407 }
1408
1409 cont = dir.GetNext( &filename );
1410 }
1411}
1412
1413
1417bool shouldExcludeFromBackup( const wxString& aFilename )
1418{
1419 // Files explicitly excluded from backup should not be deleted during restore
1420 return aFilename == wxS( "fp-info-cache" );
1421}
1422
1423
1427void findFilesToDelete( const wxString& aProjectPath, const std::set<wxString>& aRestoredFiles,
1428 std::vector<wxString>& aFilesToDelete )
1429{
1430 std::function<void( const wxString&, const wxString& )> scanDirectory =
1431 [&]( const wxString& dirPath, const wxString& relativeBase )
1432 {
1433 wxDir dir( dirPath );
1434 if( !dir.IsOpened() )
1435 return;
1436
1437 wxString filename;
1438 bool cont = dir.GetFirst( &filename );
1439
1440 while( cont )
1441 {
1442 // Skip special directories
1443 if( filename == wxS(".history") || filename == wxS(".git") ||
1444 filename == wxS("_restore_backup") || filename == wxS("_restore_temp") )
1445 {
1446 cont = dir.GetNext( &filename );
1447 continue;
1448 }
1449
1450 wxFileName fullPath( dirPath, filename );
1451 wxString relativePath = relativeBase.IsEmpty() ? filename :
1452 relativeBase + wxS("/") + filename;
1453
1454 if( fullPath.IsDir() && fullPath.DirExists() )
1455 {
1456 scanDirectory( fullPath.GetFullPath(), relativePath );
1457 }
1458 else if( fullPath.FileExists() )
1459 {
1460 // Check if this file exists in the restored commit
1461 if( aRestoredFiles.find( relativePath ) == aRestoredFiles.end() )
1462 {
1463 // Don't propose deletion of files that were never in backup scope
1464 if( !shouldExcludeFromBackup( filename ) )
1465 aFilesToDelete.push_back( relativePath );
1466 }
1467 }
1468
1469 cont = dir.GetNext( &filename );
1470 }
1471 };
1472
1473 scanDirectory( aProjectPath, wxEmptyString );
1474}
1475
1476
1481bool confirmFileDeletion( wxWindow* aParent, const std::vector<wxString>& aFilesToDelete,
1482 bool& aKeepAllFiles )
1483{
1484 if( aFilesToDelete.empty() || !aParent )
1485 {
1486 aKeepAllFiles = false;
1487 return true;
1488 }
1489
1490 wxString message = _( "The following files will be deleted when restoring this commit:\n\n" );
1491
1492 // Limit display to first 20 files to avoid overwhelming dialog
1493 size_t displayCount = std::min( aFilesToDelete.size(), size_t(20) );
1494 for( size_t i = 0; i < displayCount; ++i )
1495 {
1496 message += wxS(" • ") + aFilesToDelete[i] + wxS("\n");
1497 }
1498
1499 if( aFilesToDelete.size() > displayCount )
1500 {
1501 message += wxString::Format( _( "\n... and %zu more files\n" ),
1502 aFilesToDelete.size() - displayCount );
1503 }
1504
1505 KICAD_MESSAGE_DIALOG dlg( aParent, message, _( "Delete Files during Restore" ),
1506 wxYES_NO | wxCANCEL | wxICON_QUESTION );
1507 dlg.SetYesNoCancelLabels( _( "Proceed" ), _( "Keep All Files" ), _( "Abort" ) );
1508 dlg.SetExtendedMessage(
1509 _( "Choosing 'Keep All Files' will restore the selected commit but retain any existing "
1510 "files in the project directory. Choosing 'Proceed' will delete files that are not "
1511 "present in the restored commit." ) );
1512
1513 int choice = dlg.ShowModal();
1514
1515 if( choice == wxID_CANCEL )
1516 {
1517 wxLogTrace( traceAutoSave, wxS( "[history] User cancelled restore" ) );
1518 return false;
1519 }
1520 else if( choice == wxID_NO ) // Keep All Files
1521 {
1522 wxLogTrace( traceAutoSave, wxS( "[history] User chose to keep all files" ) );
1523 aKeepAllFiles = true;
1524 }
1525 else // Proceed with deletion
1526 {
1527 wxLogTrace( traceAutoSave, wxS( "[history] User chose to proceed with deletion" ) );
1528 aKeepAllFiles = false;
1529 }
1530
1531 return true;
1532}
1533
1534
1538bool backupCurrentFiles( const wxString& aProjectPath, const wxString& aBackupPath,
1539 const wxString& aTempRestorePath, bool aKeepAllFiles,
1540 std::set<wxString>& aBackedUpFiles )
1541{
1542 wxDir currentDir( aProjectPath );
1543 if( !currentDir.IsOpened() )
1544 return false;
1545
1546 wxString filename;
1547 bool cont = currentDir.GetFirst( &filename );
1548
1549 while( cont )
1550 {
1551 if( filename != wxS( ".history" ) && filename != wxS( ".git" ) &&
1552 filename != wxS( "_restore_backup" ) && filename != wxS( "_restore_temp" ) )
1553 {
1554 // If keepAllFiles is true, only backup files that will be overwritten
1555 bool shouldBackup = !aKeepAllFiles;
1556
1557 if( aKeepAllFiles )
1558 {
1559 // Check if this file exists in the restored commit
1560 wxFileName testPath( aTempRestorePath, filename );
1561 shouldBackup = testPath.Exists();
1562 }
1563
1564 if( shouldBackup )
1565 {
1566 wxFileName source( aProjectPath, filename );
1567 wxFileName dest( aBackupPath, filename );
1568
1569 // Create backup directory if needed
1570 if( !wxDirExists( aBackupPath ) )
1571 {
1572 wxLogTrace( traceAutoSave,
1573 wxS( "[history] backupCurrentFiles: Creating backup directory %s" ),
1574 aBackupPath );
1575 wxFileName::Mkdir( aBackupPath, 0777, wxPATH_MKDIR_FULL );
1576 }
1577
1578 wxLogTrace( traceAutoSave,
1579 wxS( "[history] backupCurrentFiles: Backing up '%s' to '%s'" ),
1580 source.GetFullPath(), dest.GetFullPath() );
1581
1582 if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
1583 {
1584 wxLogTrace( traceAutoSave,
1585 wxS( "[history] backupCurrentFiles: Failed to backup '%s'" ),
1586 source.GetFullPath() );
1587 return false;
1588 }
1589
1590 aBackedUpFiles.insert( filename );
1591 }
1592 }
1593 cont = currentDir.GetNext( &filename );
1594 }
1595
1596 return true;
1597}
1598
1599
1603bool restoreFilesFromTemp( const wxString& aTempRestorePath, const wxString& aProjectPath,
1604 std::set<wxString>& aRestoredFiles )
1605{
1606 wxDir tempDir( aTempRestorePath );
1607 if( !tempDir.IsOpened() )
1608 return false;
1609
1610 wxString filename;
1611 bool cont = tempDir.GetFirst( &filename );
1612
1613 while( cont )
1614 {
1615 wxFileName source( aTempRestorePath, filename );
1616 wxFileName dest( aProjectPath, filename );
1617
1618 wxLogTrace( traceAutoSave,
1619 wxS( "[history] restoreFilesFromTemp: Restoring '%s' to '%s'" ),
1620 source.GetFullPath(), dest.GetFullPath() );
1621
1622 if( !wxRenameFile( source.GetFullPath(), dest.GetFullPath() ) )
1623 {
1624 wxLogTrace( traceAutoSave,
1625 wxS( "[history] restoreFilesFromTemp: Failed to move '%s'" ),
1626 source.GetFullPath() );
1627 return false;
1628 }
1629
1630 aRestoredFiles.insert( filename );
1631 cont = tempDir.GetNext( &filename );
1632 }
1633
1634 return true;
1635}
1636
1637
1641void rollbackRestore( const wxString& aProjectPath, const wxString& aBackupPath,
1642 const wxString& aTempRestorePath, const std::set<wxString>& aBackedUpFiles,
1643 const std::set<wxString>& aRestoredFiles )
1644{
1645 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Rolling back due to failure" ) );
1646
1647 // Remove ONLY the files we successfully moved from temp directory
1648 // This preserves any files that were NOT in the backup (never tracked in history)
1649 for( const wxString& filename : aRestoredFiles )
1650 {
1651 wxFileName toRemove( aProjectPath, filename );
1652 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Removing '%s'" ),
1653 toRemove.GetFullPath() );
1654
1655 if( toRemove.DirExists() )
1656 {
1657 wxFileName::Rmdir( toRemove.GetFullPath(), wxPATH_RMDIR_RECURSIVE );
1658 }
1659 else if( toRemove.FileExists() )
1660 {
1661 wxRemoveFile( toRemove.GetFullPath() );
1662 }
1663 }
1664
1665 // Restore from backup - put back only what we moved
1666 if( wxDirExists( aBackupPath ) )
1667 {
1668 for( const wxString& filename : aBackedUpFiles )
1669 {
1670 wxFileName source( aBackupPath, filename );
1671 wxFileName dest( aProjectPath, filename );
1672
1673 if( source.Exists() )
1674 {
1675 wxRenameFile( source.GetFullPath(), dest.GetFullPath() );
1676 wxLogTrace( traceAutoSave, wxS( "[history] rollbackRestore: Restored '%s'" ),
1677 dest.GetFullPath() );
1678 }
1679 }
1680 }
1681
1682 // Clean up temporary directories
1683 wxFileName::Rmdir( aTempRestorePath, wxPATH_RMDIR_RECURSIVE );
1684 wxFileName::Rmdir( aBackupPath, wxPATH_RMDIR_RECURSIVE );
1685}
1686
1687
1691bool recordRestoreInHistory( git_repository* aRepo, git_commit* aCommit, git_tree* aTree,
1692 const wxString& aHash )
1693{
1694 git_time_t t = git_commit_time( aCommit );
1695 wxDateTime dt( (time_t) t );
1696 git_signature* sig = nullptr;
1697 git_signature_now( &sig, "KiCad", "[email protected]" );
1698 git_commit* parent = nullptr;
1699 git_oid parent_id;
1700
1701 if( git_reference_name_to_id( &parent_id, aRepo, "HEAD" ) == 0 )
1702 git_commit_lookup( &parent, aRepo, &parent_id );
1703
1704 wxString msg;
1705 msg.Printf( wxS( "Restored from %s %s" ), aHash, dt.FormatISOCombined().c_str() );
1706
1707 git_oid new_id;
1708 const git_commit* constParent = parent;
1709 int result = git_commit_create( &new_id, aRepo, "HEAD", sig, sig, nullptr,
1710 msg.mb_str().data(), aTree, parent ? 1 : 0,
1711 parent ? &constParent : nullptr );
1712
1713 if( parent )
1714 git_commit_free( parent );
1715 git_signature_free( sig );
1716
1717 return result == 0;
1718}
1719
1720} // namespace
1721
1722
1723bool LOCAL_HISTORY::RestoreCommit( const wxString& aProjectPath, const wxString& aHash,
1724 wxWindow* aParent )
1725{
1726 // STEP 1: Verify no files are open by checking for LOCKFILEs
1727 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Checking for open files in %s" ),
1728 aProjectPath );
1729
1730 std::vector<wxString> lockedFiles;
1731 if( !checkForLockedFiles( aProjectPath, lockedFiles ) )
1732 {
1733 wxString lockList;
1734 for( const auto& f : lockedFiles )
1735 lockList += wxS("\n - ") + f;
1736
1737 wxLogTrace( traceAutoSave,
1738 wxS( "[history] RestoreCommit: Cannot restore - files are open:%s" ),
1739 lockList );
1740
1741 // Show user-visible warning dialog
1742 if( aParent )
1743 {
1744 wxString msg = _( "Cannot restore - the following files are open by another user:" );
1745 msg += lockList;
1746 wxMessageBox( msg, _( "Restore Failed" ), wxOK | wxICON_WARNING, aParent );
1747 }
1748 return false;
1749 }
1750
1751 // STEP 2: Acquire history lock and verify target commit
1752 HISTORY_LOCK_MANAGER lock( aProjectPath );
1753
1754 if( !lock.IsLocked() )
1755 {
1756 wxLogTrace( traceAutoSave,
1757 wxS( "[history] RestoreCommit: Failed to acquire lock for %s" ),
1758 aProjectPath );
1759 return false;
1760 }
1761
1762 git_repository* repo = lock.GetRepository();
1763 if( !repo )
1764 return false;
1765
1766 // Verify the target commit exists
1767 git_oid oid;
1768 if( git_oid_fromstr( &oid, aHash.mb_str().data() ) != 0 )
1769 {
1770 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Invalid hash %s" ), aHash );
1771 return false;
1772 }
1773
1774 git_commit* commit = nullptr;
1775 if( git_commit_lookup( &commit, repo, &oid ) != 0 )
1776 {
1777 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Commit not found %s" ), aHash );
1778 return false;
1779 }
1780
1781 git_tree* tree = nullptr;
1782 git_commit_tree( &tree, commit );
1783
1784 // Create pre-restore backup snapshot using the existing lock
1785 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Creating pre-restore backup" ) );
1786
1787 std::vector<wxString> backupFiles;
1788 collectProjectFiles( aProjectPath, backupFiles );
1789
1790 if( !backupFiles.empty() )
1791 {
1792 wxString hist = historyPath( aProjectPath );
1793 if( !commitSnapshotWithLock( repo, lock.GetIndex(), hist, aProjectPath, backupFiles,
1794 wxS( "Pre-restore backup" ) ) )
1795 {
1796 wxLogTrace( traceAutoSave,
1797 wxS( "[history] RestoreCommit: Failed to create pre-restore backup" ) );
1798 git_tree_free( tree );
1799 git_commit_free( commit );
1800 return false;
1801 }
1802 }
1803
1804 // STEP 3: Extract commit to temporary location
1805 wxString tempRestorePath = aProjectPath + wxS("_restore_temp");
1806
1807 if( wxDirExists( tempRestorePath ) )
1808 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
1809
1810 if( !wxFileName::Mkdir( tempRestorePath, 0777, wxPATH_MKDIR_FULL ) )
1811 {
1812 wxLogTrace( traceAutoSave,
1813 wxS( "[history] RestoreCommit: Failed to create temp directory %s" ),
1814 tempRestorePath );
1815 git_tree_free( tree );
1816 git_commit_free( commit );
1817 return false;
1818 }
1819
1820 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extracting to temp location %s" ),
1821 tempRestorePath );
1822
1823 if( !extractCommitToTemp( repo, tree, tempRestorePath ) )
1824 {
1825 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Extraction failed, cleaning up" ) );
1826 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
1827 git_tree_free( tree );
1828 git_commit_free( commit );
1829 return false;
1830 }
1831
1832 // STEP 4: Determine which files will be deleted and ask for confirmation
1833 std::set<wxString> restoredFiles;
1834 collectFilesInDirectory( tempRestorePath, tempRestorePath, restoredFiles );
1835
1836 std::vector<wxString> filesToDelete;
1837 findFilesToDelete( aProjectPath, restoredFiles, filesToDelete );
1838
1839 bool keepAllFiles = true;
1840 if( !confirmFileDeletion( aParent, filesToDelete, keepAllFiles ) )
1841 {
1842 // User cancelled
1843 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
1844 git_tree_free( tree );
1845 git_commit_free( commit );
1846 return false;
1847 }
1848
1849 // STEP 5: Perform atomic swap - backup current, move temp to current
1850 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Performing atomic swap" ) );
1851
1852 wxString backupPath = aProjectPath + wxS("_restore_backup");
1853
1854 // Remove old backup if exists
1855 if( wxDirExists( backupPath ) )
1856 {
1857 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Removing old backup %s" ),
1858 backupPath );
1859 wxFileName::Rmdir( backupPath, wxPATH_RMDIR_RECURSIVE );
1860 }
1861
1862 // Track which files we moved to backup and restored (for rollback)
1863 std::set<wxString> backedUpFiles;
1864 std::set<wxString> restoredFilesSet;
1865
1866 // Backup current files
1867 if( !backupCurrentFiles( aProjectPath, backupPath, tempRestorePath, keepAllFiles,
1868 backedUpFiles ) )
1869 {
1870 rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
1871 restoredFilesSet );
1872 git_tree_free( tree );
1873 git_commit_free( commit );
1874 return false;
1875 }
1876
1877 // Restore files from temp
1878 if( !restoreFilesFromTemp( tempRestorePath, aProjectPath, restoredFilesSet ) )
1879 {
1880 rollbackRestore( aProjectPath, backupPath, tempRestorePath, backedUpFiles,
1881 restoredFilesSet );
1882 git_tree_free( tree );
1883 git_commit_free( commit );
1884 return false;
1885 }
1886
1887 // SUCCESS - Clean up temp and backup directories
1888 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Restore successful, cleaning up" ) );
1889 wxFileName::Rmdir( tempRestorePath, wxPATH_RMDIR_RECURSIVE );
1890 wxFileName::Rmdir( backupPath, wxPATH_RMDIR_RECURSIVE );
1891
1892 // Record the restore in history
1893 recordRestoreInHistory( repo, commit, tree, aHash );
1894
1895 git_tree_free( tree );
1896 git_commit_free( commit );
1897
1898 wxLogTrace( traceAutoSave, wxS( "[history] RestoreCommit: Complete" ) );
1899 return true;
1900}
1901
1902void LOCAL_HISTORY::ShowRestoreDialog( const wxString& aProjectPath, wxWindow* aParent )
1903{
1904 if( !HistoryExists( aProjectPath ) )
1905 return;
1906
1907 wxString hist = historyPath( aProjectPath );
1908 git_repository* repo = nullptr;
1909
1910 if( git_repository_open( &repo, hist.mb_str().data() ) != 0 )
1911 return;
1912
1913 git_revwalk* walk = nullptr;
1914 git_revwalk_new( &walk, repo );
1915 git_revwalk_push_head( walk );
1916
1917 std::vector<wxString> choices;
1918 std::vector<wxString> hashes;
1919 git_oid oid;
1920
1921 while( git_revwalk_next( &oid, walk ) == 0 )
1922 {
1923 git_commit* commit = nullptr;
1924 git_commit_lookup( &commit, repo, &oid );
1925
1926 git_time_t t = git_commit_time( commit );
1927 wxDateTime dt( (time_t) t );
1928 wxString line;
1929
1930 line.Printf( wxS( "%s %s" ), dt.FormatISOCombined().c_str(),
1931 wxString::FromUTF8( git_commit_summary( commit ) ) );
1932 choices.push_back( line );
1933 hashes.push_back( wxString::FromUTF8( git_oid_tostr_s( &oid ) ) );
1934 git_commit_free( commit );
1935 }
1936
1937 git_revwalk_free( walk );
1938 git_repository_free( repo );
1939
1940 if( choices.empty() )
1941 return;
1942
1943 int index = wxGetSingleChoiceIndex( _( "Select snapshot" ), _( "Restore" ),
1944 (int) choices.size(), &choices[0], aParent );
1945
1946 if( index != wxNOT_FOUND )
1947 RestoreCommit( aProjectPath, hashes[index] );
1948}
1949
int index
const char * name
Hybrid locking mechanism for local history git repositories.
git_repository * GetRepository()
Get the git repository handle (only valid if IsLocked() returns true).
wxString GetLockError() const
Get error message describing why lock could not be acquired.
git_index * GetIndex()
Get the git index handle (only valid if IsLocked() returns true).
bool IsLocked() const
Check if locks were successfully acquired.
bool TagSave(const wxString &aProjectPath, const wxString &aFileType)
Tag a manual save in the local history repository.
wxString GetHeadHash(const wxString &aProjectPath)
Return the current head commit hash.
bool RestoreCommit(const wxString &aProjectPath, const wxString &aHash, wxWindow *aParent=nullptr)
Restore the project files to the state recorded by the given commit hash.
void ShowRestoreDialog(const wxString &aProjectPath, wxWindow *aParent)
Show a dialog allowing the user to choose a snapshot to restore.
bool HeadNewerThanLastSave(const wxString &aProjectPath)
Return true if the autosave data is newer than the last manual save.
std::set< wxString > m_pendingFiles
bool CommitDuplicateOfLastSave(const wxString &aProjectPath, const wxString &aFileType, const wxString &aMessage)
Create a new commit duplicating the tree pointed to by Last_Save_<fileType> and move the Last_Save_<f...
bool EnforceSizeLimit(const wxString &aProjectPath, size_t aMaxBytes)
Enforce total size limit by rebuilding trimmed history keeping newest commits whose cumulative unique...
bool Init(const wxString &aProjectPath)
Initialize the local history repository for the given project path.
void ClearAllSavers()
Clear all registered savers.
bool CommitSnapshot(const std::vector< wxString > &aFiles, const wxString &aTitle)
Commit the given files to the local history repository.
std::map< const void *, std::function< void(const wxString &, std::vector< wxString > &)> > m_savers
bool RunRegisteredSaversAndCommit(const wxString &aProjectPath, const wxString &aTitle)
Run all registered savers and, if any staged changes differ from HEAD, create a commit.
void NoteFileChange(const wxString &aFile)
Record that a file has been modified and should be included in the next snapshot.
bool CommitPending()
Commit any pending modified files to the history repository.
bool HistoryExists(const wxString &aProjectPath)
Return true if history exists for the project.
bool CommitFullProjectSnapshot(const wxString &aProjectPath, const wxString &aTitle)
Commit a snapshot of the entire project directory (excluding the .history directory and ignored trans...
void UnregisterSaver(const void *aSaverObject)
Unregister a previously registered saver callback.
void RegisterSaver(const void *aSaverObject, const std::function< void(const wxString &, std::vector< wxString > &)> &aSaver)
Register a saver callback invoked during autosave history commits.
This file is part of the common library.
#define KICAD_MESSAGE_DIALOG
Definition confirm.h:52
#define _(s)
static const std::string LockFileExtension
static const std::string LockFilePrefix
const wxChar *const traceAutoSave
Flag to enable auto save feature debug tracing.
static wxString historyPath(const wxString &aProjectPath)
static wxString historyPath(const wxString &aProjectPath)
static size_t dirSizeRecursive(const wxString &path)
static bool commitSnapshotWithLock(git_repository *repo, git_index *index, const wxString &aHistoryPath, const wxString &aProjectPath, const std::vector< wxString > &aFiles, const wxString &aTitle)
static void collectProjectFiles(const wxString &aProjectPath, std::vector< wxString > &aFiles)
File locking utilities.
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
std::string path
wxString result
Test unit parsing edge cases and error handling.
int delta
wxLogTrace helper definitions.
Definition of file extensions used in Kicad.