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